From f62ea7b420479cda12484c17a16d359c69982fce Mon Sep 17 00:00:00 2001 From: Clif Bratcher Date: Mon, 13 May 2024 13:14:39 -0400 Subject: [PATCH] Readme cleanup * Various output tweaks to align with readme cleanup --- README.md | 146 ++++++++++++++++++++++++++------- simplecli/simplecli.py | 25 ++++-- tests/test_format_docstring.py | 7 +- tests/test_wrap.py | 6 +- 4 files changed, 141 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index dc6c623..4abfa5e 100644 --- a/README.md +++ b/README.md @@ -3,41 +3,23 @@ [![codecov](https://codecov.io/gh/inno/pysimplecli/branch/main/graph/badge.svg?token=T6NP6XSKJG)](https://codecov.io/gh/inno/pysimplecli) [![CI](https://github.com/inno/pysimplecli/actions/workflows/main.yml/badge.svg)](https://github.com/inno/pysimplecli/actions/workflows/main.yml) -Want to turn your script into a command line utility, but don't want the -overhead of [argparse](https://docs.python.org/3/library/argparse.html)? - -You've come to the right place! +Want to turn your script into a command line utility, but don't want the overhead of [argparse](https://docs.python.org/3/library/argparse.html)? *You've come to the right place!* Simply import and wrap whatever function you'd like to expose. That's it! -Function variables are turned into CLI parameters, complete with input -validation and help text generated from inline comments. - -Note that only simple variable types (`bool`, `float`, `int`, `str`) are -currently supported. Mapping and sequence types might be supported in the -future. - -## Features - -- **Function-to-CLI Conversion**: Easily turn Python functions into command-line tools using the `wrap` decorator. -- **Intelligent Argument Parsing**: Automatically parse command-line arguments to match the function parameters, including type handling. -- **Enhanced Decorator**: Includes parameter details such as descriptions from inline comments next to parameter definitions, providing richer help text and usage messages. -- **Automatic Argument Parsing**: Parameters of the function are automatically mapped to command-line arguments, including type conversion. -- **Comments as Help Text**: Inline comments are used as help text in the generated interface, providing context and guidance directly from code. +Function variables are turned into CLI parameters, complete with input validation and help text generated from inline comments. ## Install from PyPI -You can install pysimplecli directly from source as follows: - ```bash pip install pysimplecli -pip install git+https://github.com/inno/pysimplecli.git ``` ## Quick Start -To convert a Python function into a CLI command, simply use the `wrap` decorator from the `simplecli` module. Here is how you can get started: +To convert a Python function into a CLI command, simply use the `wrap` decorator from the `simplecli` module. Here is a simple example: + ```python import simplecli @@ -46,7 +28,7 @@ import simplecli @simplecli.wrap def main( name: str, # Person to greet -): +) -> None: print(f"Hello, {name}!") ``` @@ -55,16 +37,117 @@ $ python3 myprogram.py --name="Dade Murphy" Hello, Dade Murphy! ``` +That's it! + +## Features + +### No options to worry about + +Everything Just Works™ out of the box. + +### Autogenerated help + +Parameters show up automatically and comments make them more friendly. + ```bash -$ python3 myprogram.py --help +$ python3 hello.py --help Usage: - myprogram.py [name] + hello.py [name] Options: --name Person to greet - --help This message + --help Display hello.py version ``` +### Unicode support + +```bash +$ python3 hello.py --name="Donatello 🐢" +Hello, Donatello 🐢! +``` + +### Cross-platform + +Works on Windows/Linux/Mac with versions of Python >= 3.9 + + +### Required parameters are also positional + +Want to pass positional instead of named parameters? All required parameters are available in top-down order. + + +```bash +$ python3 hello.py "El Duderino" +Hello, El Duderino! +``` +### Autogenerated version parameter + +Note that this is only available if the dunder variable `__version__` exists like below. +```python +import simplecli +___version___ = "1.2.3" + + +@simplecli.wrap +def main( + name: str, # Person to greet +) -> None: + print(f"Hello, {name}!") +``` + +```bash +$ python3 hello.py --version +hello.py version 1.2.3 +``` + +### Automatic docstring to help description +```python +import simplecli +__version__ = "1.2.3" + + +@simplecli.wrap +def main( + name: str, # Person to greet +) -> None: + """ + This is a utility that greets whoever engages it. It's a simple example + of how to use the `simplecli` utility module. Give it a try! + """ + print(f"Hello, {name}!") +``` + +```bash +$ python3 hello.py --help +Usage: + hello.py [name] + +Description: + This is a utility that greets whoever engages it. It's a simple example + of how to use the `simplecli` utility module. Give it a try! + +Options: + --name Person to greet + --help Show this message + --version Display hello.py version +``` + +## Gotchas + +### "Required" may be a bit confusing + +A parameter becomes "required" if it is an `int`, `float` or `str` and does not have a default. + +`bool` arguments cannot be required as it would effectively be contradictory. Instead, they are `False` by default and become `True` when passed as a flag. + +### Complex variables are not supported + +*Only* simple variable types (`bool`, `float`, `int`, `str`) are currently supported. Mapping and sequence types might be supported in the future but anything else is beyond the scope of this utility. + +### Only one `@wrap` allowed per file + +It's impossible to tell which function you'd like to wrap, so we enforce a single `@wrap` per file. Imported modules also using `@wrap` are supported, however calling a decorated function is currently unsupported. + ## How It Works The `wrap` decorator takes the annotated parameters of a given function and maps them to corresponding command-line arguments. It uses Python's introspection features and the `tokenize` module to parse inline comments for parameter descriptions, enriching the auto-generated help output. @@ -79,9 +162,11 @@ The `wrap` decorator takes the annotated parameters of a given function and maps Here's an example of an `argparse` solution ```python import argparse +import sys +__version__ = "1.2.3" -def add(a: int = 5, b: int = 10): +def add(a: int = 5, b: int = 10) -> None: print(a + b) @@ -91,6 +176,8 @@ if __name__ == "__main__": help="First integer to add") parser.add_argument("--b", default=10, required=True, type=int, help="Second integer to add") + parser.add_argument("--version", action="version", + version=f"{sys.argv[0]} version {__version__}") args = parser.parse_args() add(args.a, args.b) ``` @@ -99,13 +186,14 @@ Here's the same example, but with `simplecli` ```python from simplecli import wrap +__version__ = "1.2.3" @wrap def add( a: int = 5, # First integer to add b: int = 10, # Second integer to add -): +) -> None: print(a + b) ``` @@ -116,4 +204,4 @@ Feel free to [open an issue](./issues/new) and [create a pull request](./pulls)! ## License -pysimplecli © 2024 by Clif Bratcher is licensed under [CC BY 4.0][https://creativecommons.org/licenses/by/4.0/] +pysimplecli © 2024 by Clif Bratcher is licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) diff --git a/simplecli/simplecli.py b/simplecli/simplecli.py index 7d55c58..4f00189 100644 --- a/simplecli/simplecli.py +++ b/simplecli/simplecli.py @@ -245,7 +245,7 @@ def help_text( ) -> str: help_msg = [] if docstring: - help_msg += ["Description:", " " + docstring] + help_msg += ["Description:", docstring, ""] help_msg.append("Options:") positional = [] max_attr_len = len(max(params, key=lambda x: len(x.help_name)).help_name) @@ -297,9 +297,9 @@ def missing_params_msg(missing_params: list[Param]) -> list[str]: ) ] for param in missing_params: - mp_text.append(f"\t--{param.help_name}") + mp_text.append(f" --{param.help_name}") if param.description: - mp_text[-1] += f" - {param.description}" + mp_text[-1] += f" {param.description}" return mp_text @@ -344,7 +344,7 @@ def format_docstring(docstring: str) -> str: start = end_offset = 0 searching_for_start = True - minimum_indent = 99 # Arbitrarily large minimum indent as a placeholder + minimum_indent = 2 lines = docstring.splitlines() for offset, line in enumerate(lines): indent = re.match(r"\s*", line).span()[1] # type: ignore[union-attr] @@ -372,18 +372,25 @@ def wrap(func: Callable[..., Any]) -> None: argv = sys.argv[1:] params = extract_code_params(code=func) pos_args, kw_args = clean_args(argv) - version = func.__globals__.get("__version__", "") - if version: - params.append(Param("version", internal_only=True)) params.append( - Param("help", description="This message", internal_only=True) + Param("help", description="Show this message", internal_only=True) ) + version = func.__globals__.get("__version__", "") + if version: + params.append( + Param( + "version", + description=f"Display {filename} version", + internal_only=True, + ) + ) + if "help" in kw_args: exit(help_text(filename, params, format_docstring(func.__doc__ or ""))) if "version" in kw_args: if version != "": - exit(f"Version: {version}") + exit(f"{filename} version {version}") # Strip internal-only params = [param for param in params if not param.internal_only] diff --git a/tests/test_format_docstring.py b/tests/test_format_docstring.py index 99a15bf..6660f89 100644 --- a/tests/test_format_docstring.py +++ b/tests/test_format_docstring.py @@ -10,7 +10,10 @@ def test_help_text_docstring_indent_simple(): more things """ text = format_docstring(docstring) - assert text == "stuff\n things\n more things".replace("\n", os.linesep) + expected = " stuff\n things\n more things".replace( + "\n", os.linesep + ) + assert text == expected def test_help_text_docstring_indent_hanging_whitespace(): @@ -19,7 +22,7 @@ def test_help_text_docstring_indent_hanging_whitespace(): things """ text = format_docstring(docstring) - assert text == "stuff\n things".replace("\n", os.linesep) + assert text == " stuff\n things".replace("\n", os.linesep) def test_help_text_docstring_empty(): diff --git a/tests/test_wrap.py b/tests/test_wrap.py index cd41c3b..df69dee 100644 --- a/tests/test_wrap.py +++ b/tests/test_wrap.py @@ -104,13 +104,13 @@ def code2(this_var: int): # stuff and things help_msg = e.value.args[0] assert "Description:" not in help_msg - assert "Version: 1.2.3" not in help_msg + assert "version 1.2.3" not in help_msg assert "--this-var" in help_msg assert "stuff and things" in help_msg def test_wrap_version_exists(monkeypatch): - monkeypatch.setattr(sys, "argv", ["filename", "--version"]) + monkeypatch.setattr(sys, "argv", ["super_script", "--version"]) def code1(this_var: int): # stuff and things pass @@ -121,7 +121,7 @@ def code1(this_var: int): # stuff and things simplecli.wrap(code1) help_msg = e.value.args[0] - assert "Version: 1.2.3" in help_msg + assert "super_script version 1.2.3" in help_msg assert "Description:" not in help_msg assert "--this-var" not in help_msg assert "stuff and things" not in help_msg