├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md └── src ├── Makefile ├── cmdtree ├── __init__.py ├── _compat.py ├── exceptions.py ├── parser.py ├── registry.py ├── shortcuts.py ├── tests │ ├── __init__.py │ ├── functional │ │ ├── __init__.py │ │ ├── test_command.py │ │ └── test_group.py │ └── unittest │ │ ├── __init__.py │ │ ├── test_parser.py │ │ ├── test_registry.py │ │ ├── test_shortcuts.py │ │ ├── test_tree.py │ │ └── test_types.py ├── tree.py └── types.py ├── examples ├── arg_types.py ├── command.py ├── command_group.py ├── global_argument.py └── low-level │ └── command_from_path.py ├── runtest.sh ├── setup.cfg ├── setup.py └── test-requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | # Mr.Project 2 | .idea 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | env/ 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *,cover 49 | .hypothesis/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | local_settings.py 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # IPython Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # dotenv 82 | .env 83 | 84 | # virtualenv 85 | venv/ 86 | ENV/ 87 | 88 | # Spyder project settings 89 | .spyderproject 90 | 91 | # Rope project settings 92 | .ropeproject 93 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - 2.6 4 | - 2.7 5 | - 3.5 6 | # command to install dependencies 7 | before_install: 8 | - pip install -r src/test-requirements.txt 9 | - pip install python-coveralls 10 | install: 11 | - cd src 12 | - python setup.py install 13 | # command to run tests 14 | script: py.test cmdtree --cov=cmdtree 15 | after_success: 16 | - coveralls -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Ji Qu 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 | CMDTree 2 | ------- 3 | [![PyPI version](https://badge.fury.io/py/cmdtree.svg)](https://badge.fury.io/py/cmdtree) 4 | [![Build Status](https://travis-ci.org/winkidney/cmdtree.svg?branch=master)](https://travis-ci.org/winkidney/cmdtree) 5 | [![Coverage Status](https://coveralls.io/repos/github/winkidney/cmdtree/badge.svg?branch=master)](https://coveralls.io/github/winkidney/cmdtree?branch=master) 6 | 7 | 8 | Yet another cli library for python, click-like but sub-command friendly 9 | and designed for cli auto-generating. 10 | 11 | Let's generate your command line tools from a cmd_path 12 | or just use shortcut decorators like `click`. 13 | 14 | 15 | ## Feature 16 | + Designed for cli auto-generating(make your commands by program) 17 | + Easy to use: works like `click` 18 | + Friendly low-level api(Use a tree and add your command by `command path`!) 19 | + no extra dependencies(only `six` is needed) 20 | + Python3 support 21 | + argument-type support 22 | + decorators has no side-effect on function call(call it in any other 23 | place in python style) 24 | 25 | ## Install 26 | run `pip install cmdtree` or clone the repo and use it. 27 | 28 | ## Run Test 29 | + `pip install -r test-requirements.txt` 30 | + `make test` or `py.test cmdtree` 31 | 32 | ## Quick Start 33 | 34 | **Note**: Follow the examples in folder `examples`. 35 | 36 | ### Hello world 37 | ```python 38 | from cmdtree import INT 39 | from cmdtree import command, argument, option 40 | 41 | 42 | @argument("host", help="server listen address") 43 | @option("reload", is_flag=True, help="if auto-reload on") 44 | @option("port", help="server port", type=INT, default=8888) 45 | @command(help="run a http server on given address") 46 | def run_server(host, reload, port): 47 | print( 48 | "Your server running on {host}:{port}, auto-reload is {reload}".format( 49 | host=host, 50 | port=port, 51 | reload=reload 52 | ) 53 | ) 54 | 55 | if __name__ == "__main__": 56 | from cmdtree import entry 57 | entry() 58 | ``` 59 | 60 | Get help 61 | ```bash 62 | ➜ examples git:(master) python command.py --help 63 | usage: command.py [-h] {run_server} ... 64 | 65 | positional arguments: 66 | {run_server} sub-commands 67 | run_server 68 | 69 | optional arguments: 70 | -h, --help show this help message and exit 71 | ``` 72 | 73 | Run command 74 | ```bash 75 | ➜ examples git:(master) python command.py run_server localhost 76 | Your server running on localhost:8888, auto-reload is False 77 | ``` 78 | 79 | 80 | ### SubCommand of SubCommand 81 | 82 | Code here: 83 | ```python 84 | from cmdtree import group, argument, entry 85 | 86 | @group("docker") 87 | @argument("ip") 88 | def docker(): 89 | pass 90 | 91 | 92 | # nested command 93 | @docker.command("run") 94 | @argument("container-name") 95 | def run(ip, container_name): 96 | print( 97 | "container [{name}] on host [{ip}]".format( 98 | ip=ip, 99 | name=container_name, 100 | ) 101 | ) 102 | 103 | # nested command group 104 | @docker.group("image") 105 | def image(): 106 | pass 107 | 108 | 109 | @image.command("create") 110 | @argument("name") 111 | def image_create(ip, name): 112 | print( 113 | "iamge {name} on {ip} created.".format( 114 | ip=ip, 115 | name=name, 116 | ) 117 | ) 118 | 119 | 120 | if __name__ == "__main__": 121 | entry() 122 | ``` 123 | 124 | Run command: 125 | ```bash 126 | ➜ examples git:(master) python command_group.py docker localhost image create your-docker 127 | iamge your-docker on localhost created. 128 | ``` 129 | 130 | 131 | ## Why `cmdtree`? 132 | Alternatives: 133 | + [`click`](http://click.pocoo.org/5/) from `pocoo` 134 | + `argparse` 135 | 136 | But when you should choose `cmdtree`? 137 | 138 | When you need: 139 | + fully sub-command support(not `group` in `click`) 140 | + Higher-level api support(compared to `argparse`) 141 | + More arg-type support(compared to `argparse`) 142 | + decorators has no side-effect on function call(compared to `click`) 143 | 144 | In both of them, you have to make implementation yourself. 145 | CmdTree works on this point. 146 | 147 | In most case , you can make your command `flat`. 148 | But when you need sub-command? 149 | 150 | I use it in my `schema-sugar` project, 151 | the project generate cli-tool from schema that describes REST-API. 152 | 153 | For example: 154 | You want to generate a `CRUD` commandline for http resources, 155 | 156 | ```bash 157 | # list the resource 158 | GET http://example.com/computer/disks 159 | # show one of the disk info 160 | GET http://example.com/computer/disks/1 161 | # delete 162 | DELETE http://example.com/computer/disks/1 163 | ``` 164 | 165 | I want to make a command line just like 166 | ```bash 167 | rest-cli computer disks list 168 | rest-cli computer disks delete 169 | rest-cli computer disks show 170 | ``` 171 | The `computer` is to used to make the resource `unique`, so I can not 172 | ensure that all of the commands could be made `flat`. 173 | 174 | `click` lacks the support for multiple-level sub-command. 175 | 176 | `argparse` has very low-level api(really makes me crazy). 177 | 178 | So I wrote `cmdtree` to handle this problem. Now I just wrote: 179 | ```python 180 | from cmdtree.tree import CmdTree 181 | 182 | tree = CmdTree() 183 | 184 | 185 | def index(): 186 | print("Hi, you have 10 disks in your computer...") 187 | 188 | 189 | def show(disk_id): 190 | print("This is disk %s" % disk_id) 191 | 192 | 193 | def delete(disk_id): 194 | print("disk %s deleted" % disk_id) 195 | 196 | 197 | # Add list command 198 | tree.add_commands(["computer", "list"], index) 199 | 200 | # get the parser in any place, any time 201 | tree.add_commands(["computer", "show"], show) 202 | tree_node = tree.get_cmd_by_path(["computer", "show"]) 203 | show_parser = tree_node['cmd'] 204 | show_parser.argument("disk_id") 205 | 206 | # Add delete command 207 | delete3 = tree.add_commands(["computer", "delete"], delete) 208 | delete3.argument("disk_id") 209 | 210 | # run your tree 211 | tree.root.run() 212 | ``` 213 | 214 | ## Change Log 215 | + 2016.09.22 Fix help-message missing in command and group 216 | + 2016.09.08 Global argument support 217 | 218 | 219 | ## Inspired by 220 | + `click` 221 | + `argparse` 222 | 223 | ## About 224 | 225 | Author: [winkidney@github](https://github.com/winkidney/) 226 | 227 | Repo: [GithubRepo](https://github.com/winkidney/cmdtree) 228 | 229 | Blog: [Blog](http://blog.winkidney.com) -------------------------------------------------------------------------------- /src/Makefile: -------------------------------------------------------------------------------- 1 | PYPI = https://pypi.python.org/pypi 2 | default: 3 | @grep '^[^#[:space:]].*:' Makefile 4 | mk-dist: 5 | python setup.py bdist sdist bdist_wheel upload -r $(PYPI) 6 | 7 | clean: 8 | rm -fr ./dist ./build ./cmdtree.egg-info ./.cache 9 | 10 | test: 11 | py.test --cov=cmdtree --cov-report=term-missing cmdtree 12 | 13 | develop: 14 | python setup.py develop 15 | -------------------------------------------------------------------------------- /src/cmdtree/__init__.py: -------------------------------------------------------------------------------- 1 | from cmdtree.parser import AParser 2 | from cmdtree.registry import env 3 | from cmdtree.shortcuts import ( 4 | argument, 5 | option, 6 | command, 7 | group, 8 | ) 9 | 10 | # parameter type support 11 | from cmdtree.types import ( 12 | STRING, 13 | INT, 14 | FLOAT, 15 | BOOL, 16 | UUID, 17 | Choices, 18 | IntRange, 19 | File, 20 | ) 21 | 22 | # globals and entry point 23 | env.parser = AParser() 24 | entry = env.entry 25 | 26 | -------------------------------------------------------------------------------- /src/cmdtree/_compat.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | 4 | WIN = sys.platform.startswith('win') 5 | 6 | 7 | def get_filesystem_encoding(): 8 | return sys.getfilesystemencoding() or sys.getdefaultencoding() 9 | 10 | if WIN: 11 | def _get_argv_encoding(): 12 | import locale 13 | return locale.getpreferredencoding() 14 | else: 15 | def _get_argv_encoding(): 16 | return getattr(sys.stdin, 'encoding', None) or get_filesystem_encoding() -------------------------------------------------------------------------------- /src/cmdtree/exceptions.py: -------------------------------------------------------------------------------- 1 | class ArgumentParseError(ValueError): 2 | pass -------------------------------------------------------------------------------- /src/cmdtree/parser.py: -------------------------------------------------------------------------------- 1 | from argparse import ArgumentParser 2 | import sys 3 | 4 | import six 5 | 6 | from cmdtree.exceptions import ArgumentParseError 7 | from cmdtree.registry import env 8 | 9 | 10 | def _normalize_arg_name(arg_name): 11 | return arg_name.replace("-", "_") 12 | 13 | 14 | def vars_(object=None): 15 | """ 16 | Clean all of the property starts with "_" then 17 | return result of vars(object). 18 | """ 19 | filtered_vars = {} 20 | vars_dict = vars(object) 21 | for key, value in six.iteritems(vars_dict): 22 | if key.startswith("_"): 23 | continue 24 | filtered_vars[_normalize_arg_name(key)] = value 25 | return filtered_vars 26 | 27 | 28 | class AParser(ArgumentParser): 29 | """ 30 | Arg-parse wrapper for sub command and convenient arg parse. 31 | """ 32 | def __init__(self, *args, **kwargs): 33 | self.subparsers = None 34 | super(AParser, self).__init__(*args, **kwargs) 35 | 36 | def add_cmd(self, name, help=None, func=None): 37 | """ 38 | If func is None, this is regarded as a sub-parser which can contains 39 | sub-command. 40 | Else, this is a leaf node in cmd tree which can not add sub-command. 41 | :rtype: AParser 42 | """ 43 | if self.subparsers is None: 44 | self.subparsers = self.add_subparsers( 45 | title="sub-commands", 46 | help=help or 'sub-commands', 47 | ) 48 | 49 | parser = self.subparsers.add_parser( 50 | name, 51 | help=help, 52 | ) 53 | if func is not None: 54 | parser.set_defaults(_func=func) 55 | return parser 56 | 57 | def run(self, args=None, namespace=None): 58 | args = self.parse_args(args, namespace) 59 | _func = getattr(args, "_func", None) 60 | 61 | if _func: 62 | return args._func(**vars_(args)) 63 | else: 64 | raise ValueError( 65 | "No function binding for args `{args}`".format( 66 | args=args 67 | ) 68 | ) 69 | 70 | def exit(self, status=0, message=None): 71 | if message: 72 | self._print_message(message, sys.stderr) 73 | if env.silent_exit: 74 | sys.exit(status) 75 | else: 76 | raise ArgumentParseError(message) 77 | 78 | def argument(self, name, help=None, type=None): 79 | kwargs = {"help": help} 80 | if name.startswith("-"): 81 | raise ValueError( 82 | "positional argument [{0}] can not contains `-` in".format(name) 83 | ) 84 | 85 | if type is not None: 86 | kwargs.update( 87 | type() 88 | ) 89 | return self.add_argument( 90 | name, **kwargs 91 | ) 92 | 93 | def option(self, name, help=None, is_flag=False, default=None, type=None): 94 | _name = name 95 | if not name.startswith("-"): 96 | _name = "--" + name 97 | kwargs = dict(help=help) 98 | if is_flag: 99 | kwargs['action'] = "store_true" 100 | if default is not None: 101 | kwargs['default'] = default 102 | if type is not None: 103 | kwargs.update(type()) 104 | return self.add_argument(_name, **kwargs) -------------------------------------------------------------------------------- /src/cmdtree/registry.py: -------------------------------------------------------------------------------- 1 | class ENV(object): 2 | __slots__ = ( 3 | "silent_exit", 4 | "parser", 5 | "_tree", 6 | ) 7 | 8 | def __init__(self): 9 | """ 10 | :type parser: cmdtree.parser.AParser 11 | """ 12 | self.silent_exit = True 13 | self._tree = None 14 | 15 | def entry(self, args=None, namespace=None): 16 | return self.tree.root.run(args, namespace) 17 | 18 | @property 19 | def tree(self): 20 | """ 21 | :rtype: cmdtree.tree.CmdTree 22 | """ 23 | from cmdtree.tree import CmdTree 24 | if self._tree is None: 25 | self._tree = CmdTree() 26 | return self._tree 27 | 28 | @property 29 | def root(self): 30 | return self.tree.root 31 | 32 | env = ENV() -------------------------------------------------------------------------------- /src/cmdtree/shortcuts.py: -------------------------------------------------------------------------------- 1 | from cmdtree.registry import env 2 | 3 | 4 | CMD_META_NAME = "meta" 5 | 6 | 7 | def _get_cmd_path(path_prefix, cmd_name): 8 | if path_prefix is None: 9 | full_path = (cmd_name, ) 10 | else: 11 | full_path = tuple(path_prefix) + (cmd_name, ) 12 | return full_path 13 | 14 | 15 | def _apply2parser(arguments, options, parser): 16 | """ 17 | :return the parser itself 18 | :type arguments: list[list[T], dict[str, T]] 19 | :type options: list[list[T], dict[str, T]] 20 | :type parser: cmdtree.parser.AParser 21 | :rtype: cmdtree.parser.AParser 22 | """ 23 | for args, kwargs in options: 24 | parser.option(*args, **kwargs) 25 | for args, kwargs in arguments: 26 | parser.argument(*args, **kwargs) 27 | return parser 28 | 29 | 30 | def apply2parser(cmd_proxy, parser): 31 | """ 32 | Apply a CmdProxy's arguments and options 33 | to a parser of argparse. 34 | :type cmd_proxy: callable or CmdProxy 35 | :type parser: cmdtree.parser.AParser 36 | :rtype: cmdtree.parser.AParser 37 | """ 38 | if isinstance(cmd_proxy, CmdProxy): 39 | parser_proxy = cmd_proxy.meta.parser 40 | _apply2parser( 41 | parser_proxy.arguments, 42 | parser_proxy.options, 43 | parser, 44 | ) 45 | return parser 46 | 47 | 48 | def _mk_group(name, help=None, path_prefix=None): 49 | 50 | def wrapper(func): 51 | if isinstance(func, Group): 52 | raise ValueError( 53 | "You can not register group `{name}` more than once".format( 54 | name=name 55 | ) 56 | ) 57 | _name = name 58 | _func = func 59 | 60 | if isinstance(func, CmdProxy): 61 | _func = func.func 62 | 63 | if name is None: 64 | _name = _get_func_name(_func) 65 | 66 | full_path = _get_cmd_path(path_prefix, _name) 67 | 68 | tree = env.tree 69 | parser = tree.add_parent_commands(full_path, help=help)['cmd'] 70 | _group = Group( 71 | _func, 72 | _name, 73 | parser, 74 | help=help, 75 | full_path=full_path, 76 | ) 77 | apply2parser(func, parser) 78 | return _group 79 | return wrapper 80 | 81 | 82 | def _mk_cmd(name, help=None, path_prefix=None): 83 | def wrapper(func): 84 | if isinstance(func, Cmd): 85 | raise ValueError( 86 | "You can not register a command more than once: {0}".format( 87 | func 88 | ) 89 | ) 90 | _func = func 91 | 92 | if isinstance(func, CmdProxy): 93 | _func = func.func 94 | 95 | _name = name 96 | if name is None: 97 | _name = _get_func_name(_func) 98 | 99 | full_path = _get_cmd_path(path_prefix, _name) 100 | tree = env.tree 101 | parser = tree.add_commands(full_path, _func, help=help) 102 | _cmd = Cmd( 103 | _func, 104 | _name, 105 | parser, 106 | help=help, 107 | full_path=full_path, 108 | ) 109 | apply2parser(func, parser) 110 | 111 | return _cmd 112 | return wrapper 113 | 114 | 115 | class CmdMeta(object): 116 | __slots__ = ( 117 | "full_path", 118 | "name", 119 | "parser", 120 | ) 121 | 122 | def __init__(self, name=None, full_path=None, parser=None): 123 | """ 124 | :param full_path: should always be tuple to avoid 125 | unexpected changes from outside. 126 | """ 127 | self.full_path = tuple(full_path) if full_path else tuple() 128 | self.name = name 129 | self.parser = parser 130 | 131 | 132 | class ParserProxy(object): 133 | __slots__ = ( 134 | "options", 135 | "arguments", 136 | ) 137 | 138 | def __init__(self): 139 | self.options = [] 140 | self.arguments = [] 141 | 142 | def option(self, *args, **kwargs): 143 | self.options.append((args, kwargs)) 144 | 145 | def argument(self, *args, **kwargs): 146 | self.arguments.append((args, kwargs)) 147 | 148 | 149 | class CmdProxy(object): 150 | """ 151 | Used to store original cmd info for cmd build proxy. 152 | """ 153 | __slots__ = ( 154 | "func", 155 | "meta", 156 | ) 157 | 158 | def __init__(self, func): 159 | self.func = func 160 | self.meta = CmdMeta(parser=ParserProxy()) 161 | 162 | 163 | class Group(object): 164 | def __init__(self, func, name, parser, help=None, full_path=None): 165 | """ 166 | :type func: callable 167 | :type name: str 168 | :type parser: cmdtree.parser.AParser 169 | :type help: str 170 | :type full_path: tuple or list 171 | """ 172 | self.func = func 173 | self.meta = CmdMeta( 174 | name=name, 175 | full_path=full_path, 176 | parser=parser, 177 | ) 178 | self.help = help 179 | 180 | def __call__(self, *args, **kwargs): 181 | # TODO(winkidney): This func will not work in 182 | # any case now.Be left now for possible call. 183 | return self.func(*args, **kwargs) 184 | 185 | def command(self, name=None, help=None): 186 | return _mk_cmd(name, help=help, path_prefix=self.meta.full_path) 187 | 188 | def group(self, name=None, help=None): 189 | return _mk_group(name, help=help, path_prefix=self.meta.full_path) 190 | 191 | 192 | class Cmd(object): 193 | def __init__(self, func, name, parser, help=None, full_path=None): 194 | self.func = func 195 | self.meta = CmdMeta( 196 | name=name, 197 | full_path=full_path, 198 | parser=parser, 199 | ) 200 | self.help = help 201 | 202 | def __call__(self, *args, **kwargs): 203 | return self.func(*args, **kwargs) 204 | 205 | 206 | def _get_func_name(func): 207 | assert callable(func) 208 | return func.__name__ 209 | 210 | 211 | def group(name=None, help=None): 212 | """ 213 | Group of commands, you can add sub-command/group in this group. 214 | :rtype : AParser 215 | """ 216 | return _mk_group(name, help=help) 217 | 218 | 219 | def command(name=None, help=None): 220 | return _mk_cmd(name, help=help) 221 | 222 | 223 | def argument(name, help=None, type=None): 224 | 225 | def wrapper(func): 226 | if isinstance(func, (Group, Cmd, CmdProxy)): 227 | parser = func.meta.parser 228 | parser.argument(name, help=help, type=type) 229 | return func 230 | else: 231 | meta_cmd = CmdProxy(func) 232 | parser = meta_cmd.meta.parser 233 | parser.argument(name, help=help, type=type) 234 | return meta_cmd 235 | return wrapper 236 | 237 | 238 | def option(name, help=None, is_flag=False, default=None, type=None): 239 | 240 | def wrapper(func): 241 | if isinstance(func, (Group, Cmd, CmdProxy)): 242 | parser = func.meta.parser 243 | parser.option( 244 | name, 245 | help=help, 246 | is_flag=is_flag, 247 | default=default, 248 | type=type, 249 | ) 250 | return func 251 | else: 252 | meta_cmd = CmdProxy(func) 253 | parser = meta_cmd.meta.parser 254 | parser.option( 255 | name, 256 | help=help, 257 | is_flag=is_flag, 258 | default=default, 259 | type=type, 260 | ) 261 | return meta_cmd 262 | return wrapper -------------------------------------------------------------------------------- /src/cmdtree/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/winkidney/cmdtree/8558be856f4c3044cf13d2d07a86b69877bb6491/src/cmdtree/tests/__init__.py -------------------------------------------------------------------------------- /src/cmdtree/tests/functional/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/winkidney/cmdtree/8558be856f4c3044cf13d2d07a86b69877bb6491/src/cmdtree/tests/functional/__init__.py -------------------------------------------------------------------------------- /src/cmdtree/tests/functional/test_command.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from cmdtree import INT, entry 3 | from cmdtree import command, argument, option 4 | 5 | 6 | @argument("host", help="server listen address") 7 | @option("reload", is_flag=True, help="if auto-reload on") 8 | @option("port", help="server port", type=INT, default=8888) 9 | @command(help="run a http server on given address") 10 | def run_server(host, reload, port): 11 | return host, port, reload 12 | 13 | 14 | @command(help="run a http server on given address") 15 | @argument("host", help="server listen address") 16 | @option("port", help="server port", type=INT, default=8888) 17 | def order(port, host): 18 | return host, port 19 | 20 | 21 | def test_should_return_given_argument(): 22 | from cmdtree import entry 23 | result = entry( 24 | ["run_server", "host", "--reload", "--port", "8888"] 25 | ) 26 | assert result == ("host", 8888, True) 27 | 28 | 29 | def test_should_reverse_decorator_order_has_no_side_effect(): 30 | from cmdtree import entry 31 | result = entry( 32 | ["order", "host", "--port", "8888"] 33 | ) 34 | assert result == ("host", 8888) 35 | 36 | 37 | def test_should_option_order_not_cause_argument_miss(): 38 | 39 | from cmdtree import entry 40 | 41 | @command("test_miss") 42 | @option("kline") 43 | @argument("script_path", help="file path of python _script") 44 | def run_test(script_path, kline): 45 | return script_path, kline 46 | 47 | assert entry( 48 | ["test_miss", "path", "--kline", "fake"] 49 | ) == ("path", "fake") 50 | 51 | 52 | def test_should_double_option_order_do_not_cause_calling_error(): 53 | 54 | @command("test_order") 55 | @option("feed") 56 | @option("config", help="config file path for kline database") 57 | def hello(feed, config): 58 | return feed 59 | 60 | assert entry( 61 | ["test_order", "--feed", "fake"] 62 | ) == "fake" -------------------------------------------------------------------------------- /src/cmdtree/tests/functional/test_group.py: -------------------------------------------------------------------------------- 1 | from cmdtree import Choices 2 | from cmdtree import command 3 | from cmdtree import group, argument, entry 4 | from cmdtree import option 5 | 6 | 7 | @group("docker") 8 | @argument("ip") 9 | def docker(): 10 | pass 11 | 12 | 13 | @docker.group("image") 14 | def image(): 15 | pass 16 | 17 | 18 | @image.command("create") 19 | @argument("name") 20 | def image_create(ip, name): 21 | return ip, name 22 | 23 | 24 | @docker.command("run") 25 | @argument("container-name") 26 | def run(ip, container_name): 27 | return ip, container_name 28 | 29 | 30 | def test_docker_run(): 31 | assert entry( 32 | ["docker", "0.0.0.0", "run", "container1"] 33 | ) == ("0.0.0.0", "container1") 34 | 35 | 36 | def test_nested_group_works(): 37 | assert entry( 38 | ["docker", "0.0.0.0", "image", "create", "test_image"] 39 | ) == ("0.0.0.0", "test_image") 40 | 41 | -------------------------------------------------------------------------------- /src/cmdtree/tests/unittest/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/winkidney/cmdtree/8558be856f4c3044cf13d2d07a86b69877bb6491/src/cmdtree/tests/unittest/__init__.py -------------------------------------------------------------------------------- /src/cmdtree/tests/unittest/test_parser.py: -------------------------------------------------------------------------------- 1 | import mock 2 | import pytest 3 | import six 4 | 5 | from cmdtree import parser 6 | from cmdtree.exceptions import ArgumentParseError 7 | 8 | 9 | def mk_obj(property_dict): 10 | class TestObject(object): 11 | pass 12 | obj = TestObject() 13 | for key, value in six.iteritems(property_dict): 14 | setattr(obj, key, value) 15 | return obj 16 | 17 | 18 | @pytest.fixture() 19 | def aparser(): 20 | from cmdtree.parser import AParser 21 | return AParser() 22 | 23 | 24 | @pytest.fixture() 25 | def test_func(): 26 | def func(): 27 | return "result" 28 | return func 29 | 30 | 31 | @pytest.mark.parametrize( 32 | "arg_name, expected", 33 | ( 34 | ("hello_world", "hello_world"), 35 | ("hello-world", "hello_world"), 36 | ) 37 | ) 38 | def test_normalize_arg_name(arg_name, expected): 39 | from cmdtree.parser import _normalize_arg_name 40 | assert _normalize_arg_name(arg_name) == expected 41 | 42 | 43 | @pytest.mark.parametrize( 44 | "p_dict, expected", 45 | ( 46 | ({"_k": "v", "k": "v"}, {"k": "v"}), 47 | ({"__k": "v", "k": "v"}, {"k": "v"}), 48 | ({"k1": "v", "k": "v"}, {"k": "v", "k1": "v"}), 49 | ) 50 | ) 51 | def test_vars_should_return_right_dict(p_dict, expected): 52 | obj = mk_obj(p_dict) 53 | assert parser.vars_( 54 | obj 55 | ) == expected 56 | 57 | 58 | class TestAParser: 59 | def test_should_execute_func(self, aparser, test_func): 60 | aparser.add_cmd("test", func=test_func) 61 | assert aparser.run(["test"]) == "result" 62 | 63 | def test_should_execute_child_cmd(self, aparser, test_func): 64 | parent = aparser.add_cmd("parent") 65 | parent.add_cmd("child", func=test_func) 66 | assert aparser.run(['parent', 'child']) == "result" 67 | 68 | @pytest.mark.parametrize( 69 | "cmd_func, exception", 70 | ( 71 | (None, ValueError), 72 | (lambda *args, **kwargs: "str", None), 73 | ) 74 | ) 75 | def test_should_execute_without_func(self, cmd_func, exception, aparser): 76 | parent = aparser.add_cmd("parent") 77 | parent.add_cmd("child", func=cmd_func) 78 | if exception is not None: 79 | with pytest.raises(exception): 80 | aparser.run(['parent', 'child']) 81 | else: 82 | assert aparser.run(['parent', 'child']) == "str" 83 | 84 | @pytest.mark.parametrize( 85 | "silent_exit, exception", 86 | ( 87 | (False, ArgumentParseError), 88 | (True, SystemExit) 89 | ) 90 | ) 91 | def test_should_parent_cmd_exit_or_raise_error(self, silent_exit, exception, test_func, aparser): 92 | from cmdtree.registry import env 93 | env.silent_exit = silent_exit 94 | parent = aparser.add_cmd("parent") 95 | parent.add_cmd("child", func=test_func) 96 | with pytest.raises(exception): 97 | aparser.run(['parent']) 98 | 99 | @pytest.mark.parametrize( 100 | "arg_name, exception", 101 | ( 102 | ('--name', ValueError), 103 | ('-name', ValueError), 104 | ('name', None), 105 | ) 106 | ) 107 | def test_should_argument_starts_with_valid_string(self, arg_name, exception, test_func, aparser): 108 | cmd = aparser.add_cmd("execute", func=test_func) 109 | with mock.patch.object(cmd, "add_argument") as mocked_add: 110 | if exception is not None: 111 | with pytest.raises(exception): 112 | cmd.argument(arg_name) 113 | else: 114 | cmd.argument(arg_name) 115 | mocked_add.assert_called_with(arg_name, help=None) 116 | 117 | @pytest.mark.parametrize( 118 | "arg_name, expected_name", 119 | ( 120 | ('--name', '--name'), 121 | ('-name', '-name'), 122 | ('name', '--name'), 123 | ) 124 | ) 125 | def test_option_should_starts_with_hyphen(self, arg_name, expected_name, test_func, aparser): 126 | cmd = aparser.add_cmd("execute", func=test_func) 127 | with mock.patch.object(cmd, "add_argument") as mocked_add: 128 | cmd.option(arg_name) 129 | mocked_add.assert_called_with(expected_name, help=None) 130 | 131 | @pytest.mark.parametrize( 132 | "is_flag", 133 | ( 134 | True, 135 | False, 136 | ) 137 | ) 138 | def test_option_should_work_with_is_flag(self, is_flag, test_func, aparser): 139 | cmd = aparser.add_cmd("execute", func=test_func) 140 | with mock.patch.object(cmd, "add_argument") as mocked_add: 141 | cmd.option("name", is_flag=is_flag) 142 | if is_flag: 143 | mocked_add.assert_called_with("--name", help=None, action="store_true") 144 | else: 145 | mocked_add.assert_called_with("--name", help=None) 146 | 147 | @pytest.mark.parametrize( 148 | "default", 149 | ( 150 | None, 151 | 1, 152 | ) 153 | ) 154 | def test_option_should_work_with_default_value(self, default, aparser): 155 | cmd = aparser.add_cmd("execute", func=test_func) 156 | with mock.patch.object(cmd, "add_argument") as mocked_add: 157 | cmd.option("name", default=default) 158 | if default is None: 159 | mocked_add.assert_called_with("--name", help=None) 160 | else: 161 | mocked_add.assert_called_with("--name", help=None, default=default) 162 | 163 | @pytest.mark.parametrize( 164 | "type_func, kwargs", 165 | ( 166 | (mock.Mock(), {"help": None, "type": int}), 167 | (None, {"help": None}), 168 | ) 169 | ) 170 | def test_add_argument_work_with_type( 171 | self, type_func, kwargs, aparser 172 | ): 173 | if type_func is not None: 174 | type_func.return_value = {"type": int} 175 | with mock.patch.object(aparser, "add_argument") as mocked_add: 176 | aparser.argument("name", type=type_func) 177 | if type_func is not None: 178 | assert type_func.called 179 | mocked_add.assert_called_with("name", **kwargs) 180 | 181 | @pytest.mark.parametrize( 182 | "type_func, kwargs", 183 | ( 184 | (mock.Mock(), {"help": None, "type": int}), 185 | (None, {"help": None}), 186 | ) 187 | ) 188 | def test_add_option_work_with_type( 189 | self, type_func, kwargs, aparser 190 | ): 191 | if type_func is not None: 192 | type_func.return_value = {"type": int} 193 | with mock.patch.object(aparser, "add_argument") as mocked_add: 194 | aparser.option("name", type=type_func) 195 | if type_func is not None: 196 | assert type_func.called 197 | mocked_add.assert_called_with("--name", **kwargs) 198 | -------------------------------------------------------------------------------- /src/cmdtree/tests/unittest/test_registry.py: -------------------------------------------------------------------------------- 1 | def test_get_tree_always_get_the_same_one(): 2 | from cmdtree.registry import env 3 | from cmdtree.tree import CmdTree 4 | tree1 = env.tree 5 | tree2 = env.tree 6 | assert isinstance(tree1, CmdTree) 7 | assert tree1 is tree2 -------------------------------------------------------------------------------- /src/cmdtree/tests/unittest/test_shortcuts.py: -------------------------------------------------------------------------------- 1 | import mock 2 | import pytest 3 | 4 | from cmdtree import shortcuts 5 | 6 | 7 | @pytest.fixture() 8 | def do_nothing(): 9 | 10 | def func(*args, **kwargs): 11 | return "do_nothing" 12 | 13 | return func 14 | 15 | 16 | @pytest.fixture() 17 | def mocked_parser(): 18 | return mock.Mock() 19 | 20 | 21 | @pytest.fixture() 22 | def parser_proxy(): 23 | return shortcuts.ParserProxy() 24 | 25 | 26 | @pytest.fixture() 27 | def group(mocked_parser, do_nothing): 28 | return shortcuts.Group( 29 | do_nothing, 30 | "do_nothing", 31 | mocked_parser, 32 | full_path=["do_nothing", ] 33 | ) 34 | 35 | 36 | @pytest.fixture() 37 | def cmd(mocked_parser, do_nothing): 38 | return shortcuts.Cmd( 39 | do_nothing, 40 | "do_nothing", 41 | mocked_parser, 42 | full_path=["do_nothing", ] 43 | ) 44 | 45 | 46 | @pytest.mark.parametrize( 47 | "path_prefix, cmd_name, expected", 48 | ( 49 | ( 50 | ("parent", "child"), 51 | "execute", 52 | ("parent", "child", "execute") 53 | ), 54 | ( 55 | ["parent", "child"], 56 | "execute", 57 | ("parent", "child", "execute") 58 | ), 59 | (None, "execute", ("execute", )), 60 | ) 61 | ) 62 | def test_get_cmd_path(path_prefix, cmd_name, expected): 63 | assert shortcuts._get_cmd_path( 64 | path_prefix, cmd_name 65 | ) == expected 66 | 67 | 68 | def test_should_apply2user_called_correctly(mocked_parser): 69 | option = mocked_parser.option = mock.Mock() 70 | argument = mocked_parser.argument = mock.Mock() 71 | shortcuts._apply2parser( 72 | [["cmd1", {}], ], 73 | [["cmd1", {}], ["cmd1", {}], ], 74 | mocked_parser 75 | ) 76 | assert option.call_count == 2 77 | assert argument.call_count == 1 78 | 79 | 80 | @pytest.mark.parametrize( 81 | "cmd_proxy, expected", 82 | ( 83 | (shortcuts.CmdProxy(lambda x: x), True), 84 | (lambda x: x, False), 85 | ) 86 | ) 87 | def test_should_apply2parser_be_called_with_cmd_proxy( 88 | cmd_proxy, expected, mocked_parser, 89 | ): 90 | with mock.patch.object( 91 | shortcuts, "_apply2parser" 92 | ) as mocked_apply: 93 | shortcuts.apply2parser(cmd_proxy, mocked_parser) 94 | assert mocked_apply.called is expected 95 | 96 | 97 | class TestMkGroup: 98 | def test_should_return_group_with_group(self, do_nothing): 99 | 100 | assert isinstance( 101 | shortcuts._mk_group("hello")(do_nothing), 102 | shortcuts.Group 103 | ) 104 | 105 | def test_should_raise_value_error_if_group_inited( 106 | self, do_nothing, mocked_parser 107 | ): 108 | 109 | group = shortcuts.Group(do_nothing, "test", mocked_parser) 110 | 111 | with pytest.raises(ValueError): 112 | shortcuts._mk_group("test")(group) 113 | 114 | def test_should_get_func_name_called_if_no_name_given( 115 | self, do_nothing 116 | ): 117 | with mock.patch.object( 118 | shortcuts, "_get_func_name" 119 | ) as mocked_get_name: 120 | shortcuts._mk_group(None)(do_nothing) 121 | assert mocked_get_name.called 122 | 123 | def test_should_call_apply2parser_for_meta_cmd( 124 | self, do_nothing 125 | ): 126 | 127 | with mock.patch.object( 128 | shortcuts, "apply2parser", 129 | ) as apply2parser: 130 | cmd_proxy = shortcuts.CmdProxy(do_nothing) 131 | shortcuts._mk_group("name")(cmd_proxy) 132 | assert apply2parser.called 133 | 134 | 135 | class TestMkCmd: 136 | def test_should_return_cmd_with_cmd(self, do_nothing): 137 | 138 | assert isinstance( 139 | shortcuts._mk_cmd("hello")(do_nothing), 140 | shortcuts.Cmd 141 | ) 142 | 143 | def test_should_raise_value_error_if_cmd_inited( 144 | self, do_nothing, mocked_parser 145 | ): 146 | 147 | cmd = shortcuts.Cmd(do_nothing, "test", mocked_parser) 148 | 149 | with pytest.raises(ValueError): 150 | shortcuts._mk_cmd("test")(cmd) 151 | 152 | def test_should_get_func_name_called_if_no_name_given( 153 | self, do_nothing 154 | ): 155 | with mock.patch.object( 156 | shortcuts, "_get_func_name" 157 | ) as mocked_get_name: 158 | shortcuts._mk_cmd(None)(do_nothing) 159 | assert mocked_get_name.called 160 | 161 | def test_should_call_apply2parser_for_meta_cmd( 162 | self, do_nothing 163 | ): 164 | 165 | with mock.patch.object( 166 | shortcuts, "apply2parser", 167 | ) as apply2parser: 168 | cmd_proxy = shortcuts.CmdProxy(do_nothing) 169 | shortcuts._mk_cmd("name")(cmd_proxy) 170 | assert apply2parser.called 171 | 172 | 173 | def test_cmd_meta_should_handle_none_value_of_path_to_tuple(): 174 | cmd_meta = shortcuts.CmdMeta() 175 | assert cmd_meta.full_path == tuple() 176 | 177 | 178 | class TestParserProxy: 179 | def test_should_option_add_options(self, parser_proxy): 180 | parser_proxy.option("name", help="help") 181 | assert parser_proxy.options == [( 182 | ("name", ), {"help": "help"} 183 | )] 184 | 185 | def test_should_argument_add_options(self, parser_proxy): 186 | parser_proxy.argument("name", help="help") 187 | assert parser_proxy.arguments == [( 188 | ("name", ), {"help": "help"} 189 | )] 190 | 191 | 192 | class TestGroup: 193 | def test_should_group_instance_call_func(self, group): 194 | assert group() == "do_nothing" 195 | 196 | def test_should_full_path_be_none_if_path_is_none(self, group): 197 | assert group.meta.full_path == ("do_nothing", ) 198 | 199 | def test_should_command_call_mk_command(self, group): 200 | with mock.patch.object( 201 | shortcuts, "_mk_cmd" 202 | ) as mocked_mk: 203 | group.command("name") 204 | mocked_mk.assert_called_with( 205 | "name", 206 | help=None, 207 | path_prefix=("do_nothing", ) 208 | ) 209 | 210 | def test_should_group_call_mk_group(self, group): 211 | with mock.patch.object( 212 | shortcuts, "_mk_group" 213 | ) as mocked_mk: 214 | group.group("name") 215 | mocked_mk.assert_called_with( 216 | "name", 217 | help=None, 218 | path_prefix=("do_nothing", ) 219 | ) 220 | 221 | 222 | class TestCmd: 223 | def test_should_cmd_instance_call_func(self, cmd): 224 | assert cmd() == "do_nothing" 225 | 226 | def test_should_full_path_be_none_if_path_is_none(self, cmd): 227 | assert cmd.meta.full_path == ("do_nothing", ) 228 | 229 | 230 | def test_get_func_name(do_nothing): 231 | assert shortcuts._get_func_name(do_nothing) == "func" -------------------------------------------------------------------------------- /src/cmdtree/tests/unittest/test_tree.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | 3 | import pytest 4 | from mock import mock 5 | 6 | 7 | @pytest.fixture 8 | def cmd_node(): 9 | return { 10 | "name": "new_cmd", 11 | "cmd": "cmd_obj", 12 | "children": {} 13 | } 14 | 15 | 16 | @pytest.fixture 17 | def cmd_node2(): 18 | return { 19 | "name": "child_cmd", 20 | "cmd": "cmd_obj", 21 | "children": {} 22 | } 23 | 24 | 25 | @pytest.fixture 26 | def cmd_tree(mocked_resource): 27 | from cmdtree.tree import CmdTree 28 | return CmdTree(mocked_resource) 29 | 30 | 31 | @pytest.fixture 32 | def mocked_resource(): 33 | return mock.Mock() 34 | 35 | 36 | @pytest.fixture 37 | def cmd_tree_with_tree(cmd_tree, cmd_node, cmd_node2): 38 | cmd_tree._add_node(cmd_node, ['new_cmd']) 39 | cmd_tree._add_node(cmd_node2, ['new_cmd', 'child_cmd']) 40 | return cmd_tree 41 | 42 | 43 | class TestCmdTree: 44 | def test_should_cmd_tree_gen_right_node( 45 | self 46 | ): 47 | from cmdtree.tree import _mk_cmd_node 48 | ret = _mk_cmd_node("cmd", "cmd_obj") 49 | assert ret == { 50 | "name": "cmd", 51 | "cmd": "cmd_obj", 52 | "children": {} 53 | } 54 | 55 | def test_should_cmd_tree_add_node_create_right_index( 56 | self, cmd_tree, mocked_resource, cmd_node, cmd_node2 57 | ): 58 | cmd_tree._add_node(cmd_node, ['new_cmd']) 59 | assert cmd_tree.tree == { 60 | "name": "root", 61 | "cmd": mocked_resource, 62 | "children": {"new_cmd": cmd_node} 63 | } 64 | 65 | cmd_tree._add_node(cmd_node2, ['new_cmd', 'child_cmd']) 66 | expected_cmd_node = deepcopy(cmd_node) 67 | expected_cmd_node['children']['child_cmd'] = cmd_node2 68 | assert cmd_tree.tree == { 69 | "name": "root", 70 | "cmd": mocked_resource, 71 | "children": {"new_cmd": expected_cmd_node} 72 | } 73 | 74 | def test_should_cmd_tree_get_cmd_by_path_get_parent( 75 | self, cmd_tree_with_tree, cmd_node, cmd_node2 76 | ): 77 | tree = cmd_tree_with_tree 78 | ret = tree.get_cmd_by_path(['new_cmd']) 79 | expected_cmd_node = deepcopy(cmd_node) 80 | expected_cmd_node['children']['child_cmd'] = cmd_node2 81 | assert ret == expected_cmd_node 82 | 83 | def test_should_cmd_tree_get_cmd_by_path_get_child( 84 | self, cmd_tree_with_tree, cmd_node2 85 | ): 86 | tree = cmd_tree_with_tree 87 | ret = tree.get_cmd_by_path(['new_cmd', 'child_cmd']) 88 | assert ret == cmd_node2 89 | 90 | @pytest.mark.parametrize( 91 | "full_path, end_index, result", 92 | ( 93 | ( 94 | [1, 2, 3], 95 | 1, 96 | ([2, 3], [1, ]), 97 | ), 98 | ( 99 | [1, 2, 3], 100 | None, 101 | ([], [1, 2, 3]), 102 | ), 103 | ) 104 | ) 105 | def test_get_paths_should_work_as_expected(self, full_path, end_index, result, cmd_tree): 106 | assert cmd_tree._get_paths(full_path, end_index) == result 107 | 108 | def test_should_cmd_tree_index_in_tree_get_right_index( 109 | self, cmd_tree_with_tree 110 | ): 111 | tree = cmd_tree_with_tree 112 | ret = tree.index_in_tree(['new_cmd', 'child_cmd']) 113 | assert ret is None 114 | assert tree.index_in_tree(['new_cmd']) is None 115 | ret = tree.index_in_tree(['new_cmd', 'hello']) 116 | assert ret == 1 117 | assert tree.index_in_tree(['child_cmd']) == 0 118 | assert tree.index_in_tree(['another_cmd_not_exist']) == 0 119 | 120 | def test_should_cmd_tree_add_parent_commands_return_the_last( 121 | self, 122 | cmd_tree 123 | ): 124 | cmd_tree.add_parent_commands(['new_cmd', 'hello']) 125 | assert "hello" in \ 126 | cmd_tree.tree['children']['new_cmd']['children'] 127 | assert {} == \ 128 | cmd_tree.tree['children']['new_cmd']['children']["hello"]['children'] 129 | 130 | def test_should_cmd_tree_get_cmd_by_path_got_obj( 131 | self, cmd_tree_with_tree 132 | ): 133 | assert cmd_tree_with_tree.get_cmd_by_path(['new_cmd']) is not None 134 | assert cmd_tree_with_tree.get_cmd_by_path( 135 | ['new_cmd', "child_cmd"]) is not None 136 | with pytest.raises(ValueError) as excinfo: 137 | cmd_tree_with_tree.get_cmd_by_path(['new_cmd', "fuck"]) 138 | msg = "Given key [fuck] in path ['new_cmd', 'fuck'] does not exist in tree." 139 | assert str(excinfo.value) == msg 140 | 141 | def test_should_add_parent_cmd_not_repeat_add(self, cmd_tree_with_tree): 142 | orig_node = cmd_tree_with_tree.add_parent_commands(['test_nested', 'child']) 143 | new_node = cmd_tree_with_tree.add_parent_commands(['test_nested', 'child']) 144 | assert id(orig_node['cmd']) == id(new_node['cmd']) -------------------------------------------------------------------------------- /src/cmdtree/tests/unittest/test_types.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from argparse import ArgumentTypeError 3 | 4 | import mock 5 | import pytest 6 | 7 | from cmdtree import types 8 | 9 | 10 | class TestParamTypeFactory: 11 | 12 | def setup_method(self, method): 13 | self.instance = types.ParamTypeFactory() 14 | 15 | def test_should_call_return_argparse_kwargs(self): 16 | assert self.instance() == {"type": self.instance.convert} 17 | 18 | def test_should_subclass_raise_not_implement_error(self): 19 | with pytest.raises(NotImplementedError): 20 | self.instance.convert("value") 21 | 22 | def test_should_fail_raise_argument_type_error(self): 23 | with pytest.raises(ArgumentTypeError) as e: 24 | self.instance.fail("msg") 25 | assert str(e.value) == "msg" 26 | 27 | 28 | @pytest.mark.parametrize( 29 | "value, expected", 30 | ( 31 | (True, True), 32 | ("value", "value"), 33 | (1, 1), 34 | ) 35 | ) 36 | def test_should_unprocessed_return_unprocessed_param( 37 | value, expected 38 | ): 39 | assert types.UnprocessedParamType().convert(value) == expected 40 | 41 | 42 | @pytest.mark.parametrize( 43 | "value, expected, argv_encode, fs_encode", 44 | ( 45 | (True, True, None, None), 46 | ("value", "value", None, None), 47 | (1, 1, None, None), 48 | (b"value", "value", None, None), 49 | (u"value", "value", None, None), 50 | (u"中文".encode("utf-8"), u"中文", None, None), 51 | (u"中文".encode("gbk"), u"中文", "gbk", "gbk"), 52 | (u"中文".encode("gbk"), u"中文", "utf-8", "gbk"), 53 | (u"中文".encode("gbk"), u"中文", "utf-8", "gb2312"), 54 | ) 55 | ) 56 | def test_should_string_param_return_always_unicode_if_is_string( 57 | value, expected, argv_encode, fs_encode 58 | ): 59 | if argv_encode is None: 60 | argv_encode = "utf-8" 61 | if fs_encode is None: 62 | fs_encode = "utf-8" 63 | mocked_argv = mock.Mock() 64 | mocked_fs = mock.Mock() 65 | mocked_argv.return_value = argv_encode 66 | mocked_fs.return_value = fs_encode 67 | pathcer1 = mock.patch.object(types, "_get_argv_encoding", mocked_argv) 68 | pathcer2 = mock.patch.object(types, "get_filesystem_encoding", mocked_fs) 69 | pathcer1.start() 70 | pathcer2.start() 71 | assert types.StringParamType().convert(value) == expected 72 | pathcer1.stop() 73 | pathcer2.stop() 74 | 75 | 76 | class TestIntParamType: 77 | @pytest.mark.parametrize( 78 | "value, expected", 79 | ( 80 | (1, 1), 81 | ("1", 1), 82 | (True, 1), 83 | (False, 0), 84 | ) 85 | ) 86 | def test_should_get_int(self, value, expected): 87 | assert types.INT.convert(value) == expected 88 | 89 | @pytest.mark.parametrize( 90 | "value", 91 | ( 92 | "1.1", 93 | "hello", 94 | ) 95 | ) 96 | def test_should_fail_called(self, value): 97 | with mock.patch.object( 98 | types.INT, "fail" 99 | ) as mocked_fail: 100 | types.INT.convert(value) 101 | assert mocked_fail.called 102 | 103 | 104 | class TestIntRange: 105 | @pytest.mark.parametrize( 106 | "value, min, max, clamp, expected", 107 | ( 108 | (5, 1, 10, False, 5), 109 | (1, 1, 10, False, 1), 110 | (10, 1, 10, False, 10), 111 | (10, 1, None, False, 10), 112 | (0, None, 10, False, 0), 113 | (11, 1, 10, True, 10), 114 | (0, 1, 10, True, 1), 115 | ) 116 | ) 117 | def test_should_return_value(self, value, min, max, clamp, expected): 118 | instance = types.IntRange(min=min, max=max, clamp=clamp) 119 | assert instance.convert(value) == expected 120 | 121 | @pytest.mark.parametrize( 122 | "value, min, max, clamp", 123 | ( 124 | (11, 1, 10, False), 125 | (0, 1, 10, False), 126 | (11, None, 10, False), 127 | (0, 1, None, False), 128 | ) 129 | ) 130 | def test_should_fail(self, value, min, max, clamp): 131 | instance = types.IntRange(min=min, max=max, clamp=clamp) 132 | with mock.patch.object( 133 | instance, "fail" 134 | ) as mocked_fail: 135 | instance.convert(value) 136 | assert mocked_fail.called 137 | 138 | 139 | class TestBoolParamType: 140 | @pytest.mark.parametrize( 141 | "value, expected", 142 | ( 143 | ('True', True), 144 | ('true', True), 145 | ('yes', True), 146 | ('y', True), 147 | ('1', True), 148 | ('false', False), 149 | ('0', False), 150 | ('no', False), 151 | ('n', False), 152 | (True, True), 153 | (False, False), 154 | ) 155 | ) 156 | def test_should_return_bool(self, value, expected): 157 | assert types.BOOL.convert(value) == expected 158 | 159 | @pytest.mark.parametrize( 160 | "value", 161 | ( 162 | "Failed", 163 | ) 164 | ) 165 | def test_should_fail(self, value): 166 | with mock.patch.object( 167 | types.BOOL, "fail" 168 | ) as mocked_fail: 169 | types.BOOL.convert(value) 170 | assert mocked_fail.called 171 | 172 | 173 | class TestFloatParamType: 174 | 175 | @pytest.mark.parametrize( 176 | "value, expected", 177 | ( 178 | ("1.1", 1.1), 179 | ("1", 1), 180 | (".1", 0.1), 181 | ) 182 | ) 183 | def test_should_return_float(self, value, expected): 184 | assert types.FLOAT.convert(value) == expected 185 | 186 | @pytest.mark.parametrize( 187 | "value, expected", 188 | ( 189 | ("2.x", 1.222), 190 | ("hi", 0.1), 191 | ) 192 | ) 193 | def test_should_fail(self, value, expected): 194 | with mock.patch.object( 195 | types.FLOAT, "fail" 196 | ) as mocked_fail: 197 | types.FLOAT.convert(value) 198 | assert mocked_fail.called 199 | 200 | 201 | def test_should_convert_return_file_type(): 202 | f = types.File(mode="w") 203 | assert hasattr(f.convert("/tmp/tmp.log"), "read") 204 | 205 | 206 | class TestChoices: 207 | 208 | @pytest.mark.parametrize( 209 | "choices, type_", 210 | ( 211 | (["hello", 1, "hello"], None), 212 | ([3, 1, 2], types.INT) 213 | ) 214 | ) 215 | def test_should_return_keyword_argument(self, choices, type_): 216 | instance = types.Choices(choices, type=type_) 217 | 218 | assert instance() == { 219 | "type": type_ or instance.convert, 220 | "choices": choices 221 | } -------------------------------------------------------------------------------- /src/cmdtree/tree.py: -------------------------------------------------------------------------------- 1 | from cmdtree.parser import AParser 2 | 3 | 4 | def _mk_cmd_node(cmd_name, cmd_obj): 5 | return { 6 | "name": cmd_name, 7 | "cmd": cmd_obj, 8 | "children": {} 9 | } 10 | 11 | 12 | class CmdTree(object): 13 | """ 14 | A tree that manages the command references by cmd path like 15 | ['parent_cmd', 'child_cmd']. 16 | """ 17 | 18 | def __init__(self, root_parser=None): 19 | """ 20 | :type root_parser: cmdtree.parser.AParser 21 | """ 22 | if root_parser is not None: 23 | self.root = root_parser 24 | else: 25 | self.root = AParser() 26 | self.tree = { 27 | "name": "root", 28 | "cmd": self.root, 29 | "children": {} 30 | } 31 | 32 | def get_cmd_by_path(self, existed_cmd_path): 33 | """ 34 | :return: 35 | { 36 | "name": cmd_name, 37 | "cmd": Resource instance, 38 | "children": {} 39 | } 40 | """ 41 | parent = self.tree 42 | for cmd_name in existed_cmd_path: 43 | try: 44 | parent = parent['children'][cmd_name] 45 | except KeyError: 46 | raise ValueError( 47 | "Given key [%s] in path %s does not exist in tree." 48 | % (cmd_name, existed_cmd_path) 49 | ) 50 | return parent 51 | 52 | def _add_node(self, cmd_node, cmd_path): 53 | """ 54 | :type cmd_path: list or tuple 55 | """ 56 | parent = self.tree 57 | for cmd_key in cmd_path: 58 | if cmd_key not in parent['children']: 59 | break 60 | parent = parent['children'][cmd_key] 61 | parent["children"][cmd_node['name']] = cmd_node 62 | return cmd_node 63 | 64 | @staticmethod 65 | def _get_paths(full_path, end_index): 66 | if end_index is None: 67 | new_path, existed_path = [], full_path 68 | else: 69 | new_path, existed_path = full_path[end_index:], full_path[:end_index] 70 | return new_path, existed_path 71 | 72 | def add_commands(self, cmd_path, func, help=None): 73 | cmd_name = cmd_path[-1] 74 | parent = self.add_parent_commands(cmd_path[:-1]) 75 | sub_command = parent['cmd'].add_cmd(name=cmd_name, func=func, help=help) 76 | node = _mk_cmd_node(cmd_name, sub_command) 77 | self._add_node(node, cmd_path=cmd_path) 78 | return sub_command 79 | 80 | def add_parent_commands(self, cmd_path, help=None): 81 | """ 82 | Create parent command object in cmd tree then return 83 | the last parent command object. 84 | :rtype: dict 85 | """ 86 | existed_cmd_end_index = self.index_in_tree(cmd_path) 87 | new_path, existed_path = self._get_paths( 88 | cmd_path, 89 | existed_cmd_end_index, 90 | ) 91 | parent_node = self.get_cmd_by_path(existed_path) 92 | 93 | last_one_index = 1 94 | new_path_len = len(new_path) 95 | _kwargs = {} 96 | for cmd_name in new_path: 97 | if last_one_index >= new_path_len: 98 | _kwargs['help'] = help 99 | sub_cmd = parent_node['cmd'].add_cmd( 100 | cmd_name, **_kwargs 101 | ) 102 | parent_node = _mk_cmd_node(cmd_name, sub_cmd) 103 | self._add_node( 104 | parent_node, 105 | existed_path + new_path[:new_path.index(cmd_name)] 106 | ) 107 | last_one_index += 1 108 | return parent_node 109 | 110 | def index_in_tree(self, cmd_path): 111 | """ 112 | Return the start index of which the element is not in cmd tree. 113 | :type cmd_path: list or tuple 114 | :return: None if cmd_path already indexed in tree. 115 | """ 116 | current_tree = self.tree 117 | for key in cmd_path: 118 | if key in current_tree['children']: 119 | current_tree = current_tree['children'][key] 120 | else: 121 | return cmd_path.index(key) 122 | return None 123 | -------------------------------------------------------------------------------- /src/cmdtree/types.py: -------------------------------------------------------------------------------- 1 | from argparse import ArgumentTypeError as ArgTypeError, FileType 2 | 3 | from six import text_type, PY2 4 | 5 | from ._compat import _get_argv_encoding, get_filesystem_encoding 6 | 7 | 8 | class ParamTypeFactory(object): 9 | """ 10 | Helper class for type convention. 11 | """ 12 | 13 | name = None 14 | 15 | def __call__(self): 16 | """ 17 | Return keyword arguments for add_argument function. 18 | :rtype: dict 19 | """ 20 | return {"type": self.convert} 21 | 22 | def convert(self, value): 23 | raise NotImplementedError( 24 | "type converter should be implemented before used" 25 | "for value {value}".format(value=value) 26 | ) 27 | 28 | @staticmethod 29 | def fail(msg): 30 | raise ArgTypeError(msg) 31 | 32 | 33 | class UnprocessedParamType(ParamTypeFactory): 34 | name = 'text' 35 | 36 | def convert(self, value): 37 | return value 38 | 39 | def __repr__(self): 40 | return 'UNPROCESSED' 41 | 42 | 43 | class StringParamType(ParamTypeFactory): 44 | name = 'text' 45 | 46 | def convert(self, value): 47 | if isinstance(value, bytes): 48 | enc = _get_argv_encoding() 49 | try: 50 | value = value.decode(enc) 51 | except UnicodeError: 52 | fs_enc = get_filesystem_encoding() 53 | if fs_enc != enc: 54 | try: 55 | value = value.decode(fs_enc) 56 | except UnicodeError: 57 | value = value.decode('utf-8', 'replace') 58 | return value 59 | return value 60 | 61 | def __repr__(self): 62 | return 'STRING' 63 | 64 | 65 | class IntParamType(ParamTypeFactory): 66 | name = 'integer' 67 | 68 | def convert(self, value): 69 | try: 70 | return int(value) 71 | except (ValueError, UnicodeError): 72 | self.fail('%s is not a valid integer' % value) 73 | 74 | def __repr__(self): 75 | return 'INT' 76 | 77 | 78 | class IntRange(IntParamType): 79 | name = 'integer range' 80 | 81 | def __init__(self, min=None, max=None, clamp=False): 82 | self.min = min 83 | self.max = max 84 | self.clamp = clamp 85 | 86 | def convert(self, value): 87 | rv = IntParamType.convert(self, value) 88 | if self.clamp: 89 | if self.min is not None and rv < self.min: 90 | return self.min 91 | if self.max is not None and rv > self.max: 92 | return self.max 93 | if self.min is not None and rv < self.min or \ 94 | self.max is not None and rv > self.max: 95 | if self.min is None: 96 | self.fail('%s is bigger than the maximum valid value ' 97 | '%s.' % (rv, self.max)) 98 | elif self.max is None: 99 | self.fail('%s is smaller than the minimum valid value ' 100 | '%s.' % (rv, self.min)) 101 | else: 102 | self.fail('%s is not in the valid range of %s to %s.' 103 | % (rv, self.min, self.max)) 104 | return rv 105 | 106 | def __repr__(self): 107 | return 'IntRange(%r, %r)' % (self.min, self.max) 108 | 109 | 110 | class BoolParamType(ParamTypeFactory): 111 | name = 'boolean' 112 | 113 | def convert(self, value): 114 | if isinstance(value, bool): 115 | return bool(value) 116 | value = value.lower() 117 | if value in ('true', '1', 'yes', 'y'): 118 | return True 119 | elif value in ('false', '0', 'no', 'n'): 120 | return False 121 | self.fail('%s is not a valid boolean' % value) 122 | 123 | def __repr__(self): 124 | return 'BOOL' 125 | 126 | 127 | class FloatParamType(ParamTypeFactory): 128 | name = 'float' 129 | 130 | def convert(self, value): 131 | try: 132 | return float(value) 133 | except (UnicodeError, ValueError): 134 | self.fail( 135 | '%s is not a valid floating point value' % value 136 | ) 137 | 138 | def __repr__(self): 139 | return 'FLOAT' 140 | 141 | 142 | class UUIDParameterType(ParamTypeFactory): 143 | name = 'uuid' 144 | 145 | def convert(self, value): 146 | import uuid 147 | try: 148 | if PY2 and isinstance(value, text_type): 149 | value = value.encode('ascii') 150 | return uuid.UUID(value) 151 | except (UnicodeError, ValueError): 152 | self.fail('%s is not a valid UUID value' % value) 153 | 154 | def __repr__(self): 155 | return 'UUID' 156 | 157 | 158 | class File(ParamTypeFactory): 159 | 160 | name = "filename" 161 | 162 | def __init__(self, mode="r", bufsize=-1): 163 | self.factory = FileType(mode=mode, bufsize=bufsize) 164 | 165 | def convert(self, value): 166 | return self.factory(value) 167 | 168 | 169 | class Choices(UnprocessedParamType): 170 | 171 | def __init__(self, choices, type=None): 172 | """ 173 | :type choices: tuple or list 174 | :type type: callable 175 | :param type: type convention function for all choices. 176 | Receives a `func(value)` as its argument. 177 | """ 178 | assert hasattr(choices, "index") 179 | self.choices = choices or tuple() 180 | self.type = type 181 | 182 | def __call__(self): 183 | """ 184 | Return keyword arguments 185 | :rtype: dict 186 | """ 187 | return { 188 | "type": self.type or self.convert, 189 | "choices": self.choices 190 | } 191 | 192 | 193 | UNPROCESSED = UnprocessedParamType() 194 | 195 | STRING = StringParamType() 196 | 197 | INT = IntParamType() 198 | 199 | FLOAT = FloatParamType() 200 | 201 | BOOL = BoolParamType() 202 | 203 | UUID = UUIDParameterType() -------------------------------------------------------------------------------- /src/examples/arg_types.py: -------------------------------------------------------------------------------- 1 | from cmdtree import command, argument, INT, entry, Choices 2 | 3 | 4 | @command("run") 5 | @argument("host", type=Choices(("host1", "host2", "host3"))) 6 | @argument("port", type=INT) 7 | def run_docker(host, port): 8 | print( 9 | "docker daemon api runs on {ip}:{port}".format( 10 | ip=host, 11 | port=port, 12 | ) 13 | ) 14 | 15 | 16 | if __name__ == "__main__": 17 | entry() -------------------------------------------------------------------------------- /src/examples/command.py: -------------------------------------------------------------------------------- 1 | from cmdtree import INT 2 | from cmdtree import command, argument, option 3 | 4 | 5 | @argument("host", help="server listen address") 6 | @option("reload", is_flag=True, help="if auto-reload on") 7 | @option("port", help="server port", type=INT, default=8888) 8 | @command(help="run a http server on given address") 9 | def run_server(host, reload, port): 10 | print( 11 | "Your server running on {host}:{port}, auto-reload is {reload}".format( 12 | host=host, 13 | port=port, 14 | reload=reload 15 | ) 16 | ) 17 | 18 | if __name__ == "__main__": 19 | from cmdtree import entry 20 | entry() -------------------------------------------------------------------------------- /src/examples/command_group.py: -------------------------------------------------------------------------------- 1 | from cmdtree import group, argument, entry 2 | 3 | 4 | @group("fake-docker", "fake-docker command binds") 5 | def fake_docker(): 6 | pass 7 | 8 | 9 | @group("docker", "docker command binds") 10 | @argument("ip", help="docker daemon ip addr") 11 | def docker(): 12 | pass 13 | 14 | 15 | # nested command 16 | @docker.command("run", help="run docker command") 17 | @argument("container-name") 18 | def run(ip, container_name): 19 | print( 20 | "container [{name}] on host [{ip}]".format( 21 | ip=ip, 22 | name=container_name, 23 | ) 24 | ) 25 | 26 | 27 | # nested command group 28 | @docker.group("image", help="image operation") 29 | def image(): 30 | pass 31 | 32 | 33 | @image.command("create") 34 | @argument("name") 35 | def image_create(ip, name): 36 | print( 37 | "iamge {name} on {ip} created.".format( 38 | ip=ip, 39 | name=name, 40 | ) 41 | ) 42 | 43 | 44 | if __name__ == "__main__": 45 | entry() -------------------------------------------------------------------------------- /src/examples/global_argument.py: -------------------------------------------------------------------------------- 1 | from cmdtree import command 2 | 3 | from cmdtree import entry, env 4 | env.root.argument("host", help="server listen address") 5 | 6 | 7 | @command(help="run a http server on given address") 8 | def run_server(host): 9 | print( 10 | "Your server running on {host}".format( 11 | host=host, 12 | ) 13 | ) 14 | 15 | if __name__ == "__main__": 16 | 17 | entry() -------------------------------------------------------------------------------- /src/examples/low-level/command_from_path.py: -------------------------------------------------------------------------------- 1 | from cmdtree.tree import CmdTree 2 | 3 | tree = CmdTree() 4 | 5 | 6 | def index(): 7 | print("Hi, you have 10 disks in your computer...") 8 | 9 | 10 | def show(disk_id): 11 | print("This is disk %s" % disk_id) 12 | 13 | 14 | def delete(disk_id): 15 | print("disk %s deleted" % disk_id) 16 | 17 | 18 | # Add list command 19 | tree.add_commands(["computer", "list"], index) 20 | 21 | # get the parser in any place, any time 22 | tree.add_commands(["computer", "show"], show) 23 | tree_node = tree.get_cmd_by_path(["computer", "show"]) 24 | show_parser = tree_node['cmd'] 25 | show_parser.argument("disk_id") 26 | 27 | # Add delete command 28 | delete3 = tree.add_commands(["computer", "delete"], delete) 29 | delete3.argument("disk_id") 30 | 31 | # run your tree 32 | tree.root.run() -------------------------------------------------------------------------------- /src/runtest.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | py.test --cov=cmdtree --cov-report=term-missing cmdtree 4 | -------------------------------------------------------------------------------- /src/setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | -------------------------------------------------------------------------------- /src/setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from setuptools import setup, find_packages 4 | 5 | HERE = os.path.abspath(os.path.dirname(__file__)) 6 | 7 | install_requires = ( 8 | "argparse", 9 | "six>=1.10.0", 10 | ) 11 | 12 | setup( 13 | name='cmdtree', 14 | version='0.0.5', 15 | packages=find_packages(HERE, include=['cmdtree']), 16 | install_requires=install_requires, 17 | url='https://github.com/winkidney/cmdtree', 18 | license='MIT', 19 | author='winkidney', 20 | author_email='winkidney@gmail.com', 21 | description='Yet another cli tool library ,' 22 | 'sub-command friendly, ' 23 | 'designed for cli auto-generating.', 24 | ) 25 | -------------------------------------------------------------------------------- /src/test-requirements.txt: -------------------------------------------------------------------------------- 1 | mock>=2.0.0 2 | pytest>=2.9.0 3 | pytest-cov --------------------------------------------------------------------------------