├── aiocmd ├── __init__.py ├── nested_completer.py └── aiocmd.py ├── MANIFEST.in ├── docs ├── image1.png ├── image2.png └── example.py ├── setup.py ├── LICENSE └── README.md /aiocmd/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE README.md 2 | -------------------------------------------------------------------------------- /docs/image1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KimiNewt/aiocmd/HEAD/docs/image1.png -------------------------------------------------------------------------------- /docs/image2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KimiNewt/aiocmd/HEAD/docs/image2.png -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup(name='aiocmd', 4 | packages=find_packages("."), 5 | version='0.1.5', 6 | author='Dor Green', 7 | author_email='dorgreen1@gmail.com', 8 | description='Coroutine-based CLI generator using prompt_toolkit', 9 | url='http://github.com/KimiNewt/aiocmd', 10 | keywords=['asyncio', 'cmd'], 11 | license='MIT', 12 | install_requires=[ 13 | 'prompt_toolkit>=2.0.9', 'packaging' 14 | ], 15 | classifiers=[ 16 | 'License :: OSI Approved :: MIT License', 17 | 18 | 'Programming Language :: Python :: 3', 19 | 'Programming Language :: Python :: 3.5', 20 | 'Programming Language :: Python :: 3.6', 21 | 'Programming Language :: Python :: 3.7' 22 | ]) 23 | -------------------------------------------------------------------------------- /docs/example.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from prompt_toolkit.completion import WordCompleter 4 | 5 | from aiocmd import aiocmd 6 | 7 | 8 | class MyCLI(aiocmd.PromptToolkitCmd): 9 | 10 | def __init__(self, my_name="My CLI"): 11 | super().__init__() 12 | self.prompt = "%s $ " % my_name 13 | self.aliases["nap"] = "sleep" 14 | 15 | def do_my_action(self): 16 | """This will appear in help text""" 17 | print("You ran my action!") 18 | 19 | def do_add(self, x, y): 20 | print(int(x) + int(y)) 21 | 22 | def do_echo(self, to_echo): 23 | print(to_echo) 24 | 25 | async def do_sleep(self, sleep_time=1): 26 | await asyncio.sleep(int(sleep_time)) 27 | 28 | def _add_completions(self): 29 | return WordCompleter([str(i) for i in range(9)]) 30 | 31 | def _sleep_completions(self): 32 | return WordCompleter([str(i) for i in range(1, 60)]) 33 | 34 | 35 | if __name__ == "__main__": 36 | asyncio.get_event_loop().run_until_complete(MyCLI().run()) 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Dor Green 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aiocmd 2 | Coroutine-based CLI generator using prompt_toolkit, similarly to the built-in cmd module. 3 | 4 | ## How to install? 5 | Simply use `pip3 install aiocmd` 6 | 7 | ## How to use? 8 | To use, inherit from the `PromptToolkitCmd` class and implement the `do_` for each command. 9 | 10 | Each command can receive arguments and optional (keyword) arguments. You then must run the `run()` coroutine to start the CLI. 11 | 12 | For instance: 13 | ```python 14 | import asyncio 15 | 16 | from aiocmd import aiocmd 17 | 18 | 19 | class MyCLI(aiocmd.PromptToolkitCmd): 20 | 21 | def do_my_action(self): 22 | """This will appear in help text""" 23 | print("You ran my action!") 24 | 25 | def do_add(self, x, y): 26 | print(int(x) + int(y)) 27 | 28 | async def do_sleep(self, sleep_time=1): 29 | await asyncio.sleep(int(sleep_time)) 30 | 31 | 32 | if __name__ == "__main__": 33 | asyncio.get_event_loop().run_until_complete(MyCLI().run()) 34 | ``` 35 | 36 | Will create this CLI: 37 | 38 | ![CLIImage](./docs/image1.png) 39 | 40 | ## Extra features 41 | 42 | You can implement a custom completion for each command by implementing `__completions`. 43 | 44 | For example, to complete a single-digit number for the `add` action: 45 | 46 | ```python 47 | class MyCLI(aiocmd.PromptToolkitCmd): 48 | 49 | def _add_completions(self): 50 | return WordCompleter([str(i) for i in range(9)]) 51 | ``` 52 | 53 | ![CLIImage](./docs/image2.png) 54 | 55 | You can also set a custom `prompt` and `aliases` parameters for the class (example in docs). 56 | -------------------------------------------------------------------------------- /aiocmd/nested_completer.py: -------------------------------------------------------------------------------- 1 | """ 2 | Nestedcompleter for completion of hierarchical data structures. 3 | """ 4 | from typing import Dict, Iterable, Mapping, Optional, Set, Union 5 | 6 | from prompt_toolkit.completion import CompleteEvent, Completer, Completion 7 | from prompt_toolkit.completion.word_completer import WordCompleter 8 | from prompt_toolkit.document import Document 9 | 10 | __all__ = [ 11 | 'NestedCompleter' 12 | ] 13 | 14 | NestedDict = Mapping[str, Union['NestedDict', Set[str], None, Completer]] 15 | 16 | 17 | class NestedCompleter(Completer): 18 | """ 19 | Completer which wraps around several other completers, and calls any the 20 | one that corresponds with the first word of the input. 21 | By combining multiple `NestedCompleter` instances, we can achieve multiple 22 | hierarchical levels of autocompletion. This is useful when `WordCompleter` 23 | is not sufficient. 24 | If you need multiple levels, check out the `from_nested_dict` classmethod. 25 | """ 26 | def __init__(self, options: Dict[str, Optional[Completer]], 27 | ignore_case: bool = True) -> None: 28 | 29 | self.options = options 30 | self.ignore_case = ignore_case 31 | 32 | def __repr__(self) -> str: 33 | return 'NestedCompleter(%r, ignore_case=%r)' % (self.options, self.ignore_case) 34 | 35 | @classmethod 36 | def from_nested_dict(cls, data: NestedDict) -> 'NestedCompleter': 37 | """ 38 | Create a `NestedCompleter`, starting from a nested dictionary data 39 | structure, like this: 40 | .. code:: 41 | data = { 42 | 'show': { 43 | 'version': None, 44 | 'interfaces': None, 45 | 'clock': None, 46 | 'ip': {'interface': {'brief'}} 47 | }, 48 | 'exit': None 49 | 'enable': None 50 | } 51 | The value should be `None` if there is no further completion at some 52 | point. If all values in the dictionary are None, it is also possible to 53 | use a set instead. 54 | Values in this data structure can be a completers as well. 55 | """ 56 | options = {} 57 | for key, value in data.items(): 58 | if isinstance(value, Completer): 59 | options[key] = value 60 | elif isinstance(value, dict): 61 | options[key] = cls.from_nested_dict(value) 62 | elif isinstance(value, set): 63 | options[key] = cls.from_nested_dict({item: None for item in value}) 64 | else: 65 | assert value is None 66 | options[key] = None 67 | 68 | return cls(options) 69 | 70 | def get_completions(self, document: Document, 71 | complete_event: CompleteEvent) -> Iterable[Completion]: 72 | # Split document. 73 | text = document.text_before_cursor.lstrip() 74 | 75 | # If there is a space, check for the first term, and use a 76 | # subcompleter. 77 | if ' ' in text: 78 | first_term = text.split()[0] 79 | completer = self.options.get(first_term) 80 | 81 | # If we have a sub completer, use this for the completions. 82 | if completer is not None: 83 | remaining_text = document.text[len(first_term):].lstrip() 84 | move_cursor = len(document.text) - len(remaining_text) 85 | 86 | new_document = Document( 87 | remaining_text, 88 | cursor_position=document.cursor_position - move_cursor) 89 | 90 | for c in completer.get_completions(new_document, complete_event): 91 | yield c 92 | 93 | # No space in the input: behave exactly like `WordCompleter`. 94 | else: 95 | completer = WordCompleter(list(self.options.keys()), ignore_case=self.ignore_case) 96 | for c in completer.get_completions(document, complete_event): 97 | yield c 98 | -------------------------------------------------------------------------------- /aiocmd/aiocmd.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import inspect 3 | import shlex 4 | import signal 5 | import sys 6 | 7 | import packaging.version 8 | import prompt_toolkit 9 | from prompt_toolkit import PromptSession 10 | from prompt_toolkit.completion import WordCompleter 11 | from prompt_toolkit.key_binding import KeyBindings 12 | from prompt_toolkit.patch_stdout import patch_stdout 13 | 14 | try: 15 | from prompt_toolkit.completion.nested import NestedCompleter 16 | except ImportError: 17 | from aiocmd.nested_completer import NestedCompleter 18 | 19 | 20 | def _is_prompt_toolkit3(): 21 | return packaging.version.parse(prompt_toolkit.__version__) >= packaging.version.parse("3.0") 22 | 23 | 24 | class ExitPromptException(Exception): 25 | pass 26 | 27 | 28 | class PromptToolkitCmd: 29 | """Baseclass for custom CLIs 30 | 31 | Works similarly to the built-in Cmd class. You can inherit from this class and implement: 32 | - do_ - This will add the "" command to the cli. 33 | The method may receive arguments (required) and keyword arguments (optional). 34 | - __completions - Returns a custom Completer class to use as a completer for this action. 35 | Additionally, the user cant change the "prompt" variable to change how the prompt looks, and add 36 | command aliases to the 'aliases' dict. 37 | """ 38 | ATTR_START = "do_" 39 | prompt = "$ " 40 | doc_header = "Documented commands:" 41 | aliases = {"?": "help", "exit": "quit"} 42 | 43 | def __init__(self, ignore_sigint=True): 44 | self.completer = self._make_completer() 45 | self.session = None 46 | self._ignore_sigint = ignore_sigint 47 | self._currently_running_task = None 48 | 49 | async def run(self): 50 | if self._ignore_sigint and sys.platform != "win32": 51 | asyncio.get_event_loop().add_signal_handler(signal.SIGINT, self._sigint_handler) 52 | self.session = PromptSession(enable_history_search=True, key_bindings=self._get_bindings()) 53 | try: 54 | with patch_stdout(): 55 | await self._run_prompt_forever() 56 | finally: 57 | if self._ignore_sigint and sys.platform != "win32": 58 | asyncio.get_event_loop().remove_signal_handler(signal.SIGINT) 59 | self._on_close() 60 | 61 | async def _run_prompt_forever(self): 62 | while True: 63 | try: 64 | if _is_prompt_toolkit3(): 65 | result = await self.session.prompt_async(self.prompt, completer=self.completer) 66 | else: 67 | # This is done because old versions of prompt toolkit don't support Python 3.5. 68 | # When we deprecate 3.5, this can be removed. 69 | from prompt_toolkit.eventloop import use_asyncio_event_loop 70 | use_asyncio_event_loop() 71 | result = await self.session.prompt(self.prompt, async_=True, completer=self.completer) 72 | except EOFError: 73 | return 74 | 75 | if not result: 76 | continue 77 | args = shlex.split(result) 78 | if args[0] in self.command_list: 79 | try: 80 | self._currently_running_task = asyncio.ensure_future( 81 | self._run_single_command(args[0], args[1:])) 82 | await self._currently_running_task 83 | except asyncio.CancelledError: 84 | print() 85 | continue 86 | except ExitPromptException: 87 | return 88 | else: 89 | print("Command %s not found!" % args[0]) 90 | 91 | def _sigint_handler(self): 92 | if self._currently_running_task: 93 | self._currently_running_task.cancel() 94 | 95 | def _get_bindings(self): 96 | bindings = KeyBindings() 97 | bindings.add("c-c")(lambda event: self._interrupt_handler(event)) 98 | return bindings 99 | 100 | async def _run_single_command(self, command, args): 101 | command_real_args, command_real_kwargs = self._get_command_args(command) 102 | if len(args) < len(command_real_args) or len(args) > (len(command_real_args) 103 | + len(command_real_kwargs)): 104 | print("Bad command args. Usage: %s" % self._get_command_usage(command, command_real_args, 105 | command_real_kwargs)) 106 | return 107 | 108 | try: 109 | com_func = self._get_command(command) 110 | if asyncio.iscoroutinefunction(com_func): 111 | await com_func(*args) 112 | else: 113 | com_func(*args) 114 | return 115 | except (ExitPromptException, asyncio.CancelledError): 116 | raise 117 | except Exception as ex: 118 | print("Command failed: ", ex) 119 | 120 | def _interrupt_handler(self, event): 121 | event.cli.current_buffer.text = "" 122 | 123 | def _make_completer(self): 124 | return NestedCompleter({com: self._completer_for_command(com) for com in self.command_list}) 125 | 126 | def _completer_for_command(self, command): 127 | if not hasattr(self, "_%s_completions" % command): 128 | return WordCompleter([]) 129 | return getattr(self, "_%s_completions" % command)() 130 | 131 | def _get_command(self, command): 132 | if command in self.aliases: 133 | command = self.aliases[command] 134 | return getattr(self, self.ATTR_START + command) 135 | 136 | def _get_command_args(self, command): 137 | args = [param for param in inspect.signature(self._get_command(command)).parameters.values() 138 | if param.default == param.empty] 139 | kwargs = [param for param in inspect.signature(self._get_command(command)).parameters.values() 140 | if param.default != param.empty] 141 | return args, kwargs 142 | 143 | def _get_command_usage(self, command, args, kwargs): 144 | return ("%s %s %s" % (command, 145 | " ".join("<%s>" % arg for arg in args), 146 | " ".join("[%s]" % kwarg for kwarg in kwargs), 147 | )).strip() 148 | 149 | @property 150 | def command_list(self): 151 | return [attr[len(self.ATTR_START):] 152 | for attr in dir(self) if attr.startswith(self.ATTR_START)] + list(self.aliases.keys()) 153 | 154 | def do_help(self): 155 | print() 156 | print(self.doc_header) 157 | print("=" * len(self.doc_header)) 158 | print() 159 | 160 | get_usage = lambda command: self._get_command_usage(command, *self._get_command_args(command)) 161 | max_usage_len = max([len(get_usage(command)) for command in self.command_list]) 162 | for command in sorted(self.command_list): 163 | command_doc = self._get_command(command).__doc__ 164 | print(("%-" + str(max_usage_len + 2) + "s%s") % (get_usage(command), command_doc or "")) 165 | 166 | def do_quit(self): 167 | """Exit the prompt""" 168 | raise ExitPromptException() 169 | 170 | def _on_close(self): 171 | """Optional hook to call on closing the cmd""" 172 | pass 173 | --------------------------------------------------------------------------------