diff options
Diffstat (limited to 'docs/features/scripting.rst')
| -rw-r--r-- | docs/features/scripting.rst | 427 |
1 files changed, 413 insertions, 14 deletions
diff --git a/docs/features/scripting.rst b/docs/features/scripting.rst index f92942be..8e005622 100644 --- a/docs/features/scripting.rst +++ b/docs/features/scripting.rst @@ -84,6 +84,28 @@ command. Here's a simple example that uses the arg_printer_ script:: system paths, and as shown above it has the ability to pass command-line arguments to the scripts invoked. +Developing a CMD2 API +--------------------- + +If you as an app designer have not explicitly disabled the run_pyscript command it must be assumed +that your application is structured for use in higher level python scripting. The following sections +are meant as guidelines and highlight possible pitfalls with both production and consumption +of API functionality. For clarity when speaking of "scripter" we are referring to those writing +scripts to be run by pyscript and "designer" as the CMD2 application author. + +Basics +~~~~~~ + +Without any work on the part of the designer, a scripter can take advantage of piecing together workflows +using simple ``app`` calls. The result of a run_pyscript app call yields a ``CommandResult`` object exposing +four members: ``Stdout``, ``Stderr``, ``Stop``, and ``Data``. + +``Stdout`` and ``Stderr`` are fairly straightforward representations of normal data streams and accurately reflect +what is seen by the user during normal cmd2 interaction. ``Stop`` contains information about how the invoked +command has ended its lifecycle. Lastly ``Data`` contains any information the designer sets via ``self.last_result`` +or ``self._cmd.last_result`` if called from inside a CommandSet. + + Python scripts executed with :ref:`features/builtin_commands:run_pyscript` can run ``cmd2`` application commands by using the syntax:: @@ -96,29 +118,406 @@ where: * ``command`` and ``args`` are entered exactly like they would be entered by a user of your application. +Using fstrings tends to be the most straight forward and easily readable way to +provide parameters.:: + + first = 'first' + second = 'second' + + app(f'command {first} -t {second}) + + See python_scripting_ example and associated conditional_ script for more information. -Advanced Support -~~~~~~~~~~~~~~~~ -When implementing a command, setting ``self.last_result`` allows for application-specific -data to be returned to a python script from the command. This can allow python scripts to -make decisions based on the result of previous application commands. +Design principles +~~~~~~~~~~~~~~~~~ +If the cmd2 application follows the unix_design_philosophy_ a scriptor will have the most flexibility +to piece together workflows using different commands. If the designers' application is more complete +and less likely to be augmented in the future a scripter may opt for simple serial scripts with little +control flow. In either case, choices made by the designer will have effects on scripters. + +The following diagram illustrates the different boundaries to keep in mind. + + +:: + + +---------------------------------------------+ + | | + | Py scripts | + | | + | +-----------------------------------------+ | + | | CMD2 Application | | + | | | | + | | +-------------------------------------+ | | + | | | Class Library | | | + | | | +------+ +------+ +------+ +------+ | | | + | | | | | | | | | | | | | | + | | | | C | | C | | C | | C | | | | + | | | | | | | | | | | | | | + | | | +------+ +------+ +------+ +------+ | | | + | | | | | | + | | +-------------------------------------+ | | + | | | | + | +-----------------------------------------+ | + | | + +---------------------------------------------+ + +.. note:: + + As a designer it is preferable to design from the inside to out. Your code will be + infinitely far easier to unit test than at the higher level. While there are + regression testing extensions for cmd2 UnitTesting will always be faster for development. + +.. warning:: + + It is bad design or a high level py_script to know about let alone access low level class + libraries of an application. Resist this urge at all costs, unless it's necessary. + +Developing a Basic API +~~~~~~~~~~~~~~~~~~~~~~ + +CMD2 out of the box allows scripters to take advantage of all exposed ``do_*`` commands. As a +scripter one can easily interact with the application via ``stdout`` and ``stderr``. + +As a baseline lets start off with the familiar FirstApp + +:: + + #!/usr/bin/env python + """A simple cmd2 application.""" + import cmd2 + + + class FirstApp(cmd2.Cmd): + """A simple cmd2 application.""" + def __init__(self): + shortcuts = cmd2.DEFAULT_SHORTCUTS + shortcuts.update({'&': 'speak'}) + super().__init__(shortcuts=shortcuts) + + # Make maxrepeats settable at runtime + self.maxrepeats = 3 + self.add_settable(cmd2.Settable('maxrepeats', int, 'max repetitions for speak command', self)) + + + speak_parser = cmd2.Cmd2ArgumentParser() + speak_parser.add_argument('-p', '--piglatin', action='store_true', help='atinLay') + speak_parser.add_argument('-s', '--shout', action='store_true', help='N00B EMULATION MODE') + speak_parser.add_argument('-r', '--repeat', type=int, help='output [n] times') + speak_parser.add_argument('words', nargs='+', help='words to say') + + @cmd2.with_argparser(speak_parser) + def do_speak(self, args): + """Repeats what you tell me to.""" + words = [] + for word in args.words: + if args.piglatin: + word = '%s%say' % (word[1:], word[0]) + if args.shout: + word = word.upper() + words.append(word) + repetitions = args.repeat or 1 + for _ in range(min(repetitions, self.maxrepeats)): + # .poutput handles newlines, and accommodates output redirection too + self.poutput(' '.join(words)) + + if __name__ == '__main__': + import sys + c = FirstApp() + sys.exit(c.cmdloop()) + + +Lets start off on the wrong foot:: + + app('speak' + print('Working') + +:: + + SyntaxError: unexpected EOF while parsing + (Cmd) run_pyscript script.py + File "<string>", line 2 + app('speak' + ^ + SyntaxError: unexpected EOF while parsing + +cmd2 pyscripts require **valid** python code as a first step. + +.. warning:: + + It is a common misconception that all application exceptions will "bubble" up from below. Unfortunately or fortunately + this is not the case. CMD2 sinkholes all application exceptions and there are no means to handle them. + + +When executing the ``speak`` command without parameters you see the following error:: + + (Cmd) speak + Usage: speak [-h] [-p] [-s] [-r REPEAT] words [...] + Error: the following arguments are required: words + +Even though this is a fully qualified CMD2 error the py_script must look for this error and perform error checking.:: + + app('speak') + print("Working") + +:: + + (Cmd) run_pyscript script.py + Working + (Cmd) + +You should notice that no error message is printed. Let's utilize the ``CommandResult`` +object to inspect the actual returned data.:: + + result = app('speak') + print(result) + +:: + + (Cmd) run_pyscript script.py + CommandResult(stdout='', stderr='Usage: speak [-h] [-p] [-s] [-r REPEAT] words [...]\nError: the following arguments are required: words\n\n', stop=False, data=None) + +Now we can see that there has been an error. Let's re write the script to perform error checking.:: + + result = app('speak') + + if not result: + print(result.stderr) + +:: + + (Cmd) run_pyscript script.py + Something went wrong + +In python development is good practice to fail and exit quickly after user input.:: + + import sys + + result = app('speak TRUTH!!') + + if not result: + print("Something went wrong") + sys.exit() + + print("Continuing along..") + +:: + + (Cmd) run_pyscript script.py + Continuing along.. + +We changed the input to be a valid ``speak`` command but no output. Again we must inspect the +``CommandResult``:: + + import sys + + #Syntax error + result = app('speak TRUTH!!!') + if not result: + print("Something went wrong") + sys.exit() -The application command (default: ``app``) returns a ``cmd2.CommandResult`` for each command. -The ``cmd2.CommandResult`` object provides the captured output to ``stdout`` and ``stderr`` -while a command is executing. Additionally, it provides the value that command stored in -``self.last_result``. + print(result.stdout) + +:: + + (Cmd) run_pyscript script.py + TRUTH!!! + +By just using ``stdout`` and ``stderr`` it is possible to string together commands +with rudimentary control flow. In the next section we will show how to take advantage of +cmd_result data. + +Developing an Advanced API +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Until now the application designer has paid little attention to scripters and their needs. +Wouldn't it be nice if while creating py_scripts one did not have to parse data from ``stdout``? We can +accomodate the weary scripter by adding one small line at the end of our ``do_*`` commands. + +``self.last_result = <value>`` + +Adding the above line supercharges a cmd2 application and opens a new world of possibilities. + +.. note:: + + When setting results for a command function inside of a CommandSet use the private cmd instance:: + + self._cmd.last_result = <value> + + +In the following command example we return an array containing directory elements.:: + + dir_parser = cmd2.Cmd2ArgumentParser() + dir_parser.add_argument('-l', '--long', action='store_true', help="display in long format with one item per line") + + @cmd2.with_argparser(dir_parser, with_unknown_args=True) + def do_dir(self, args, unknown): + """List contents of current directory.""" + # No arguments for this command + if unknown: + self.perror("dir does not take any positional arguments:") + self.do_help('dir') + return + + # Get the contents as a list + contents = os.listdir(self.cwd) + + for f in contents: + self.poutput(f'{f}') + self.poutput('') + + self.last_result = contents + +The following script retrieves the array contents.:: + + result = app('dir') + print(result.data) + +Results:: + + Cmd) run_pyscript script.py + ['.venv', 'app.py', 'script.py'] + +As a rule of thumb it is safer for the designer to return simple scalar types as command results instead of complex objects. +If there is benefit in providing class objects designers should choose immutable over mutable types and never +provide direct access to class members as this could potentially lead to violation of the open_closed_principle_. + +When possible, a dataclass is a lightweight solution perfectly suited for data manipulation. Lets dive into an +example. + +The following fictitional application has two commands: ``build`` and ``status``. We can pretend that the build action +happens somewhere else in the world at an REST API endpoint and has significant computational cost. The status command +for all intents and purposes will only show the current status of a build task. The application has provided all that is +needed for a user to start a build and then determine it's status. The problem however is that with a long running process +the user may want to wait for it to finish. A designer may be tempted to create a command to start a build and then +poll for status until finished but this scenario is better solved as an extensible script. + +app.py:: + + #!/usr/bin/env python + """A simple cmd2 application.""" + import sys + from dataclasses import dataclass + from random import choice, randint + from typing import Optional + + import cmd2 + from cmd2.parsing import Statement + + + @dataclass(frozen=True) + class BuildStatus: + id: int + name: str + status: str + + + class FirstApp(cmd2.Cmd): + """A simple cmd2 application.""" + + def __init__(self): + self._status_cache = dict() + + def _start_build(self, name: str) -> BuildStatus: + return BuildStatus(randint(10, 100), name, "Started") + + def _get_status(self, name: str) -> Optional[BuildStatus]: + + status = self._status_cache.get(name) + + status_types = ["canceled", "restarted", "error", "finished"] + + if status.status != "finished": + status = BuildStatus(status.id, status.name, choice(status_types)) + self._status_cache[name] = status + + return status + + build_parser = cmd2.Cmd2ArgumentParser() + build_parser.add_argument("name", help="Name of build to start") + + @cmd2.with_argparser(build_parser) + def do_build(self, args: Statement): + """Executes a long running process at an API endpoint""" + status = self._start_build(args.name) + self._status_cache[args.name] = status + + self.poutput( + f"Build {args.name.upper()} successfuly stared with id : {status.id}" + ) + self.last_result = status + + status_parser = cmd2.Cmd2ArgumentParser() + status_parser.add_argument("name", help="Name of build determine status of") + + @cmd2.with_argparser(status_parser) + def do_status(self, args: Statement): + """Shows the current status of a build""" + + status = self._get_status(args.name) + + self.poutput(f"Status for Build: {args.name} \n {status.status}") + self.last_result = status + + + if __name__ == "__main__": + import sys + + c = FirstApp() + sys.exit(c.cmdloop()) + + +The below is a possible solution via pyscript:: + + import sys + import time + + # start build + result = app('build tower') + + # If there was an error then quit now + if not result: + print('Build failed') + sys.exit() + + # This is a BuildStatus dataclass object + build = result.data + + print(f"Build {build.name} : {build.status}") + + # Poll status (it would be wise to NOT hang here) + while True: + + # Perform status check + result = app('status tower') + + #error checking + if not result: + print("Unable to determin status") + break + + build_status = result.data + + # If the status shows complete then we are done + if build_status.status in ['finished', 'canceled']: + print(f"Build {build.name} has completed") + break + + print(f"Current Status: {build_status.status}") + time.sleep(1) -Additionally, an external test Mixin plugin has been provided to allow for python based -external testing of the application. For example, for system integration tests scenarios -where the python application is a component of a larger suite of tools and components. This -interface allows python based tests to call commands and validate results as part of a -larger test suite. See :ref:`plugins/external_test:External Test Plugin` .. _python_scripting: https://github.com/python-cmd2/cmd2/blob/master/examples/python_scripting.py .. _conditional: https://github.com/python-cmd2/cmd2/blob/master/examples/scripts/conditional.py + +.. _unix_design_philosophy: + https://en.wikipedia.org/wiki/Unix_philosophy + +.. _open_closed_principle: + https://en.wikipedia.org/wiki/Open%E2%80%93closed_principle
\ No newline at end of file |
