├── .coveragerc ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE.txt ├── README.md ├── cliche ├── __init__.py ├── argparser.py ├── choice.py ├── docstring_to_help.py ├── install.py ├── install_generator.py ├── types.py └── using_underscore.py ├── deploy.py ├── examples ├── advanced.py ├── calculator.py ├── choices.py ├── class_example.py ├── enums.py ├── exception_example.py ├── list_of_items.py ├── minimal.py ├── optional.py ├── py310.py └── pydantic_example.py ├── resources ├── cliche_rendered.png ├── logo.gif └── logo.jpg ├── setup.cfg ├── setup.py └── tests ├── test_base.py └── test_doc_reading.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | exclude_lines = 3 | pragma: no cover 4 | def __repr__ 5 | raise AssertionError 6 | raise NotImplementedError 7 | if __name__ == .__main__.: 8 | sys.argv 9 | group 10 | omit = 11 | setup.py 12 | deploy.py 13 | flycheck -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *#* 3 | *.DS_STORE 4 | *.log 5 | *Data.fs* 6 | *flymake* 7 | dist/* 8 | *egg* 9 | urllist* 10 | build/ 11 | __pycache__/ 12 | /.Python 13 | /bin/ 14 | docs/_build/ 15 | docs/_static/* 16 | !docs/_static/icon.png 17 | /include/ 18 | /lib/ 19 | /pip-selfcheck.json 20 | .tox/ 21 | .cache 22 | .coverage 23 | .coverage.* 24 | .coveralls.yml 25 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/asottile/seed-isort-config 3 | rev: v1.9.0 4 | hooks: 5 | - id: seed-isort-config 6 | - repo: https://github.com/pre-commit/mirrors-isort 7 | rev: v4.3.20 # Use the revision sha / tag you want to point at 8 | hooks: 9 | - id: isort 10 | - repo: https://github.com/ambv/black 11 | rev: 19.3b0 12 | hooks: 13 | - id: black 14 | - repo: https://github.com/gvanderest/pylama-pre-commit 15 | rev: 0.1.2 16 | hooks: 17 | - id: pylama 18 | default_language_version: 19 | python: python3.7 20 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2020 Pascal van Kooten 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | # Cliche 6 | 7 | Build a simple command-line interface from your functions. 8 | 9 | Features: 10 | 11 | - ✓ Least syntax required: you do not need to "learn a library" to use this 12 | - ✓ keeps it DRY (Don't Repeat yourself): 13 | - it uses all information available like *annotations*, *default values* and *docstrings*... yet does not require them. 14 | - ✓ Just decorate a function with `@cli` - that is it - it can now be called as CLI but also remains usable by other functions (unlike the click library) 15 | - ✓ Works with booleans (flags) and lists (multiple args) automatically 16 | - ✓ Standing on the shoulders of giants (i.e. it uses argparse and learnings from others) 17 | - ✓ Prints returned python objects in JSON (unless passing `--raw`) 18 | - ✓ Colorized output automatically 19 | - ✓ Allows creating executable by using `cliche install ` 20 | - ✓ Creates shortcuts, e.g. a variable "long_option" will be usable like `--long-option` and `-l` 21 | - ✓ No external dependencies -> lightweight 22 | 23 | ## Examples 24 | 25 | #### Simplest Example 26 | 27 | You want to make a calculator. You not only want its functions to be reusable, you also want it to be callable from command line. 28 | 29 | ```python 30 | # calculator.py 31 | from cliche import cli 32 | 33 | @cli 34 | def add(a: int, b: int): 35 | return a + b 36 | ``` 37 | 38 | Now let's see how to use it from the command-line: 39 | 40 | ``` 41 | pascal@archbook:~/calc$ cliche install calc 42 | pascal@archbook:~/calc$ calc add --help 43 | 44 | usage: calc add [-h] a b 45 | 46 | positional arguments: 47 | a |int| 48 | b |int| 49 | 50 | optional arguments: 51 | -h, --help show this help message and exit 52 | ``` 53 | 54 | thus: 55 | 56 | pascal@archbook:~/calc$ calc add 1 10 57 | 11 58 | 59 | #### Installation of commands 60 | 61 | You noticed we ran 62 | 63 | cliche install calc 64 | 65 | We can undo this with 66 | 67 | cliche uninstall calc 68 | 69 | Note that installing means that all `@cli` functions will be detected 70 | in the folder, not just of a single file, even after installation. You 71 | only have to install once, and on Linux it also adds autocompletion to 72 | your CLI if `argcomplete` has been installed. 73 | 74 | #### Advanced Example 75 | 76 | ```python 77 | from cliche import cli 78 | 79 | @cli 80 | def add_or_mul(a_number: int, b_number=10, sums=False): 81 | """ Adds or multiplies a and b 82 | 83 | :param a_number: the first one 84 | :param b_number: second one 85 | :param sums: Sums when true, otherwise multiply 86 | """ 87 | if sums: 88 | print(a_number + b_number) 89 | else: 90 | print(a_number * b_number) 91 | ``` 92 | 93 | Help: 94 | 95 | ![cliche rendered](./resources/cliche_rendered.png) 96 | 97 | Calling it: 98 | 99 | pascal@archbook:~/calc$ calc add_or_mul 1 100 | 10 101 | 102 | pascal@archbook:~/calc$ calc add_or_mul --sum 1 103 | 11 104 | 105 | pascal@archbook:~/calc$ calc add_or_mul 2 -b 3 106 | 6 107 | 108 | #### More examples 109 | 110 | Check the example files [here](https://github.com/kootenpv/cliche/tree/master/examples) 111 | 112 | ## Comparison with other CLI generators 113 | 114 | - argparse: it is powerful, but you need a lot of code to construct an argparse CLI 115 | - click: you need a lot of decorators to construct a CLI, and not obvious how to use it. It does not keep things DRY. Also, the annotated function is not usable. 116 | - hug (cli): connected to a whole web framework, but gets a lot right 117 | - python-fire: low set up, but annoying traces all the time / ugly design, does not show default values nor types 118 | - cleo: requires too much code/objects to construct 119 | -------------------------------------------------------------------------------- /cliche/__init__.py: -------------------------------------------------------------------------------- 1 | __project__ = "cliche" 2 | __version__ = "0.10.116" 3 | import sys 4 | import time 5 | import warnings 6 | 7 | if not getattr(sys, "cliche_ts__", False): 8 | sys.cliche_ts__ = 0 9 | 10 | import json 11 | import os 12 | import re 13 | import traceback 14 | from collections import defaultdict 15 | from inspect import currentframe, iscoroutinefunction, signature 16 | from types import ModuleType 17 | 18 | CLICHE_INIT_TS = time.time() 19 | 20 | try: 21 | import argcomplete 22 | 23 | ARGCOMPLETE_IMPORTED = True 24 | except ImportError: 25 | ARGCOMPLETE_IMPORTED = False 26 | 27 | 28 | from cliche.argparser import ( 29 | ColoredHelpOnErrorParser, 30 | add_arguments_to_command, 31 | add_command, 32 | bool_inverted, 33 | class_init_lookup, 34 | container_fn_name_to_type, 35 | get_desc_str, 36 | pydantic_models, 37 | ) 38 | from cliche.install import install, uninstall 39 | from cliche.using_underscore import UNDERSCORE_DETECTED 40 | 41 | CLICHE_AFTER_INIT_TS = time.time() 42 | loaded_modules_before = set(sys.modules) 43 | 44 | 45 | def get_class(f): 46 | vals = vars(sys.modules[f.__module__]) 47 | for attr in f.__qualname__.split(".")[:-1]: 48 | try: 49 | vals = vals[attr] 50 | except TypeError: 51 | return None 52 | if isinstance(vals, dict): 53 | return None 54 | return vals 55 | 56 | 57 | def get_init(f): 58 | cl = get_class(f) 59 | if cl is None: 60 | return None, None 61 | for init_class in cl.__mro__: 62 | init = init_class.__init__ 63 | if init is not None: 64 | return init_class, init 65 | return None 66 | 67 | 68 | def highlight(x) -> str: 69 | return f"\x1b[1;36m{x}\x1b[0m" 70 | 71 | 72 | def cli_info(**kwargs) -> None: 73 | """Outputs CLI and Python version info and exits.""" 74 | sv = sys.version_info 75 | python_version = f"{sv.major}.{sv.minor}.{sv.micro}" 76 | installed = False 77 | try: 78 | with open(sys.argv[0]) as f: 79 | txt = f.read() 80 | installed = "__import__(function_to_imports[key])" in txt 81 | file_path = re.findall('file_path = "(.+)"', txt) 82 | except FileNotFoundError: 83 | pass 84 | autocomplete = False 85 | try: 86 | name = os.path.basename(sys.argv[0]) 87 | with open(os.path.expanduser("~/.bashrc")) as f: 88 | autocomplete = f"register-python-argcomplete {name}" in f.read() 89 | except FileNotFoundError: 90 | pass 91 | v = f" (version {version[0]})" if version else "" 92 | print("Executable: ", highlight(name + v)) 93 | print("Executable path: ", highlight(sys.argv[0])) 94 | print("Cache path: ", highlight(sys.argv[0] + ".json")) 95 | print("Cliche version: ", highlight(__version__)) 96 | print("Installed by cliche: ", highlight(installed)) 97 | if installed: 98 | print("CLI directory: ", highlight(file_path[0])) 99 | print("Autocomplete enabled:", highlight(autocomplete), "(only possible on Linux)") 100 | print("Python Version: ", highlight(python_version)) 101 | print("Python Interpreter: ", highlight(sys.executable)) 102 | 103 | 104 | # t1 = time.time() 105 | 106 | fn_registry = {} 107 | fn_class_registry = {} 108 | main_called = [] 109 | version = [] 110 | use_timing = False 111 | module_count = defaultdict(int) # issue 9 112 | old_sys_argv = sys.argv.copy() 113 | the_group = "" 114 | the_cmd = "" 115 | 116 | if "--cli" in sys.argv: 117 | cli_info() 118 | sys.exit(0) 119 | 120 | if "--timing" in sys.argv: 121 | sys.argv.remove("--timing") 122 | use_timing = True 123 | print( 124 | "timing cliche modules loading", 125 | CLICHE_AFTER_INIT_TS - CLICHE_INIT_TS, 126 | ) 127 | print("diff inits", CLICHE_AFTER_INIT_TS - sys.cliche_ts__) 128 | 129 | 130 | def warn(x) -> None: 131 | sys.stderr.write("\033[31m" + x + "\033[0m\n") 132 | sys.stderr.flush() 133 | 134 | 135 | def cli(arg): 136 | if callable(arg): 137 | return inner_cli(arg) 138 | else: 139 | 140 | def d2(fn): 141 | return inner_cli(fn, arg) 142 | 143 | return d2 144 | 145 | 146 | def inner_cli(fn, group=""): 147 | # print(fn, time.time() - t1) # for debug 148 | current_modules = set(sys.modules) 149 | new_modules = current_modules - loaded_modules_before 150 | loaded_modules_before.update(current_modules) 151 | t1 = time.time() 152 | module = sys.modules[fn.__module__] 153 | fn.lookup = {} 154 | 155 | for x in dir(module): 156 | if x.startswith("_"): 157 | continue 158 | item = getattr(module, x) 159 | with warnings.catch_warnings(): 160 | warnings.filterwarnings(action="ignore", category=FutureWarning) 161 | if isinstance(item, ModuleType): 162 | sub_module = item 163 | for y in dir(sub_module): 164 | fn.lookup[(x, y)] = getattr(sub_module, y) 165 | fn.lookup[(x, y + "Value")] = getattr(sub_module, y) 166 | else: 167 | fn.lookup[(x,)] = getattr(module, x) 168 | fn.lookup[(x + "Value",)] = getattr(module, x) 169 | fn.lookup[(x, "V")] = getattr(module, x) 170 | if "." in fn.__qualname__: 171 | class_init_lookup[".".join(fn.__qualname__.split(".")[:-1]) + ".__init__"] = fn.lookup 172 | 173 | def decorated_fn(*args, **kwargs) -> None: 174 | no_traceback = False 175 | raw = False 176 | if "notraceback" in kwargs: 177 | no_traceback = kwargs.pop("notraceback") 178 | if "raw" in kwargs: 179 | raw = kwargs.pop("raw") 180 | if "cli" in kwargs: 181 | kwargs.pop("cli") 182 | if "pdb" in kwargs: 183 | kwargs.pop("pdb") 184 | if "timing" in kwargs: 185 | kwargs.pop("timing") 186 | try: 187 | if not UNDERSCORE_DETECTED: 188 | kwargs = {k.replace("-", "_"): v for k, v in kwargs.items()} 189 | if fn in pydantic_models: 190 | for var_name in pydantic_models[fn]: 191 | model, model_args = pydantic_models[fn][var_name] 192 | for m in model_args: 193 | kwargs.pop(m) 194 | kwargs[var_name] = model(**kwargs) 195 | fn_time = time.time() 196 | if iscoroutinefunction(fn): 197 | import asyncio 198 | 199 | res = asyncio.run(fn(*args, **kwargs)) 200 | else: 201 | res = fn(*args, **kwargs) 202 | if use_timing: 203 | print("timing function call success", time.time() - fn_time) 204 | if res is not None: 205 | if raw: 206 | print(res) 207 | else: 208 | try: 209 | print(json.dumps(res, indent=4)) 210 | except (TypeError, json.JSONDecodeError): 211 | print(res) 212 | except Exception as e: 213 | if use_timing: 214 | print("timing function call success", time.time() - fn_time) 215 | fn_name, sig = fn.__module__ + "." + fn.__name__, signature(fn) 216 | print(f"Fault while calling {fn_name}{sig} with the above arguments") 217 | if no_traceback: 218 | warn(traceback.format_exception_only(type(e), e)[-1].strip().split(" ", 1)[1]) 219 | sys.exit(1) 220 | else: 221 | raise 222 | 223 | fn_name = fn.__name__ 224 | if UNDERSCORE_DETECTED: 225 | fn_registry[(group, fn_name)] = (decorated_fn, fn) 226 | else: 227 | fn_registry[(group, fn_name)] = (decorated_fn, fn) # .replace("_", "-") 228 | if use_timing: 229 | new_m = len(new_modules) 230 | if new_m > 5: 231 | new_module_text = f"({new_m} new_modules since last cli decoration)" 232 | elif not new_modules: 233 | new_module_text = "(no new modules loaded)" 234 | else: 235 | new_module_text = f"(loaded {', '.join(new_modules)} module(s) since last cli decoration)" 236 | print( 237 | "timing preparing", 238 | fn_name, 239 | time.time() - t1, 240 | "since startup", 241 | time.time() - CLICHE_INIT_TS, 242 | new_module_text, 243 | ) 244 | return fn 245 | 246 | 247 | def add_traceback(parser) -> None: 248 | parser.add_argument( 249 | "--notraceback", 250 | action="store_true", 251 | default=False, 252 | help="Omit showing Python tracebacks", 253 | ) 254 | 255 | 256 | def add_pdb(parser) -> None: 257 | if [x for x in parser._actions if "--pdb" in x.option_strings]: 258 | return 259 | parser.add_argument( 260 | "--pdb", 261 | action="store_true", 262 | default=False, 263 | help="Drop into pdb on error", 264 | ) 265 | 266 | 267 | def add_timing(parser) -> None: 268 | if [x for x in parser._actions if "--timing" in x.option_strings]: 269 | return 270 | parser.add_argument( 271 | "--timing", 272 | action="store_true", 273 | default=False, 274 | help="Add timings of cliche and function call", 275 | ) 276 | 277 | 278 | def add_raw(parser) -> None: 279 | parser.add_argument( 280 | "--raw", 281 | action="store_true", 282 | default=False, 283 | help="Prevent function output as JSON", 284 | ) 285 | 286 | 287 | def add_cli(parser) -> None: 288 | parser.add_argument( 289 | "--cli", 290 | action="store_true", 291 | default=False, 292 | help=cli_info.__doc__, 293 | ) 294 | 295 | 296 | def add_cliche_self_parser(parser) -> None: 297 | subparsers = parser.add_subparsers(dest="command") 298 | installer = subparsers.add_parser("install", help="Create CLI from current folder") 299 | installer.add_argument("name", help="Name of the cli to create") 300 | installer.add_argument( 301 | "-m", 302 | "--module_dir", 303 | default=None, 304 | help="The root directory to search for functions (None defaults to current directory)", 305 | ) 306 | installer.add_argument( 307 | "-n", 308 | "--no-autocomplete", 309 | action="store_false", 310 | help="Default: False | Whether to add autocomplete support", 311 | ) 312 | add_cli(parser) 313 | add_pdb(parser) 314 | add_timing(parser) 315 | bool_inverted.add("no_autocomplete") 316 | fn_registry[("", "install")] = [install, install] 317 | uninstaller = subparsers.add_parser("uninstall", help="Delete CLI") 318 | uninstaller.add_argument("name", help="Name of the CLI to remove") 319 | fn_registry[("", "uninstall")] = [uninstall, uninstall] 320 | 321 | 322 | def add_class_arguments(cmd, fn, fn_name): 323 | abbrevs = None 324 | init_class, init = get_init(fn) 325 | if init is not None: 326 | group_name = "INITIALIZE CLASS: {}()".format(init.__qualname__.split(".")[0]) 327 | group = cmd.add_argument_group(group_name) 328 | var_names = [x for x in init.__code__.co_varnames if x not in ["self", "cls"]] 329 | fn_class_registry[fn_name] = (init_class, var_names) 330 | abbrevs = add_arguments_to_command(group, init, abbrevs) 331 | return abbrevs 332 | 333 | 334 | def add_optional_cliche_arguments(cmd) -> None: 335 | group = cmd.add_argument_group("OPTIONAL CLI ARGUMENTS") 336 | add_traceback(group) 337 | add_cli(group) 338 | add_pdb(group) 339 | add_timing(group) 340 | add_raw(group) 341 | 342 | 343 | def get_parser(): 344 | global the_group, the_cmd 345 | frame = currentframe().f_back 346 | module_doc = frame.f_code.co_consts[0] 347 | module_doc = module_doc if isinstance(module_doc, str) else None 348 | parser = ColoredHelpOnErrorParser(description=module_doc) 349 | 350 | from cliche import fn_registry 351 | 352 | if fn_registry: 353 | add_optional_cliche_arguments(parser) 354 | groups = {group for group, fn_name in fn_registry} 355 | fnames = {fn_name for group, fn_name in fn_registry} 356 | possible_group = sys.argv[1].replace("-", "_") if len(sys.argv) > 1 else "-" 357 | possible_cmd = sys.argv[2].replace("-", "_") if len(sys.argv) > 2 else "-" 358 | 359 | # if only one @cli and the second arg is not a command 360 | if (possible_group, possible_cmd) in fn_registry: 361 | # if len(sys.argv) == 3 or ("-h" in sys.argv or "--help" in sys.argv): 362 | the_group, the_cmd = possible_group, possible_cmd 363 | del sys.argv[1] 364 | del sys.argv[1] 365 | decorated_fn, fn = fn_registry[(possible_group, possible_cmd)] 366 | add_arguments_to_command(parser, fn) 367 | parser.description = get_desc_str(fn) 368 | elif len(fn_registry) == 1 and (len(sys.argv) < 2 or sys.argv[1].replace("-", "_") not in fnames): 369 | fn = next(iter(fn_registry.values()))[1] 370 | add_arguments_to_command(parser, fn) 371 | else: 372 | subparsers = parser.add_subparsers(dest="command") 373 | group_known = False 374 | if possible_group in groups: 375 | for (fn_group, fn_name), (_decorated_fn, fn) in fn_registry.items(): 376 | if possible_group == fn_group: 377 | cmd = add_command(subparsers, fn_name, fn) 378 | ColoredHelpOnErrorParser.sub_command = possible_group 379 | group_known = True 380 | if subparsers is None: 381 | subparsers = parser.add_subparsers(dest="command") 382 | if group_known: 383 | del sys.argv[1] 384 | if not group_known: 385 | group_fn_names = defaultdict(list) 386 | for (group, fn_name), (_decorated_fn, fn) in sorted( 387 | fn_registry.items(), key=lambda x: (x[0] == "info", x[0]) 388 | ): 389 | if group: 390 | group_fn_names[group].append(fn_name) 391 | else: 392 | cmd = add_command(subparsers, fn_name, fn) 393 | # for methods defined on classes, add those args 394 | abbrevs = add_class_arguments(cmd, fn, fn_name) 395 | add_arguments_to_command(cmd, fn, abbrevs) 396 | for group, fn_names in group_fn_names.items(): 397 | mod_parser = subparsers.add_parser(group, help=f"SUBCOMMAND -> ({', '.join(sorted(fn_names))})") 398 | group_parser = mod_parser.add_subparsers() 399 | for fn_name in fn_names: 400 | group_parser.add_parser(fn_name) 401 | else: 402 | add_cliche_self_parser(parser) 403 | 404 | return parser 405 | 406 | 407 | def main(exclude_module_names=None, version_info=None, *parser_args) -> None: 408 | global old_sys_argv 409 | t1 = time.time() 410 | if main_called: 411 | return 412 | main_called.append(True) 413 | # if "cliche" in sys.argv[0] and "cliche/" not in sys.argv[0]: 414 | # module_name = sys.argv[1] 415 | # sys.argv.remove(module_name) 416 | # import importlib.util 417 | 418 | # spec = importlib.util.spec_from_file_location("pydantic", module_name) 419 | # module = importlib.util.module_from_spec(spec) 420 | # spec.loader.exec_module(module) 421 | # ColoredHelpOnErrorParser.module_name = module_name 422 | 423 | if exclude_module_names is not None: 424 | # exclude module namespaces 425 | for x in exclude_module_names: 426 | for k, v in list(fn_registry.items()): 427 | _, fn = v 428 | if x in fn.__module__: 429 | fn_registry.pop(k) 430 | 431 | if version_info is not None: 432 | version.append(version_info) 433 | 434 | use_pdb = False 435 | if "--pdb" in sys.argv: 436 | sys.argv.remove("--pdb") 437 | old_sys_argv = sys.argv.copy() 438 | use_pdb = True 439 | 440 | parser = get_parser() 441 | 442 | if ARGCOMPLETE_IMPORTED: 443 | argcomplete.autocomplete(parser) 444 | 445 | group = "" 446 | tool_name = os.path.basename(sys.argv[0]) 447 | if the_group and the_cmd: 448 | ColoredHelpOnErrorParser.sub_command = f"{tool_name} {the_group} {the_cmd}" 449 | group = the_group 450 | 451 | elif old_sys_argv != sys.argv: 452 | group = old_sys_argv[1] 453 | ColoredHelpOnErrorParser.sub_command = f"{tool_name} {group}" 454 | 455 | if parser_args: 456 | parsed_args = parser.parse_args(parser_args) 457 | else: 458 | parsed_args = parser.parse_args() 459 | 460 | if use_timing: 461 | print("timing arg parsing", time.time() - t1) 462 | 463 | cmd = None 464 | if the_cmd: 465 | group, cmd = the_group, the_cmd 466 | else: 467 | try: 468 | cmd = parsed_args.command 469 | except AttributeError: 470 | if len(fn_registry) == 1: 471 | group, cmd = next(iter(fn_registry)) 472 | else: 473 | warn("No commands have been registered.\n") 474 | parser.print_help() 475 | sys.exit(3) 476 | 477 | kwargs = dict(parsed_args._get_kwargs()) 478 | if "command" in kwargs: 479 | kwargs.pop("command") 480 | for x in bool_inverted: 481 | if x in kwargs: 482 | # stripping "no-" from e.g. "--no-sums" 483 | kwargs[x[3:]] = kwargs.pop(x) 484 | if cmd is None: 485 | t2 = time.time() 486 | parser.print_help() 487 | if use_timing: 488 | print("timing print help", time.time() - t2) 489 | else: 490 | try: 491 | t3 = time.time() 492 | # test.... i think this is never filled, so lets try always with empty 493 | # starargs = parsed_args._get_args() 494 | starargs = [] 495 | group, cmd = group.replace("-", "_"), cmd.replace("-", "_") 496 | if cmd in fn_class_registry: 497 | init_class, init_varnames = fn_class_registry[cmd] 498 | kwargs = {k.replace("-", "_"): v for k, v in kwargs.items()} 499 | init_kwargs = {k: kwargs.pop(k) for k in init_varnames if k in kwargs} 500 | # [k for k in init_varnames if k not in init_kwargs] 501 | fn_registry[("", cmd)][0](init_class(**init_kwargs), **kwargs) 502 | else: 503 | for name, value in list(kwargs.items()): 504 | for key in [(cmd, name), (cmd, "--" + name)]: 505 | if key in container_fn_name_to_type: 506 | if value is not None: 507 | kwargs[name] = container_fn_name_to_type[key](value) 508 | fn_registry[(group, cmd)][0](*starargs, **kwargs) 509 | if use_timing: 510 | print("timing function call success", time.time() - t3) 511 | except: 512 | if not use_pdb: 513 | if use_timing: 514 | print("timing function call exception", time.time() - t3) 515 | raise 516 | try: 517 | import ipdb as pdb 518 | except ModuleNotFoundError: 519 | import pdb 520 | 521 | extype, value, tb = sys.exc_info() 522 | traceback.print_exc() 523 | pdb.post_mortem(tb) 524 | 525 | 526 | cli.main = main 527 | -------------------------------------------------------------------------------- /cliche/argparser.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import contextlib 3 | import re 4 | import sys 5 | from enum import Enum 6 | from typing import Union 7 | 8 | from cliche.choice import DictAction, EnumAction, ProtoEnumAction 9 | from cliche.docstring_to_help import parse_doc_params 10 | from cliche.using_underscore import UNDERSCORE_DETECTED 11 | 12 | pydantic_models = {} 13 | bool_inverted = set() 14 | CONTAINER_MAPPING = {"List": list, "Iterable": list, "Set": set, "Tuple": tuple} 15 | CONTAINER_MAPPING.update({k.lower(): v for k, v in CONTAINER_MAPPING.items()}) 16 | container_fn_name_to_type = {} 17 | class_init_lookup = {} # for class functions 18 | 19 | PYTHON_310_OR_HIGHER = sys.version_info >= (3, 10) 20 | IS_VERBOSE = {"verbose", "verbosity"} 21 | 22 | 23 | class ColoredHelpOnErrorParser(argparse.ArgumentParser): 24 | # color_dict is a class attribute, here we avoid compatibility 25 | # issues by attempting to override the __init__ method 26 | # RED : Error, GREEN : Okay, YELLOW : Warning, Blue: Help/Info 27 | color_dict = {"RED": "1;31", "GREEN": "1;32", "YELLOW": "1;33", "BLUE": "1;36"} 28 | # only when called with `cliche`, not `python` 29 | module_name = False 30 | 31 | def print_help(self, file=None) -> None: 32 | if file is None: 33 | file = sys.stdout 34 | self._print_message(self.format_help(), file, self.color_dict["BLUE"]) 35 | 36 | @staticmethod 37 | def make_subgroups(message): 38 | ind = message.find("SUBCOMMAND -> ") 39 | if ind == -1: 40 | return message 41 | z = message[:ind].rfind("\n") 42 | return message[:z] + "\n\nSUBCOMMANDS:" + message[z:].replace("SUBCOMMAND -> ", "") 43 | 44 | def _print_message(self, message, file=None, color=None) -> None: 45 | if message: 46 | message = message[0].upper() + message[1:] 47 | if self.module_name: 48 | repl = " ".join(["cliche " + self.module_name] + self.prog.split()[1:]) 49 | message = message.replace(self.prog, repl) 50 | if file is None: 51 | file = sys.stderr 52 | # Print messages in bold, colored text if color is given. 53 | if color is None: 54 | file.write(message) 55 | else: 56 | # \x1b[ is the ANSI Control Sequence Introducer (CSI) 57 | if hasattr(self, "sub_command"): 58 | message = message.replace(self.prog, self.sub_command) 59 | if color == self.color_dict["BLUE"]: 60 | message = message.strip() 61 | if len(self.prog.split()) > 1: 62 | message = message.replace("positional arguments:", "POSITIONAL ARGUMENTS:") 63 | else: 64 | # check if first is a positional arg or actual command 65 | ms = re.findall("positional arguments:. {([^}]+)..", message, flags=re.DOTALL) 66 | if ms: 67 | ms = ms[0] 68 | first_start = message.index("positional arguments") 69 | start = first_start + message[first_start:].index(ms) + len(ms) 70 | end = message.index("options:" if PYTHON_310_OR_HIGHER else "optional ") 71 | if all(x in message[start:end] for x in ms.split(",")): 72 | # remove the line that shows the possibl commands, like e.g. 73 | # {badd, print-item, add} 74 | message = re.sub( 75 | "positional arguments:. {[^ }]+..", 76 | "COMMANDS:\n", 77 | message, 78 | flags=re.DOTALL, 79 | ) 80 | message = message.replace("positional arguments:", "POSITIONAL ARGUMENTS:") 81 | 82 | message = self.make_subgroups(message) 83 | message = message.replace("options" if PYTHON_310_OR_HIGHER else "optional arguments", "OPTIONS:") 84 | lines = message.split("\n") 85 | inds = 1 86 | for i in range(1, len(lines)): 87 | if re.search("^[A-Z]", lines[i]): 88 | break 89 | if re.search(" +([{]|[.][.][.])", lines[i]): 90 | lines[i] = None 91 | else: 92 | inds += 1 93 | lines = [ 94 | "\x1b[" + color + "m" + "\n".join([x for x in lines[:inds] if x is not None]) + "\x1b[0m" 95 | ] + lines[inds:] 96 | message = "\n".join([x for x in lines if x is not None]) 97 | message = re.sub( 98 | "Default:.[^|]+", 99 | "\x1b[" + color + "m" + r"\g<0>" + "\x1b[0m", 100 | message, 101 | flags=re.DOTALL, 102 | ) 103 | reg = r"(\n *-[a-zA-Z]) (.+, --)( \[[A-Z0-9. ]+\])?" 104 | message = re.sub(reg, "\x1b[" + color + "m" + r"\g<1>" + "\x1b[0m, --", message) 105 | reg = r", (--[^ ]+)" 106 | message = re.sub(reg, ", " + "\x1b[" + color + "m" + r"\g<1> " + "\x1b[0m", message) 107 | 108 | for reg in [ 109 | "\n -h, --help", 110 | "\n {[^}]+}", 111 | "\n +--[^ ]+", 112 | "\n {1,6}[a-z0-9A-Z_-]+", 113 | ]: 114 | message = re.sub(reg, "\x1b[" + color + "m" + r"\g<0>" + "\x1b[0m", message) 115 | file.write(message + "\n") 116 | else: 117 | file.write("\x1b[" + color + "m" + message.strip() + "\x1b[0m\n") 118 | 119 | def exit(self, status=0, message=None) -> None: 120 | if message: 121 | self._print_message(message, sys.stderr, self.color_dict["RED"]) 122 | sys.exit(status) 123 | 124 | def error(self, message) -> None: 125 | # otherwise it prints generic help but it should print the specific help of the subcommand 126 | if "unrecognized arguments" in message: 127 | multiple_args = message.count(" ") > 2 128 | option_str = "Unknown option" if PYTHON_310_OR_HIGHER else "Unknown optional argument" 129 | 130 | type_arg_msg = option_str if "-" in message else "Extra positional argument" 131 | if multiple_args: 132 | type_arg_msg += "(s)" 133 | message = message.replace("unrecognized arguments", type_arg_msg) 134 | with contextlib.suppress(SystemExit): 135 | self.parse_args(sys.argv[1:-1] + ["--help"]) 136 | else: 137 | self.print_help(sys.stderr) 138 | self.exit(2, message) 139 | 140 | 141 | def get_desc_str(fn): 142 | doc_str = fn.__doc__ or "" 143 | desc = re.split("^ *Parameter|^ *Return|^ *Example|:param|\n\n", doc_str)[0].strip() 144 | desc = desc.replace("%", "%%") 145 | return desc[:1].upper() + desc[1:] 146 | 147 | 148 | def add_command(subparsers, fn_name, fn): 149 | desc = get_desc_str(fn) 150 | name = fn_name if UNDERSCORE_DETECTED else fn_name.replace("_", "-") 151 | return subparsers.add_parser(name, help=desc, description=desc) 152 | 153 | 154 | def is_pydantic(class_type): 155 | try: 156 | return "BaseModel" in [x.__name__ for x in class_type.__mro__] 157 | except AttributeError: 158 | return False 159 | 160 | 161 | def add_group(parser_cmd, model, fn, var_name, abbrevs) -> None: 162 | kwargs = [] 163 | pydantic_models[fn] = {} 164 | name = model.__name__ if UNDERSCORE_DETECTED else model.__name__.replace("_", "-") 165 | group = parser_cmd.add_argument_group(name) 166 | for field_name, field in model.__fields__.items(): 167 | kwargs.append(field_name) 168 | default = field.default if field.default is not None else "--1" 169 | default_help = f"Default: {default} | " if default != "--1" else "" 170 | tp = field.type_ 171 | container_type = tp in [list, set, tuple] 172 | with contextlib.suppress(AttributeError): 173 | container_type = CONTAINER_MAPPING.get(tp._name) 174 | if is_pydantic(tp): 175 | msg = "Cannot use nested pydantic just yet:" + f"property {var_name}.{field_name} of function {fn.__name__}" 176 | raise ValueError(msg) 177 | arg_desc = f"|{tp.__name__}| {default_help}" 178 | add_argument(group, tp, container_type, field_name, default, arg_desc, abbrevs) 179 | pydantic_models[fn][var_name] = (model, kwargs) 180 | 181 | 182 | def get_var_names(var_name, abbrevs): 183 | # adds shortenings when possible 184 | if var_name.startswith("--"): 185 | short = "-" + var_name[2] 186 | # don't add shortening for inverted bools 187 | if var_name.startswith(("--no-", "--no_")): 188 | var_names = [var_name] 189 | elif short not in abbrevs: 190 | abbrevs.append(short) 191 | var_names = [short, var_name] 192 | elif short.upper() not in abbrevs: 193 | abbrevs.append(short.upper()) 194 | var_names = [short.upper(), var_name] 195 | else: 196 | var_names = [var_name] 197 | else: 198 | var_names = [var_name] 199 | return var_names 200 | 201 | 202 | def protobuf_tp_converter(tp): 203 | def inner(x): 204 | return tp.Value(x) 205 | 206 | return inner 207 | 208 | 209 | def add_argument(parser_cmd, tp, container_type, var_name, default, arg_desc, abbrevs) -> None: 210 | kwargs = {} 211 | var_name = var_name if UNDERSCORE_DETECTED else var_name.replace("_", "-") 212 | arg_desc = arg_desc.replace("%", "%%") 213 | nargs = None 214 | if container_type: 215 | with contextlib.suppress(AttributeError): 216 | tp = tp.__args__[0] 217 | nargs = "*" 218 | if tp is bool: 219 | action = "store_true" if not default else "store_false" 220 | var_names = get_var_names("--" + var_name, abbrevs) 221 | parser_cmd.add_argument(*var_names, action=action, help=arg_desc) 222 | return 223 | try: 224 | if isinstance(tp, tuple): 225 | kwargs["action"] = DictAction 226 | elif "EnumTypeWrapper" in str(tp): 227 | kwargs["action"] = ProtoEnumAction 228 | elif issubclass(tp, Enum): 229 | kwargs["action"] = EnumAction 230 | # txt = "|".join(tp.__members__) 231 | # if len(txt) > 77: 232 | # txt = txt[:77] + "... " 233 | # kwargs["metavar"] = txt 234 | except TypeError: 235 | pass 236 | if default != "--1": 237 | var_name = "--" + var_name 238 | var_names = get_var_names(var_name, abbrevs) 239 | if nargs == "*" and default == "--1": 240 | default = container_type() 241 | if container_type: 242 | fn = parser_cmd.prog.split()[-1] 243 | container_fn_name_to_type[(fn, var_name)] = container_type 244 | parser_cmd.add_argument(*var_names, type=tp, nargs=nargs, default=default, help=arg_desc, **kwargs) 245 | 246 | 247 | def get_var_name_and_default(fn): 248 | arg_count = fn.__code__.co_argcount 249 | defs = fn.__defaults__ or () 250 | defaults = (("--1",) * arg_count + defs)[-arg_count:] 251 | for var_name, default in zip(fn.__code__.co_varnames, defaults, strict=False): 252 | if var_name in ["self", "cls"]: 253 | continue 254 | yield var_name, default 255 | 256 | 257 | def base_lookup(fn, tp, sans): 258 | tp_ending = tuple(tp.split(".")) 259 | sans_ending = tuple(sans.split(".")) 260 | if fn.__qualname__ in class_init_lookup: 261 | fn.lookup = class_init_lookup[fn.__qualname__] 262 | if tp_ending in fn.lookup: 263 | tp_name = tp 264 | tp = fn.lookup[tp_ending] 265 | elif sans_ending in fn.lookup: 266 | tp_name = sans 267 | tp = fn.lookup[sans_ending] 268 | else: 269 | tp_name = sans 270 | tp = __builtins__.get(sans, sans) 271 | return tp, tp_name 272 | 273 | 274 | def optional_pipe_lookup(fn, tp) -> None: 275 | if tp.startswith("None | "): 276 | sans_optional = tp[7:] 277 | elif tp.endswith("| None"): 278 | sans_optional = tp[:-7] 279 | else: 280 | msg = f"Optional confusion: {fn} {tp}" 281 | raise Exception(msg) 282 | return base_lookup(fn, tp, sans_optional) 283 | 284 | 285 | def optional_lookup(fn, tp): 286 | if isinstance(tp, str) and "|" in tp: 287 | return optional_pipe_lookup(fn, tp) 288 | if type(tp).__name__ == "UnionType": 289 | assert len(tp.__args__) == 2, "Union may at most have 2 types" 290 | assert type(None) in tp.__args__, "Union must have one None" 291 | a, b = tp.__args__ 292 | if type(a) == type(None): 293 | b, a = a, b 294 | return base_lookup(fn, a.__name__, a.__name__) 295 | sans_optional = tp.replace("Optional[", "") 296 | if tp != sans_optional: # strip ] 297 | sans_optional = sans_optional[:-1] 298 | return base_lookup(fn, tp, sans_optional) 299 | 300 | 301 | def container_lookup(fn, tp, container_name): 302 | sans_container = tp.replace(f"{container_name}[", "") 303 | if tp != sans_container: # strip ] 304 | sans_container = sans_container[:-1].split(",")[0].strip() 305 | return base_lookup(fn, tp, sans_container) 306 | 307 | 308 | def get_fn_info(fn, var_name, default): 309 | default_type = type(default) if default != "--1" and default is not None else None 310 | tp = fn.__annotations__.get(var_name, default_type or str) 311 | # List, Iterable, Set, Tuple 312 | container_type = False 313 | found_result = True 314 | tp_name = "bugggg" 315 | found_result = default_type in [list, set, tuple, dict] 316 | if default_type in [list, set, tuple, dict]: 317 | container_type = default_type 318 | if "typing" not in str(tp): 319 | tp_args = ", ".join({type(x).__name__ for x in default}) or "str" 320 | tp_name = "1 or more of: " + tp_args 321 | else: 322 | tp_args = ", ".join(x.__name__ for x in tp.__args__) 323 | tp_name = "1 or more of: " + tp_args 324 | if hasattr(tp, "__args__"): 325 | tp = tp.__args__[0] 326 | elif len({type(x) for x in default}) > 1: 327 | tp = None 328 | elif default: 329 | if container_type is dict: 330 | tp = (type(next(iter(default))), type(next(iter(default.values())))) 331 | elif container_lookup(fn, tp, "tuple")[0] != tp: 332 | # tp = container_lookup(fn, tp, "tuple")[0] 333 | found_result = False 334 | elif container_lookup(fn, tp, "list")[0] != tp: 335 | # tp = container_lookup(fn, tp, "list")[0] 336 | found_result = False 337 | else: 338 | tp = type(next(iter(default))) 339 | else: 340 | found_result = False 341 | if not found_result: 342 | try: 343 | if tp.__origin__ == Union: 344 | tp = tp.__args__[0] 345 | container_type = CONTAINER_MAPPING.get(tp._name) 346 | except AttributeError: 347 | pass 348 | if "dict[" in str(tp).lower(): 349 | if str(tp).lower().startswith("optional"): 350 | tp = tp[9:-1] 351 | aa, bb = tp[5:-1].split(", ") 352 | tp = (base_lookup(fn, tp, aa), base_lookup(fn, tp, bb)) 353 | container_type = dict 354 | else: 355 | for container_name, container in CONTAINER_MAPPING.items(): 356 | if isinstance(tp, str) and container_name in tp: 357 | tp, tp_name = container_lookup(fn, tp, container_name) 358 | container_type = container 359 | tp_name = "0 or more of: " + tp_name 360 | break 361 | else: 362 | if container_type: 363 | if not hasattr(tp, "__args__"): 364 | tp_arg = "str" 365 | tp = str 366 | else: 367 | if tp.__args__ and "Union" in str(tp.__args__[0]): 368 | # cannot cast 369 | tp_arg = "str" 370 | elif tp.__args__: 371 | tp_arg = tp.__args__[0].__name__ 372 | else: 373 | tp_arg = "str" 374 | tp = tp.__args__[0] 375 | tp_name = "0 or more of: " + tp_arg 376 | elif tp == "str": 377 | tp = str 378 | tp_name = "str" 379 | elif isinstance(tp, str) and base_lookup(fn, tp, "")[0]: 380 | tp, tp_name = base_lookup(fn, tp, "") 381 | elif tp.__class__.__name__ == "EnumTypeWrapper": 382 | tp_name = tp._enum_type.name 383 | elif hasattr(tp, "__name__"): 384 | tp_name = tp.__name__ 385 | else: 386 | tp, tp_name = optional_lookup(fn, tp) 387 | return tp, tp_name, default, container_type 388 | 389 | 390 | def add_arguments_to_command(cmd, fn, abbrevs=None): 391 | doc_str = fn.__doc__ or "" 392 | doc_params = parse_doc_params(doc_str) 393 | abbrevs = abbrevs or ["-h"] 394 | for var_name, default in get_var_name_and_default(fn): 395 | tp, tp_name, default, container_type = get_fn_info(fn, var_name, default) 396 | if is_pydantic(tp): 397 | # msg = f"Cannot use pydantic just yet, argument {var_name!r} (type {tp.__name__}) on cmd {cmd.prog!r}" 398 | # raise ValueError(msg) 399 | add_group(cmd, tp, fn, var_name, abbrevs) 400 | continue 401 | doc_text = doc_params.get(var_name, "") 402 | # changing the name to "no_X" in case the default is True for X, since we should set a flag to invert it 403 | # e.g. --sums becomes --no-sums 404 | if tp == bool and default is True: 405 | var_name = "no_" + var_name 406 | bool_inverted.add(var_name) 407 | default = False 408 | default_help = f"Default: {default} | " if default != "--1" else "" 409 | default = True 410 | else: 411 | if isinstance(default, Enum): 412 | default_fmt = default.name 413 | elif default == "--1": 414 | default_fmt = "" 415 | elif container_type and "Wrapper" in str(tp) and default: 416 | default_fmt = str(container_type([tp.Name(x) for x in default])).replace("'", "").replace('"', "") 417 | elif "Wrapper" in str(tp) and default: 418 | default_fmt = tp.Name(default) 419 | else: 420 | default_fmt = default 421 | default_help = f"Default: {default_fmt} | " if default != "--1" else "" 422 | arg_desc = f"|{tp_name}| {default_help}" + doc_text 423 | add_argument(cmd, tp, container_type, var_name, default, arg_desc, abbrevs) 424 | return abbrevs 425 | -------------------------------------------------------------------------------- /cliche/choice.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | from argparse import Action 3 | from enum import Enum 4 | 5 | 6 | def Choice(*args): 7 | return Enum("Choice", args) 8 | 9 | 10 | # credits: https://stackoverflow.com/a/60750535/1575066 11 | 12 | 13 | class EnumAction(Action): 14 | """Argparse action for handling Enums""" 15 | 16 | def __init__(self, **kwargs) -> None: 17 | # Pop off the type value 18 | enum = kwargs.pop("type", None) 19 | 20 | # Ensure an Enum subclass is provided 21 | if enum is None: 22 | msg = "type must be assigned an Enum when using EnumAction" 23 | raise ValueError(msg) 24 | if not issubclass(enum, Enum): 25 | msg = "type must be an Enum when using EnumAction" 26 | raise TypeError(msg) 27 | # Generate choices from the Enum 28 | kwargs.setdefault("choices", tuple(e.name for e in enum)) 29 | 30 | super().__init__(**kwargs) 31 | 32 | self._enum = enum 33 | 34 | def lookup(self, value): 35 | if value in self._enum.__members__: 36 | enum = self._enum[value] 37 | else: 38 | with contextlib.suppress(ValueError): 39 | value = int(value) 40 | enum = self._enum(value) 41 | return enum 42 | 43 | def __call__(self, parser, namespace, values, option_string=None): 44 | # Convert value back into an Enum 45 | if isinstance(values, list): 46 | result = [self.lookup(x) for x in values] 47 | else: 48 | result = self.lookup(values) 49 | setattr(namespace, self.dest, result) 50 | 51 | 52 | class DictAction(Action): 53 | """Argparse action for handling Protobuf Enums""" 54 | 55 | def __init__(self, **kwargs) -> None: 56 | self.key_class = kwargs["type"][0][0] 57 | self.value_class = kwargs["type"][1][0] 58 | 59 | self._tps = kwargs.pop("type", None) 60 | 61 | super().__init__(**kwargs) 62 | 63 | def enum_lookup(self, key_or_value, value): 64 | enum = None 65 | if key_or_value == "key": 66 | if value in self.key_class.__members__: 67 | enum = self.key_class[value] 68 | elif key_or_value == "value": 69 | if value in self.value_class.__members__: 70 | enum = self.value_class[value] 71 | if enum is None: 72 | with contextlib.suppress(ValueError): 73 | value = int(value) 74 | enum_class = self.key_class if key_or_value == "key" else self.value_class 75 | enum = enum_class(value) 76 | return enum 77 | 78 | def proto_enum_lookup(self, key_or_value, value): 79 | if key_or_value == "key": 80 | res = dict(zip(self.key_class.keys(), self.key_class.values(), strict=False)) 81 | if value in res: 82 | return res[value] 83 | try: 84 | return int(value) 85 | except: 86 | msg = f"{value} not in Protobuf type {self.key_class._enum_type.name}, valid keys: {list(res.keys())}" 87 | raise ValueError(msg) 88 | if key_or_value == "value": 89 | res = dict(zip(self.value_class.keys(), self.value_class.values(), strict=False)) 90 | if value in res: 91 | return res[value] 92 | try: 93 | return int(value) 94 | except: 95 | msg = ( 96 | f"{value} not in Protobuf type {self.value_class._enum_type.name}, valid values: {list(res.keys())}" 97 | ) 98 | raise ValueError(msg) 99 | return None 100 | 101 | def key_lookup(self, key): 102 | if hasattr(self.key_class, "__members__"): 103 | return self.enum_lookup("key", key) 104 | if "EnumTypeWrapper" in str(self.key_class): 105 | return self.proto_enum_lookup("key", key) 106 | return self.key_class(key) 107 | 108 | def value_lookup(self, value): 109 | if hasattr(self.key_class, "__members__"): 110 | return self.enum_lookup("value", value) 111 | if "EnumTypeWrapper" in str(self.value_class): 112 | return self.proto_enum_lookup("value", value) 113 | try: 114 | return self.value_class(value) 115 | except TypeError: 116 | return value 117 | 118 | def single_lookup(self, v) -> dict: 119 | key, value = v.split("=") 120 | return {self.key_lookup(key): self.value_lookup(value)} 121 | 122 | def __call__(self, parser, namespace, values, option_string=None): 123 | # Convert value back into an Enum 124 | if isinstance(values, list): 125 | result = {self.key_lookup(x.split("=")[0]): self.value_lookup(x.split("=")[1]) for x in values} 126 | else: 127 | result = self.single_lookup(values) 128 | setattr(namespace, self.dest, result) 129 | 130 | 131 | class ProtoEnumAction(Action): 132 | """Argparse action for handling Protobuf Enums""" 133 | 134 | def __init__(self, **kwargs) -> None: 135 | # Pop off the type value 136 | enum = kwargs.pop("type", None) 137 | 138 | # Ensure an Enum subclass is provided 139 | if enum is None: 140 | msg = "type must be assigned an Enum when using EnumAction" 141 | raise ValueError(msg) 142 | # Generate choices from the Enum 143 | kwargs.setdefault("choices", tuple(enum.keys())) 144 | 145 | super().__init__(**kwargs) 146 | 147 | self._enum = enum 148 | 149 | def lookup(self, value): 150 | return self._enum.Value(value) 151 | 152 | def __call__(self, parser, namespace, values, option_string=None): 153 | # Convert value back into an Enum 154 | if isinstance(values, list): 155 | result = [self.lookup(x) for x in values] 156 | else: 157 | result = self.lookup(values) 158 | setattr(namespace, self.dest, result) 159 | -------------------------------------------------------------------------------- /cliche/docstring_to_help.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | GOOGLE_DOC_RE = re.compile(r"^[^ ] +:|^[^ ]+ +\([^\)]+\):") 4 | 5 | 6 | def parse_google_param_descriptions(doc): 7 | stack = {} 8 | results = {} 9 | args_seen = False 10 | for line in doc.split("\n"): 11 | line = line.strip() 12 | if line == "Returns:": 13 | break 14 | if not args_seen: 15 | if line == "Args:": 16 | args_seen = True 17 | elif GOOGLE_DOC_RE.search(line): 18 | if stack: 19 | results[stack["fn"]] = "\n".join(stack["lines"]) 20 | fn_name = line.split(":")[0].split()[0] 21 | stack = {"fn": fn_name, "lines": [GOOGLE_DOC_RE.sub("", line).strip()]} 22 | elif stack and line.strip(): 23 | stack["lines"].append(line.strip()) 24 | if stack: 25 | results[stack["fn"]] = "\n".join(stack["lines"]) 26 | return results 27 | 28 | 29 | def parse_sphinx_param_descriptions(doc): 30 | stack = {} 31 | results = {} 32 | for line in doc.split("\n"): 33 | line = line.strip() 34 | if line.startswith(":"): 35 | if stack: 36 | results[stack["fn"]] = "\n".join(stack["lines"]) 37 | stack = {} 38 | if line.startswith(":param"): 39 | # e.g. :param name (str): 40 | if re.search(r":param +[^ ]+ +[(]", line): 41 | fn_name = line.split(":")[1].split()[-2] 42 | # e.g. :param name: 43 | else: 44 | fn_name = line.split(":")[1].split()[-1] 45 | stack = {"fn": fn_name, "lines": [line.split(":", 2)[2].strip()]} 46 | elif stack and line.strip(): 47 | stack["lines"].append(line.strip()) 48 | if stack: 49 | results[stack["fn"]] = "\n".join(stack["lines"]) 50 | return results 51 | 52 | 53 | def parse_doc_params(doc_str): 54 | doc_params = parse_sphinx_param_descriptions(doc_str) 55 | doc_params.update(parse_google_param_descriptions(doc_str)) 56 | return doc_params 57 | -------------------------------------------------------------------------------- /cliche/install.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import sys 4 | import platform 5 | 6 | 7 | def install(name, autocomplete=True, module_dir=None, overwrite=False, **kwargs): 8 | cliche_path = os.path.dirname(os.path.realpath(__file__)) 9 | with open(sys.argv[0]) as f: 10 | first_line = f.read().split("\n")[0] 11 | cwd = module_dir or os.getcwd() 12 | bin_path = os.path.dirname(sys.argv[0]) 13 | bin_name = os.path.join(bin_path, name) 14 | if os.path.exists(bin_name): 15 | if not overwrite: 16 | raise FileExistsError(bin_name) 17 | template_path = os.path.join(cliche_path, "install_generator.py") 18 | with open(template_path) as f: 19 | template = f.read() 20 | with open(bin_name, "w") as f: 21 | for k, v in {"cwd": cwd, "bin_name": bin_name, "first_line": first_line}.items(): 22 | template = re.sub("{{ *" + re.escape(k) + " *}}", v, template) 23 | f.write(template) 24 | os.system(f'chmod +x "{bin_name}"') 25 | if autocomplete and platform.system() == "Linux": 26 | try: 27 | import argcomplete 28 | except ImportError: 29 | print("Can't import argcomplete. either run with --no_autocomplete or install argcomplete") 30 | raise 31 | os.system(f"""echo 'eval "$({bin_path}/register-python-argcomplete {name})"' >> ~/.bashrc""") 32 | print("Note: for autocomplete to work, please reopen a terminal.") 33 | 34 | 35 | def uninstall(name, **kwargs): 36 | bin_path = os.path.dirname(sys.argv[0]) 37 | bin_name = os.path.join(bin_path, name) 38 | with open(bin_name) as f: 39 | txt = f.read() 40 | if "from cliche" not in txt: 41 | raise ValueError(f"The command {name!r} does not seem to have been installed by cliche") 42 | try: 43 | os.remove(bin_name) 44 | except FileNotFoundError: 45 | pass 46 | try: 47 | os.remove(bin_name + ".json") 48 | except FileNotFoundError: 49 | pass 50 | if platform.system() == "Linux": 51 | with open(os.path.expanduser("~/.bashrc")) as f: 52 | inp = f.read() 53 | autocomplete_line = f'register-python-argcomplete {name})"\n' 54 | if autocomplete_line in inp: 55 | inp = "\n".join([x for x in inp.split("\n") if autocomplete_line.strip() not in x]) 56 | with open(os.path.expanduser("~/.bashrc"), "w") as f: 57 | f.write(inp) 58 | 59 | 60 | def runner(): 61 | import importlib 62 | 63 | module_name = os.path.basename(sys.argv[0]) 64 | source_code_dir = os.path.dirname(importlib.util.find_spec(module_name).origin) 65 | install(module_name, file_path=source_code_dir, overwrite=True) 66 | 67 | os.execv(sys.argv[0], sys.argv) 68 | -------------------------------------------------------------------------------- /cliche/install_generator.py: -------------------------------------------------------------------------------- 1 | {{first_line}} 2 | 3 | # the above should be dynamic 4 | import os 5 | import sys 6 | import re 7 | import time 8 | import json 9 | import glob 10 | 11 | sys.cliche_loaded_modules__ = set(sys.modules) 12 | sys.cliche_ts__ = time.time() 13 | use_timing = "--timing" in sys.argv 14 | 15 | any_change = False 16 | new_cache = {} 17 | 18 | file_path = "{{cwd}}" 19 | sys.path.insert(0, file_path) 20 | 21 | new_cache = {} 22 | # cache filename should be dynamic 23 | try: 24 | with open("{{bin_name}}.json") as f: 25 | cache = json.load(f) 26 | except (FileNotFoundError, json.JSONDecodeError): 27 | cache = {} 28 | 29 | if use_timing: 30 | print("timing cache load", time.time() - sys.cliche_ts__) 31 | 32 | # this path should be dynamic 33 | for x in glob.glob(f"{file_path}/**/*.py", recursive=True): 34 | if any(e in x for e in ["#", "flycheck", "swp"]): 35 | continue 36 | mod_date = os.stat(x)[8] 37 | if x in cache: 38 | new_cache[x] = cache[x] 39 | if cache[x]["mod_date"] == mod_date: 40 | continue 41 | any_change = True 42 | with open(x) as f: 43 | contents = f.read() 44 | # functions = re.findall(r"^ *@cli *\n *def ([^( ]+)+", contents, re.M) 45 | functions = re.findall(r"^ *@cli(?:\(.([a-zA-Z0-9_]+).\))? *\n *(?:async )?def ([^( ]+)+", contents, re.M) 46 | version = re.findall("""^ *__version__ = ['"]([^'"]+)""", contents) 47 | module_name = x.replace(file_path, "").strip("/").replace("/", ".").replace(".py", "") 48 | cache[x] = { 49 | "mod_date": mod_date, 50 | "functions": functions, 51 | "filename": x, 52 | "import_name": module_name, 53 | } 54 | # getattr(importlib.import_module(module_name), functions[0][1]) 55 | if version: 56 | cache[x]["version_info"] = version[0] 57 | new_cache[x] = cache[x] 58 | 59 | if use_timing: 60 | print("timing cache build", time.time() - sys.cliche_ts__) 61 | 62 | if any_change: 63 | cache = new_cache 64 | with open("{{bin_name}}.json", "w") as f: 65 | json.dump(cache, f) 66 | 67 | function_to_imports = {} 68 | version_info = None 69 | for cache_value in cache.values(): 70 | import_name = cache_value["import_name"] 71 | functions = cache_value["functions"] 72 | version_info = version_info or cache_value.get("version_info") 73 | if not functions: 74 | continue 75 | module_name = import_name.split(".")[-1] 76 | for group, function in functions: 77 | function_to_imports[(group, function)] = import_name 78 | 79 | if use_timing: 80 | print("timing function build", time.time() - sys.cliche_ts__) 81 | 82 | 83 | def fallback(version_info=None): 84 | if use_timing: 85 | print("before imports", time.time() - sys.cliche_ts__) 86 | for import_name in sorted(set(function_to_imports.values())): 87 | t1 = time.time() 88 | __import__(import_name) 89 | if use_timing: 90 | print("import time", import_name, time.time() - sys.cliche_ts__) 91 | if use_timing: 92 | print("before main import", time.time() - sys.cliche_ts__) 93 | from cliche import main 94 | 95 | main(version_info=version_info) 96 | 97 | 98 | if len(sys.argv) > 1: 99 | one = sys.argv[1].replace("-", "_") 100 | two = sys.argv[2].replace("-", "_") if len(sys.argv) > 2 else "-" 101 | for key in [(one, two), ("", one)]: 102 | if key in function_to_imports: 103 | __import__(function_to_imports[key]) 104 | if use_timing: 105 | print("before main import", time.time() - sys.cliche_ts__) 106 | from cliche import main 107 | 108 | main(version_info=version_info) 109 | break 110 | else: 111 | if use_timing: 112 | print("before fallback", time.time() - sys.cliche_ts__) 113 | fallback(version_info=version_info) 114 | else: 115 | if use_timing: 116 | print("before fallback", time.time() - sys.cliche_ts__) 117 | fallback(version_info=version_info) 118 | 119 | if use_timing: 120 | print("kk", time.time() - sys.cliche_ts__) 121 | -------------------------------------------------------------------------------- /cliche/types.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, date 2 | 3 | 4 | class DateStr(date): 5 | def __new__(_cls, date_str: str) -> date: # type: ignore # pylint: disable=signature-differs 6 | return datetime.strptime(date_str, "%Y-%m-%d").date() 7 | 8 | 9 | class DateTimeStr(datetime): 10 | def __new__(_cls, date_str: str) -> datetime: # type: ignore # pylint: disable=signature-differs 11 | return datetime.fromisoformat(date_str) 12 | -------------------------------------------------------------------------------- /cliche/using_underscore.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | UNDERSCORE_DETECTED = False 4 | if len(sys.argv) > 1: 5 | UNDERSCORE_DETECTED = "_" in sys.argv[1] 6 | for x in sys.argv[1:]: 7 | if x.startswith("-") and "_" in x: 8 | UNDERSCORE_DETECTED = True 9 | -------------------------------------------------------------------------------- /deploy.py: -------------------------------------------------------------------------------- 1 | """ File unrelated to the package, except for convenience in deploying """ 2 | import re 3 | import sys 4 | import sh 5 | import os 6 | 7 | commit_count = sh.git("rev-list", ["--all"]).count("\n") 8 | 9 | with open("setup.py") as f: 10 | setup = f.read() 11 | 12 | setup = re.sub('MICRO_VERSION = "[0-9]+"', 'MICRO_VERSION = "{}"'.format(commit_count), setup) 13 | 14 | major = re.search('MAJOR_VERSION = "([0-9]+)"', setup).groups()[0] 15 | minor = re.search('MINOR_VERSION = "([0-9]+)"', setup).groups()[0] 16 | micro = re.search('MICRO_VERSION = "([0-9]+)"', setup).groups()[0] 17 | version = "{}.{}.{}".format(major, minor, micro) 18 | 19 | with open("setup.py", "w") as f: 20 | f.write(setup) 21 | 22 | name = os.getcwd().split("/")[-1] 23 | 24 | with open(f"{name}/__init__.py") as f: 25 | init = f.read() 26 | 27 | with open(f"{name}/__init__.py", "w") as f: 28 | f.write(re.sub('__version__ = "[0-9.]+"', '__version__ = "{}"'.format(version), init)) 29 | 30 | os.system("rm -rf dist/") 31 | os.system(f"{sys.executable} setup.py sdist bdist_wheel") 32 | os.system("twine upload dist/*") 33 | -------------------------------------------------------------------------------- /examples/advanced.py: -------------------------------------------------------------------------------- 1 | """Can be called like "python advanced.py" or "cliche advanced.py" """ 2 | from cliche import cli, main 3 | 4 | 5 | @cli 6 | def badd(a_string: str, a_number: int = 10): 7 | """ Sums a_string and a_number, but we all expect it to fail. 8 | 9 | :param a_string: the first one 10 | :param a_number: This parameter seems to be 11 | really good but i don't know tho 12 | """ 13 | print(a_string + a_number) 14 | 15 | 16 | @cli 17 | def add(a_number: int, b_number: int = 10): 18 | print(a_number + b_number) 19 | 20 | 21 | @cli 22 | def sum_or_multiply(a_number: int, b_number: int = 10, sums: bool = True): 23 | """ Sums or multiplies a and b 24 | 25 | :param a_number: the first one 26 | :param b_number: This parameter seems to be 27 | :param sums: When True, sums instead of multiply 28 | """ 29 | if sums: 30 | print(a_number + b_number) 31 | else: 32 | print(a_number * b_number) 33 | 34 | 35 | if __name__ == "__main__": 36 | main() 37 | -------------------------------------------------------------------------------- /examples/calculator.py: -------------------------------------------------------------------------------- 1 | from cliche import cli 2 | 3 | @cli 4 | def add(a: int, b: int): 5 | print(a + b) 6 | 7 | 8 | @cli 9 | def sum_or_multiply(a_number: int, b_number: int = 10, sums: bool = False): 10 | """ Sums or multiplies a and b 11 | 12 | :param a_number: the first one 13 | :param b_number: This parameter seems to be 14 | :param sums: Sums when true, otherwise multiply 15 | """ 16 | if sums: 17 | print(a_number + b_number) 18 | else: 19 | print(a_number * b_number) 20 | -------------------------------------------------------------------------------- /examples/choices.py: -------------------------------------------------------------------------------- 1 | from cliche import cli, main, Choice 2 | 3 | 4 | # class Color(Enum): 5 | # BLUE = 1 6 | # RED = 2 7 | 8 | 9 | @cli 10 | def choices(color: Choice("red", "blue") = "red"): 11 | print(color) 12 | 13 | 14 | if __name__ == "__main__": 15 | main() 16 | -------------------------------------------------------------------------------- /examples/class_example.py: -------------------------------------------------------------------------------- 1 | from cliche import cli, main 2 | 3 | 4 | @cli 5 | def a(a=1): 6 | pass 7 | 8 | 9 | class A: 10 | def __init__(self, c, d=1): 11 | self.c = c 12 | self.d = d 13 | 14 | @cli 15 | def printer_a(self): 16 | print(self.c, self.d) 17 | 18 | 19 | class B(A): 20 | @cli 21 | def printer_a(self, a, b=1): 22 | print(a, b, self.c, self.d) 23 | 24 | 25 | if __name__ == "__main__": 26 | main() 27 | -------------------------------------------------------------------------------- /examples/enums.py: -------------------------------------------------------------------------------- 1 | from cliche import cli, main, Enum 2 | 3 | 4 | class Color(Enum): 5 | BLUE = 1 6 | RED = 2 7 | 8 | 9 | @cli 10 | def enums(color: Color): 11 | print(color) 12 | 13 | 14 | if __name__ == "__main__": 15 | main() 16 | -------------------------------------------------------------------------------- /examples/exception_example.py: -------------------------------------------------------------------------------- 1 | from cliche import cli 2 | 3 | 4 | @cli 5 | def exception_example(): 6 | raise ValueError("No panic! This is a known error") 7 | -------------------------------------------------------------------------------- /examples/list_of_items.py: -------------------------------------------------------------------------------- 1 | from cliche import List, cli, Set 2 | 3 | 4 | @cli 5 | def a(z=[1, 1]): 6 | """ Test default list default value """ 7 | print(z) 8 | 9 | 10 | @cli 11 | def b(z: List[int] = [1, 1]): 12 | """ Test list typing and list default value """ 13 | print(z) 14 | 15 | 16 | @cli 17 | def c(z: List[int]): 18 | """ Test list typing """ 19 | print(z) 20 | 21 | 22 | @cli 23 | def d(z: List[str]): 24 | """ Test list typing """ 25 | print(z) 26 | 27 | 28 | # slightly broken 29 | # (feb2018) pascal@archbook:/home/.../cliche/examples$ cliche list_of_items.py f 1 2 30 | # [1, 2] None 31 | 32 | 33 | @cli 34 | def e(a: int = 1, z: List[int] = None): 35 | """ Test list typing with None as default """ 36 | print(a, z) 37 | 38 | 39 | @cli 40 | def f(a: int, z: List[int] = None): 41 | """ Test pos argument and list typing with None as default """ 42 | print(a, z) 43 | 44 | 45 | @cli 46 | def g(z: Set[str]): 47 | """ Test set default """ 48 | print(z) 49 | 50 | 51 | @cli 52 | def h(z: Set[str] = set(["a"])): 53 | """ Test set default """ 54 | print(z) 55 | -------------------------------------------------------------------------------- /examples/minimal.py: -------------------------------------------------------------------------------- 1 | from cliche import cli 2 | 3 | 4 | @cli 5 | def add(a: int, b: int): 6 | print(a + b) 7 | -------------------------------------------------------------------------------- /examples/optional.py: -------------------------------------------------------------------------------- 1 | from cliche import cli 2 | from typing import Optional, List 3 | 4 | 5 | @cli 6 | def optional(a: Optional[str] = None): 7 | print(a) 8 | 9 | 10 | @cli 11 | def optional_list(a: Optional[List[int]] = None): 12 | print(a) 13 | -------------------------------------------------------------------------------- /examples/py310.py: -------------------------------------------------------------------------------- 1 | from cliche import cli 2 | 3 | 4 | @cli 5 | def py310_optional(a: int | None = None): 6 | print(a) 7 | -------------------------------------------------------------------------------- /examples/pydantic_example.py: -------------------------------------------------------------------------------- 1 | from cliche import cli 2 | from pydantic import BaseModel 3 | 4 | 5 | class B(BaseModel): 6 | b: str 7 | 8 | 9 | class Item(BaseModel): 10 | a: str = "good_one" 11 | b: str 12 | 13 | 14 | @cli 15 | def print_item(item: Item, b: int = 2): 16 | print(repr(item), b) 17 | -------------------------------------------------------------------------------- /resources/cliche_rendered.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kootenpv/cliche/5ab8a2e4ea75725e56b16db17e1640da14d6dfaf/resources/cliche_rendered.png -------------------------------------------------------------------------------- /resources/logo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kootenpv/cliche/5ab8a2e4ea75725e56b16db17e1640da14d6dfaf/resources/logo.gif -------------------------------------------------------------------------------- /resources/logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kootenpv/cliche/5ab8a2e4ea75725e56b16db17e1640da14d6dfaf/resources/logo.jpg -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | 4 | [bdist_rpm] 5 | doc_files = README.md 6 | 7 | [wheel] 8 | universal = 1 -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages 2 | from setuptools import setup 3 | 4 | MAJOR_VERSION = "0" 5 | MINOR_VERSION = "10" 6 | MICRO_VERSION = "116" 7 | VERSION = "{}.{}.{}".format(MAJOR_VERSION, MINOR_VERSION, MICRO_VERSION) 8 | 9 | setup( 10 | name="cliche", 11 | version=VERSION, 12 | description="A minimalistic CLI wrapper out to be the best", 13 | url="https://github.com/kootenpv/cliche", 14 | author="Pascal van Kooten", 15 | author_email="kootenpv@gmail.com", 16 | entry_points={"console_scripts": ["cliche = cliche.__init__:main"]}, 17 | license="MIT", 18 | packages=find_packages(), 19 | include_package_data=True, 20 | install_requires=["ipdb == 0.13.9"], 21 | classifiers=[ 22 | "Environment :: Console", 23 | "Intended Audience :: Developers", 24 | "Intended Audience :: Customer Service", 25 | "Intended Audience :: System Administrators", 26 | "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", 27 | "Operating System :: Microsoft", 28 | "Operating System :: MacOS :: MacOS X", 29 | "Operating System :: Unix", 30 | "Operating System :: POSIX", 31 | "Programming Language :: Python", 32 | "Programming Language :: Python :: 3", 33 | "Programming Language :: Python :: 3.4", 34 | "Programming Language :: Python :: 3.5", 35 | "Programming Language :: Python :: 3.6", 36 | "Programming Language :: Python :: 3.7", 37 | "Programming Language :: Python :: 3.8", 38 | "Programming Language :: Python :: 3.9", 39 | "Programming Language :: Python :: 3.10", 40 | "Topic :: Software Development", 41 | "Topic :: Software Development :: Build Tools", 42 | "Topic :: Software Development :: Debuggers", 43 | "Topic :: Software Development :: Libraries", 44 | "Topic :: Software Development :: Libraries :: Python Modules", 45 | "Topic :: System :: Software Distribution", 46 | "Topic :: System :: Systems Administration", 47 | "Topic :: Utilities", 48 | ], 49 | zip_safe=False, 50 | platforms="any", 51 | ) 52 | -------------------------------------------------------------------------------- /tests/test_base.py: -------------------------------------------------------------------------------- 1 | """ Base tests""" 2 | import pytest 3 | import cliche 4 | from cliche import cli, main 5 | from typing import List 6 | 7 | 8 | def mainer(*args): 9 | cliche.main_called = [] 10 | main(None, None, "simple", *args) 11 | 12 | 13 | @cli 14 | def basic(): 15 | pass 16 | 17 | 18 | def test_basic_int_add(): 19 | expected = 3 20 | 21 | @cli 22 | def simple(first: int, second: float): 23 | assert first + second == expected 24 | 25 | mainer("1", "2") 26 | 27 | 28 | def test_basic_docs(): 29 | expected = 3 30 | 31 | @cli 32 | def simple(first: int, second: float): 33 | """Explanation 34 | 35 | :param first: First 36 | :param second: Second 37 | """ 38 | assert first + second == expected 39 | 40 | mainer("1", "2") 41 | 42 | 43 | def test_kw(): 44 | @cli 45 | def simple(first: int = 1): 46 | assert first == 3 47 | 48 | mainer("--first", "3") 49 | mainer("-f", "3") 50 | 51 | 52 | def test_basic_default(): 53 | expected = 3 54 | 55 | @cli 56 | def simple(first: int, second: float = 2): 57 | """Explanation 58 | 59 | :param first: First 60 | :param second: Second 61 | """ 62 | assert first + second == expected 63 | 64 | mainer("1") 65 | 66 | 67 | def test_empty_list(): 68 | @cli 69 | def simple(first: List[str]): 70 | """Explanation 71 | 72 | :param first: First 73 | """ 74 | assert first == [] 75 | 76 | mainer() 77 | 78 | 79 | def test_list_int(): 80 | @cli 81 | def simple(first: List[int]): 82 | """Explanation 83 | 84 | :param first: First 85 | """ 86 | assert first == [1] 87 | 88 | mainer("1") 89 | -------------------------------------------------------------------------------- /tests/test_doc_reading.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from cliche.docstring_to_help import parse_doc_params 3 | 4 | 5 | def test_google_docstr(): 6 | inp = """ 7 | Args: 8 | msg (str): Human readable string describing the exception. 9 | code (:obj:`int`, optional): Error code. 10 | """ 11 | assert parse_doc_params(inp) == { 12 | 'msg': 'Human readable string describing the exception.', 13 | 'code': 'Error code.', 14 | } 15 | 16 | 17 | def test_sphinx_docstr(): 18 | inp = """ Explanation 19 | 20 | :param first: First 21 | :param second: Second 22 | """ 23 | assert parse_doc_params(inp) == { 24 | 'first': 'First', 25 | 'second': 'Second', 26 | } 27 | --------------------------------------------------------------------------------