Metadata-Version: 2.4
Name: slick-cli
Version: 0.1.0
Summary: Slick, type-hint driven command-line apps built on the Python standard library.
Author: nehz
License-Expression: MIT
Keywords: cli,command-line,argparse,decorator,type-hints,terminal
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: Software Development :: User Interfaces
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Dynamic: license-file

# slick-cli

**Write a function, get a command-line app.** `slick-cli` turns ordinary, type-hinted
Python functions into polished CLIs: arguments, options, flags, choices, subcommands,
help text and colored output, with **zero dependencies** (it is a thin, friendly layer
over `argparse`).

```python
from slick_cli import App, echo

app = App("greeter", "Friendly greetings.", version="1.0")

@app.command
def hello(name: str, count: int = 1, shout: bool = False) -> None:
    """Greet someone.

    Args:
        name: Who to greet.
        count: How many times.
        shout: Use UPPERCASE.
    """
    for _ in range(count):
        echo(name.upper() if shout else f"Hello, {name}!")

if __name__ == "__main__":
    app()
```

```console
$ python greeter.py hello Ada --count 2
Hello, Ada!
Hello, Ada!
$ python greeter.py hello --help
usage: greeter hello [-h] [--count COUNT] [--shout | --no-shout] NAME
...
```

## Features

- **Decorator-based commands**: `@app.command` registers a function and returns it unchanged, so it stays directly callable and testable.
- **Type hints drive parsing**: `int`, `float`, `Path`, `bool` flags, `list[T]`, `*args`, `Literal[...]` and `Enum` choices, and `T | None`.
- **Help from docstrings**: the summary, description and Google-style `Args:` entries become `--help` output, with defaults and env vars listed.
- **Subcommand groups**: nest apps with `app.group(...)` or mount an existing one with `app.add_app(...)`; command aliases are supported.
- **Per-parameter tweaks** via `Annotated[T, Arg(...)]`: short flags, custom names, metavars and environment-variable fallbacks.
- **Colored output that behaves**: `style`/`secho` emit ANSI color only to TTYs, and respect `NO_COLOR` and `FORCE_COLOR`.
- **Clean errors and exit codes**: `abort("msg")` prints `Error: msg` and exits non-zero, with no traceback. Return values become exit codes.
- **In-process testing**: `slick_cli.testing.invoke` captures exit code, stdout and stderr.
- Standard library only, Python 3.10+, fully typed (`py.typed`).

## Install

```console
pip install slick-cli
```

Or from a checkout: `pip install .`

## Quickstart

```python
# todo.py
from pathlib import Path
from typing import Annotated, Literal

from slick_cli import App, Arg, abort, secho

app = App("todo", "A tiny todo manager.")
FILE = Path("todo.txt")

@app.command
def add(text: list[str], *, priority: Literal["low", "high"] = "low") -> None:
    """Add an item."""
    with FILE.open("a") as f:
        f.write(f"[{priority}] {' '.join(text)}\n")
    secho("added", fg="green")

@app.command(name="list", aliases=["ls"])
def list_items(*, high_only: Annotated[bool, Arg(short="-H")] = False) -> None:
    """Show items."""
    if not FILE.exists():
        abort("nothing to do yet")
    for line in FILE.read_text().splitlines():
        if not high_only or line.startswith("[high]"):
            print(line)

if __name__ == "__main__":
    app()
```

```console
$ python todo.py add buy milk --priority high
added
$ python todo.py ls -H
[high] buy milk
```

A runnable tour lives in [`examples/demo.py`](examples/demo.py):

```console
python3 examples/demo.py --help
python3 examples/demo.py hello Ada --count 2 --shout --color magenta
python3 examples/demo.py sum 1.5 2 3.25 -p 1
python3 examples/demo.py files ls --path . --ext .py
DEMO_TOKEN=secret python3 examples/demo.py deploy prod -y
```

## How parameters map to the command line

| Python parameter                       | Command line                                   |
|----------------------------------------|------------------------------------------------|
| `name: str` (no default)               | positional `NAME`                              |
| `count: int = 1` (has a default)       | option `--count COUNT`                         |
| `*, user: str` (keyword-only, no default) | required option `--user USER`               |
| `verbose: bool = False`                | flag `--verbose / --no-verbose`                |
| `files: list[Path]` (no default)       | positional taking one or more values           |
| `*, tag: list[str] \| None = None`     | repeatable option `--tag a --tag b`            |
| `*rest: int`                           | positional taking zero or more values          |
| `mode: Literal["fast", "slow"]`        | choices `{fast,slow}`                          |
| `color: Color` (an `Enum`)             | choices from member values (names also accepted, case-insensitive) |
| `out: Path \| None = None`             | same as `Path`; `None` when omitted            |
| `when: SomeType`                       | `SomeType(string)` is used as the converter    |

