Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 63 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,42 @@ a default, in which case zero values are allowed.

---

### Pydantic models

Install the optional Pydantic integration:

```bash
uv add "yeetr[pydantic]"
```

A function with one Pydantic model parameter automatically exposes the model
fields as CLI options and receives a validated model instance:

```python
from pydantic import BaseModel, Field


class Request(BaseModel):
name: str = Field(description="Name to greet", default="world", alias="n")
tol: float = Field(description="Tolerance", default=0.5, alias="t")


def main(request: Request) -> None:
...
```

```bash
yeet app.py -n person --tol 0.25
```

Field descriptions become help text. Field aliases become additional CLI
flags (`alias="n"` becomes `-n`; longer aliases become `--alias`). Pydantic
aliases may also include their CLI prefix explicitly (`alias="-n"`). Pydantic
validation runs after CLI parsing. The model must be the function's only
parameter.

---

## Parameter Metadata

### `Arg` and `Opt`
Expand All @@ -263,7 +299,7 @@ from yeetr import Arg, Opt
def main(
path: Annotated[Path, Arg(help="Input file")],
*,
workers: Annotated[int, Opt(alias="-w", help="Worker count")] = 4,
workers: Annotated[int, Opt(alias="w", help="Worker count")] = 4,
) -> None:
...
```
Expand All @@ -277,6 +313,27 @@ accepts `alias`, `aliases`, `help`, `metavar`, `envvar`, `hidden`, and the
path validators below. Mixing them (e.g. `Opt` on a positional or `Arg` on a
keyword-only parameter) raises a clear `YeetrError`.

Prefer aliases without a `-` prefix. A single-letter alias automatically
becomes a short option (`alias="w"` becomes `-w`). An alias with two or more
letters automatically becomes a long option (`alias="workers"` becomes
`--workers`). Explicit CLI spelling such as `alias="-w"` or `alias="--workers"`
is accepted when needed.

For one alias, prefer `alias=`:

```python
Opt(alias="w")
```

For multiple aliases, omit `alias=` and use only `aliases=`:

```python
Opt(aliases=("w", "workers", "worker-count"))
```

This exposes `-w`, `--workers`, and `--worker-count`, in addition to the
generated option based on the parameter name.

You can also define aliases once and reuse them:

```python
Expand All @@ -286,7 +343,7 @@ from yeetr import Arg, Opt


type InputPath = Annotated[Path, Arg(help="Input file")]
type WorkerCount = Annotated[int, Opt(alias="-w", help="Worker count")]
type WorkerCount = Annotated[int, Opt(alias="w", help="Worker count")]


def main(path: InputPath, *, workers: WorkerCount = 4) -> None:
Expand Down Expand Up @@ -424,7 +481,9 @@ the only way to attach per-parameter metadata that fully type-checks.

`str`, `int`, `float`, `bool`, `pathlib.Path`, `typing.Literal[...]`,
`enum.Enum` subclasses, `T | None`, `list[T]`, `tuple[T, U]`, and
`tuple[T, ...]`. Anything else raises a clear `YeetrError`.
`tuple[T, ...]`. With the `pydantic` extra installed, a lone
`pydantic.BaseModel` parameter is also supported. Anything else raises a
clear `YeetrError`.

---

