Skip to content

Commit

Permalink
Readme cleanup
Browse files Browse the repository at this point in the history
    * Various output tweaks to align with readme cleanup
  • Loading branch information
inno committed May 13, 2024
1 parent c76903d commit f62ea7b
Show file tree
Hide file tree
Showing 4 changed files with 141 additions and 43 deletions.
146 changes: 117 additions & 29 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -46,7 +28,7 @@ import simplecli
@simplecli.wrap
def main(
name: str, # Person to greet
):
) -> None:
print(f"Hello, {name}!")
```

Expand All @@ -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.
Expand All @@ -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)
Expand All @@ -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)
```
Expand All @@ -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)
```

Expand All @@ -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/)
25 changes: 16 additions & 9 deletions simplecli/simplecli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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]
Expand Down
7 changes: 5 additions & 2 deletions tests/test_format_docstring.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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():
Expand Down
6 changes: 3 additions & 3 deletions tests/test_wrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down

0 comments on commit f62ea7b

Please sign in to comment.