Underscores in names become dashes (`dry_run` becomes `--dry-run`). Unannotated
parameters are strings. A `bool` with no default is a flag defaulting to `False`.
`**kwargs` is rejected with `TypeError`.

## API overview

Everything below is importable from `slick_cli` unless noted.

### `App(name=None, help=None, *, version=None)`

A collection of commands and nested groups.

- `@app.command` / `@app.command(name=None, *, help=None, aliases=())`: register a function. The name defaults to the function name with `_` replaced by `-` (leading/trailing underscores stripped). `help` defaults to the docstring summary. Duplicate names or aliases raise `ValueError`.
- `app.group(name, help=None) -> App`: create and mount a nested app for subcommands.
- `app.add_app(other, name=None) -> App`: mount an existing app (uses `other.name` if `name` is omitted).
- `app.run(argv=None) -> int`: parse `argv` (default `sys.argv[1:]`), run the command, return the exit code. Never raises `SystemExit`.
- `app.main(argv=None)` and `app(argv=None)`: `run`, then `sys.exit` with the code.
- `app.build_parser(prog=None) -> argparse.ArgumentParser`: the underlying parser, if you need it.
- `app.commands`: dict of registered `Command` objects and nested `App`s, by name.

Running an app (or a group) with no command prints its help and exits 0.
`--version` is added when `version` is set.

### `run(func, argv=None, *, prog=None, version=None) -> int`

Run a single function as a whole CLI with no subcommands.

### Exit codes

| Outcome                                         | Exit code      |
|-------------------------------------------------|----------------|
| command returns `None`                          | `0`            |
| command returns an `int`                        | that int       |
| command returns `True` / `False`                | `0` / `1`      |
| command returns anything else                   | printed, `0`   |
| `CliError(msg, code)` / `abort(msg, code)`      | `code` (default `1`) |
| usage error (bad value, missing argument)       | `2`            |
| `Ctrl-C`                                        | `130`          |

### `Arg(help=None, short=None, name=None, metavar=None, env=None)`

Per-parameter metadata, attached with `typing.Annotated`:

```python
def login(*, user: Annotated[str, Arg(short="-u", env="APP_USER", help="Account name")]): ...
```

- `help` overrides the docstring entry.
- `short` adds a short flag (options and flags only).
- `name` replaces the long option name.
- `metavar` sets the usage placeholder.
- `env` names an environment variable used when the option is not given. Booleans accept `1/true/yes/on` and `0/false/no/off`. List options split the variable on commas. A bad value is a usage error (exit 2).

### Errors

- `CliError(message, exit_code=1)`: raise it for expected failures. It prints `Error: <message>` to stderr.
- `abort(message, exit_code=1)`: shorthand that raises `CliError`.

### Output

- `style(text, fg=None, bg=None, *, bold=False, dim=False, italic=False, underline=False) -> str`: wrap text in ANSI codes. The colors are `black red green yellow blue magenta cyan white`, each also with a `bright_` prefix. An unknown color raises `ValueError`.
- `unstyle(text) -> str`: strip ANSI codes.
- `echo(message="", *, err=False, nl=True, color=None)`: print to stdout or stderr. Styling is stripped unless `should_color(stream)` is true or `color=True` is passed.
- `secho(message="", *, err=False, nl=True, color=None, **styles)`: `style` and `echo` in one call.
- `should_color(stream) -> bool`: returns `False` if `NO_COLOR` is set, otherwise `True` if `FORCE_COLOR` is set, otherwise whether `stream` is a TTY.
- `confirm(prompt, default=False) -> bool`: a yes/no prompt on stdin. An empty answer or end of input returns `default`.

### Lower-level pieces

- `Command(func, name=None, *, help=None, aliases=())`: the object behind each registered command. It has `.name`, `.help`, `.description`, `.aliases` and `.params`.
- `slick_cli.docstrings.parse_docstring(doc) -> DocInfo` returns `summary`, `description` and `params`.

### Testing: `slick_cli.testing`

```python
from slick_cli.testing import invoke

result = invoke(app, ["hello", "Ada"], env={"APP_USER": "me"}, input="y\n")
assert result.exit_code == 0
assert result.stdout == "Hello, Ada!\n"
```

`invoke(target, args=(), *, env=None, input=None) -> Result` accepts an `App` or a plain
function. `Result` has `exit_code`, `stdout` and `stderr`.

## Development

```console
PYTHONPATH=src python3 -m unittest discover -s tests -v
# or, if pytest is installed:
python3 -m pytest
```

## License

MIT