Expand Down Expand Up @@ -525,6 +584,7 @@ problem. Quick honest comparison so you can pick the right tool:
| Executable shebang | `#!yeet` or `#!uv run yeet` can make the script itself executable without extra wrapper code | No equivalent single-line signature-driven runner; still need a `typer.run(...)` or app entry point |
| Arg vs. option mapping | Uses Python's `*` separator: before `*` = positional args, after `*` = `--options` (no per-param annotation needed) | Decide per parameter via `typer.Argument(...)` / `typer.Option(...)` |
| Per-param metadata | `Annotated[T, Arg(...)]` / `Annotated[T, Opt(...)]` | `Annotated[T, typer.Argument(...)]` / `typer.Option(...)` |
| Pydantic models | Optional integration: a lone `BaseModel` parameter expands into validated CLI options automatically | No built-in model expansion; expose options and construct the model manually |
| Variadic positional args | Native `*args: T` maps to a trailing variadic positional arg | Use `list[T]` with `typer.Argument(...)` |
| Boolean flags | Default drives the flag: `= False` -> `--flag`, `= True` -> `--no-flag` | Pair of flags declared explicitly: `--flag / --no-flag` |
| Subcommands | Not supported (single command per script) | First-class subcommands, command groups, nested apps |
Expand Down
9 changes: 9 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ classifiers = [
dependencies = ["rich>=15.0.0", "rich-argparse>=1.5.2"]

[project.optional-dependencies]
pydantic = ["pydantic>=2.13.4"]
uvloop = ["uvloop>=0.21.0"]

[project.scripts]
Expand All @@ -35,13 +36,18 @@ dev = [
"deptry>=0.25.1",
"pyright>=1.1.380",
"pytest-cov>=7.0.0",
"pydantic>=2.13.4",
"ruff>=0.15.7",
"mkdocs>=1.6.1",
"mkdocs-material>=9.7.6",
"mkdocstrings[python]>=1.0.3",
"prek>=0.4.1",
"pylint>=3.3.8",
]
local-dev = [
"pydantic>=2.13.4",
"typer>=0.26.4",
]

[build-system]
requires = ["uv_build>=0.11.0,<0.12.0"]
Expand Down Expand Up @@ -135,3 +141,6 @@ max-args = 10
max-positional-arguments = 10
max-locals = 20
max-attributes = 20

[tool.deptry.per_rule_ignores]
DEP002 = ["pydantic"]
136 changes: 135 additions & 1 deletion tests/test_yeetr.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Tests for yeetr's signature-driven CLI runner."""

# pylint: disable=import-outside-toplevel,missing-function-docstring,redefined-builtin
# pylint: disable=import-outside-toplevel,missing-class-docstring,missing-function-docstring,redefined-builtin,too-few-public-methods,too-many-lines

import asyncio
import enum
Expand Down Expand Up @@ -254,6 +254,117 @@ def main(*, point: Annotated[tuple[int, float], Opt(envvar="POINT")] = (0, 0.0))
assert captured == {"point": (1, 2.5)}


def test_pydantic_model_expands_to_options() -> None:
from pydantic import BaseModel, Field

captured: dict[str, object] = {}

class Request(BaseModel):
name: str = Field(description="Name to greet", default="world", alias="n")
tol: float = Field(description="Tolerance", default=0.5, alias="t")

def main(request: Request) -> None:
captured["request"] = request

yeetr.run(main, argv=["-n", "name", "--tol", "0.25"])
assert captured == {"request": Request(n="name", t=0.25)}


def test_pydantic_model_accepts_prefixed_field_alias() -> None:
from pydantic import BaseModel, Field

captured: dict[str, object] = {}

class Request(BaseModel):
name: str = Field(default="world", alias="-n")

def main(request: Request) -> None:
captured["request"] = request

yeetr.run(main, argv=["-n", "name"])
assert captured == {"request": Request(**{"-n": "name"})}


def test_pydantic_model_accepts_prefixed_long_field_alias() -> None:
from pydantic import BaseModel, Field

captured: dict[str, object] = {}

class Request(BaseModel):
name: str = Field(default="world", alias="--who")

def main(request: Request) -> None:
captured["request"] = request

yeetr.run(main, argv=["--who", "name"])
assert captured == {"request": Request(**{"--who": "name"})}


def test_pydantic_model_uses_field_defaults() -> None:
from pydantic import BaseModel

captured: dict[str, object] = {}

class Request(BaseModel):
name: str = "world"

def main(request: Request) -> None:
captured["request"] = request

yeetr.run(main, argv=[])
assert captured == {"request": Request(name="world")}


def test_pydantic_model_validates_fields() -> None:
from pydantic import BaseModel, Field

class Request(BaseModel):
count: int = Field(gt=0)

def main(request: Request) -> None:
del request

with pytest.raises(SystemExit):
yeetr.run(main, argv=["--count", "0"])


def test_pydantic_model_help_uses_field_metadata() -> None:
from io import StringIO

from pydantic import BaseModel, Field
from rich.console import Console

from yeetr._runner import _build_parser # pyright: ignore[reportPrivateUsage]

class Request(BaseModel):
name: str = Field(description="Name to greet", default="world", alias="n")

def main(request: Request) -> None:
del request

parser, _, _ = _build_parser(main, prog="app")
buf = StringIO()
console = Console(file=buf, force_terminal=False, width=200)
parser.print_help(file=console.file)
output = buf.getvalue()
assert "--name" in output
assert "-n" in output
assert "Name to greet" in output


def test_pydantic_model_must_be_only_parameter() -> None:
from pydantic import BaseModel

class Request(BaseModel):
name: str

def main(request: Request, other: str) -> None:
del request, other

with pytest.raises(YeetrError, match="only parameter"):
yeetr.run(main, argv=[])


def test_async_main() -> None:
captured: dict[str, object] = {}

Expand Down Expand Up @@ -305,6 +416,29 @@ def main(*, workers: Annotated[int, Opt(alias="-w", help="Worker count")] = 4) -
assert captured == {"workers": 3}


def test_opt_alias_accepts_shorthand() -> None:
captured: dict[str, object] = {}

def main(*, workers: Annotated[int, Opt(alias="w")] = 4) -> None:
captured["workers"] = workers

yeetr.run(main, argv=["-w", "8"])
assert captured == {"workers": 8}


def test_opt_aliases_accept_long_shorthand_and_explicit_spelling() -> None:
captured: dict[str, object] = {}

def main(*, name: Annotated[str, Opt(aliases=("who", "--person"))] = "world") -> None:
captured["name"] = name

yeetr.run(main, argv=["--who", "name"])
assert captured == {"name": "name"}

yeetr.run(main, argv=["--person", "person"])
assert captured == {"name": "person"}


def test_opt_no_default_required() -> None:
captured: dict[str, object] = {}

Expand Down
Loading
Loading