From dc9cdf625b252362a8a8a5aea6f591d2d37ff975 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:09:44 +0100 Subject: [PATCH 1/4] Add custom parser support --- docs/guide/cli-rules.md | 3 + docs/guide/parameter-metadata.md | 46 +++++++- tests/test_yeetr.py | 187 +++++++++++++++++++++++++++++++ yeetr/_metadata.py | 13 +++ yeetr/_runner.py | 82 ++++++++++++-- 5 files changed, 322 insertions(+), 9 deletions(-) diff --git a/docs/guide/cli-rules.md b/docs/guide/cli-rules.md index c4b9d3b..1cdf019 100644 --- a/docs/guide/cli-rules.md +++ b/docs/guide/cli-rules.md @@ -24,3 +24,6 @@ - `Opt(hidden=True)` still parses but is hidden from `--help`. - `exists`/`file_okay`/`dir_okay`/`readable`/`writable` validate `Path` parameters (and `Path`-typed lists, tuples, and `*args`) at parse time. +- `Arg(parser=...)`/`Opt(parser=...)` run a custom converter: the CLI string + is coerced to the parser's annotated input type, then the parser produces + the final value. Not supported on `list`/`tuple` parameters or `*args`. diff --git a/docs/guide/parameter-metadata.md b/docs/guide/parameter-metadata.md index 31bb435..91509a6 100644 --- a/docs/guide/parameter-metadata.md +++ b/docs/guide/parameter-metadata.md @@ -24,8 +24,8 @@ yeet app.py input.pdf -w 8 ``` `Arg` accepts `help`, `metavar`, `min` (only meaningful on [variadic `*args`](parameter-types.md#variadic-positional-args-args)), -and the [path validators](path-validators.md). `Opt` accepts `alias`, -`aliases`, `help`, `metavar`, `envvar`, `hidden`, and the +`parser`, and the [path validators](path-validators.md). `Opt` accepts +`alias`, `aliases`, `help`, `metavar`, `envvar`, `hidden`, `parser`, and the [path validators](path-validators.md) too. Mixing them (e.g. `Opt` on a positional or `Arg` on a keyword-only parameter) raises a clear `YeetrError`. @@ -75,6 +75,48 @@ Env-var values are type-coerced just like CLI values. `bool` accepts (`:` on POSIX, `;` on Windows). `tuple[...]` also splits on `os.pathsep`. `Literal` and enum choices are validated. +## Custom parsers (`parser=`) + +`parser=` supplies your own converter for a parameter, so yeetr can handle +domain types it doesn't build in. yeetr inspects the parser's single +(annotated) parameter, coerces the raw CLI string to *that* type with its +normal machinery, then calls the parser to produce the value passed to the +function: + +```python +from pathlib import Path +from typing import Annotated +from yeetr import Opt + + +def read_bytes(path: Path) -> bytes: + return path.read_bytes() + + +def main(*, pdf_bytes: Annotated[bytes, Opt(parser=read_bytes)]) -> None: + ... +``` + +```bash +yeet app.py --pdf-bytes input.pdf +``` + +Here yeetr sees `read_bytes` takes a `Path`, coerces `"input.pdf"` to a +`Path`, and calls `read_bytes` with it. The outer annotation (`bytes`) is +only the function-facing type — yeetr never coerces to it; the parser is +trusted for the output. + +The parser's input type drives everything user-facing: `--help` shows the +input type, and the [path validators](path-validators.md) compose — +`Opt(parser=read_bytes, exists=True)` validates the intermediate `Path` +before the parser runs. If the parser raises `ValueError` or `TypeError`, +the failure is reported as a normal argument error. + +The parser must be a callable taking exactly one type-annotated parameter; +anything else raises `YeetrError` at parser-build time. `parser=` is not +supported on `list`/`tuple` parameters or variadic `*args`, and `bool`, +`list`, and `tuple` parser input types are rejected. + ## Hidden options (`Opt(hidden=True)`) Hidden options still parse from the CLI but are absent from `--help` (both diff --git a/tests/test_yeetr.py b/tests/test_yeetr.py index 3a12dc6..b94b0df 100644 --- a/tests/test_yeetr.py +++ b/tests/test_yeetr.py @@ -1329,6 +1329,193 @@ def main(*paths: Annotated[Path, Arg(exists=True)]) -> None: yeetr.run(main, argv=[str(missing)]) +def _read_bytes(path: Path) -> bytes: + return path.read_bytes() + + +def _double(value: int) -> int: + return value * 2 + + +def _reject(value: str) -> str: + raise ValueError(value) + + +def _untyped( + value, # pyright: ignore[reportMissingParameterType, reportUnknownParameterType] +) -> str: + del value + return "" + + +def _no_params() -> str: + return "" + + +def _two_params(first: str, second: str) -> str: + return first + second + + +def _split_words(value: str) -> list[str]: + return value.split() + + +def test_parser_opt_coerces_input_then_calls_parser(tmp_path: Path) -> None: + captured: dict[str, object] = {} + + def main(*, data: Annotated[bytes, Opt(parser=_read_bytes)]) -> None: + captured["data"] = data + + file = tmp_path / "file" + file.write_bytes(b"content") + yeetr.run(main, argv=["--data", str(file)]) + assert captured == {"data": b"content"} + + +def test_parser_arg_coerces_input_then_calls_parser() -> None: + captured: dict[str, object] = {} + + def main(value: Annotated[int, Arg(parser=_double)]) -> None: + captured["value"] = value + + yeetr.run(main, argv=["5"]) + assert captured == {"value": 10} + + +def test_parser_with_exists_rejects_missing_before_parser(tmp_path: Path) -> None: + def main(*, data: Annotated[bytes, Opt(parser=_read_bytes, exists=True)]) -> None: + del data + + missing = tmp_path / "missing" + with pytest.raises(SystemExit): + yeetr.run(main, argv=["--data", str(missing)]) + + +def test_parser_with_exists_accepts_existing(tmp_path: Path) -> None: + captured: dict[str, object] = {} + + def main(*, data: Annotated[bytes, Opt(parser=_read_bytes, exists=True)]) -> None: + captured["data"] = data + + file = tmp_path / "file" + file.write_bytes(b"content") + yeetr.run(main, argv=["--data", str(file)]) + assert captured == {"data": b"content"} + + +def test_parser_with_path_checks_on_non_path_input_raises() -> None: + def main(*, value: Annotated[int, Opt(parser=_double, exists=True)]) -> None: + del value + + with pytest.raises(YeetrError, match="Path"): + yeetr.run(main, argv=["--value", "5"]) + + +def test_parser_error_is_wrapped(capsys: pytest.CaptureFixture[str]) -> None: + def main(*, value: Annotated[str, Opt(parser=_reject)]) -> None: + del value + + with pytest.raises(SystemExit): + yeetr.run(main, argv=["--value", "x"]) + err = capsys.readouterr().err + assert "invalid value" in err + + +def test_parser_arg_error_is_wrapped() -> None: + def main(value: Annotated[str, Arg(parser=_reject)]) -> None: + del value + + with pytest.raises(SystemExit): + yeetr.run(main, argv=["x"]) + + +def test_parser_zero_params_rejected() -> None: + def main(*, value: Annotated[str, Opt(parser=_no_params)]) -> None: + del value + + with pytest.raises(YeetrError, match="must take exactly one argument"): + yeetr.run(main, argv=["--value", "x"]) + + +def test_parser_two_params_rejected() -> None: + def main(value: Annotated[str, Arg(parser=_two_params)]) -> None: + del value + + with pytest.raises(YeetrError, match="must take exactly one argument"): + yeetr.run(main, argv=["x"]) + + +def test_parser_untyped_param_rejected() -> None: + opt = Opt(parser=_untyped) # pyright: ignore[reportUnknownArgumentType] + + def main(*, value: Annotated[str, opt]) -> None: + del value + + with pytest.raises(YeetrError, match="untyped parameter"): + yeetr.run(main, argv=["--value", "x"]) + + +def test_parser_help_shows_input_type(capsys: pytest.CaptureFixture[str]) -> None: + def main(*, data: Annotated[bytes, Opt(parser=_read_bytes)]) -> None: + del data + + with pytest.raises(SystemExit): + yeetr.run(main, argv=["--help"]) + output = capsys.readouterr().out + assert "Path" in output + assert "bytes" not in output + + +def test_parser_on_list_rejected() -> None: + def main(*, words: Annotated[list[str], Opt(parser=_split_words)]) -> None: + del words + + with pytest.raises(YeetrError, match="list/tuple"): + yeetr.run(main, argv=["--words", "a b"]) + + +def test_parser_on_tuple_rejected() -> None: + def main(pair: Annotated[tuple[int, int], Arg(parser=_double)]) -> None: + del pair + + with pytest.raises(YeetrError, match="list/tuple"): + yeetr.run(main, argv=["1", "2"]) + + +def test_parser_on_var_positional_rejected() -> None: + def main(*values: Annotated[int, Arg(parser=_double)]) -> None: + del values + + with pytest.raises(YeetrError, match="variadic"): + yeetr.run(main, argv=["5"]) + + +def test_parser_optional_annotation(tmp_path: Path) -> None: + captured: dict[str, object] = {} + + def main(*, data: Annotated[bytes | None, Opt(parser=_read_bytes)] = None) -> None: + captured["data"] = data + + yeetr.run(main, argv=[]) + assert captured == {"data": None} + + file = tmp_path / "file" + file.write_bytes(b"content") + yeetr.run(main, argv=["--data", str(file)]) + assert captured == {"data": b"content"} + + +def test_parser_envvar(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, object] = {} + + def main(*, value: Annotated[int, Opt(parser=_double, envvar="VALUE")] = 0) -> None: + captured["value"] = value + + monkeypatch.setenv("VALUE", "3") + yeetr.run(main, argv=[]) + assert captured == {"value": 6} + + def _write_demo(tmp_path: Path) -> Path: file = tmp_path / "demo.py" file.write_text( diff --git a/yeetr/_metadata.py b/yeetr/_metadata.py index 2f7ad31..1443323 100644 --- a/yeetr/_metadata.py +++ b/yeetr/_metadata.py @@ -1,5 +1,6 @@ """Public metadata objects used to describe CLI parameters.""" +from collections.abc import Callable from dataclasses import dataclass @@ -22,6 +23,11 @@ def main(path: Annotated[Path, Arg(help="Input file")]) -> None: ... ``writable``) apply only when the parameter's effective type is ``Path`` (or ``list[Path]`` / variadic ``*paths: Path``). Setting any of these on a non-``Path`` parameter raises ``YeetrError`` at parser-build time. + + ``parser`` supplies a custom converter: yeetr coerces the raw CLI string + to the parser's single (annotated) parameter type, then calls the parser + to produce the value passed to the function. Not supported on ``list``, + ``tuple``, or variadic ``*args`` parameters. """ help: str | None = None @@ -32,6 +38,7 @@ def main(path: Annotated[Path, Arg(help="Input file")]) -> None: ... dir_okay: bool = True readable: bool = False writable: bool = False + parser: Callable[..., object] | None = None @dataclass(frozen=True, slots=True) @@ -61,6 +68,11 @@ def main(*, workers: Annotated[int, Opt(alias="w", help="Workers")] = 4) -> None Path validators (``exists``, ``file_okay``, ``dir_okay``, ``readable``, ``writable``) apply only when the parameter's effective type is ``Path`` (or ``list[Path]``). + + ``parser`` supplies a custom converter: yeetr coerces the raw CLI string + to the parser's single (annotated) parameter type, then calls the parser + to produce the value passed to the function. Not supported on ``list`` + or ``tuple`` parameters. """ alias: str | None = None @@ -74,3 +86,4 @@ def main(*, workers: Annotated[int, Opt(alias="w", help="Workers")] = 4) -> None dir_okay: bool = True readable: bool = False writable: bool = False + parser: Callable[..., object] | None = None diff --git a/yeetr/_runner.py b/yeetr/_runner.py index 1730a1f..038125a 100644 --- a/yeetr/_runner.py +++ b/yeetr/_runner.py @@ -93,6 +93,7 @@ class _ParamInfo: # pylint: disable=too-many-instance-attributes default: Any = None parent_name: str | None = None parent_type: Any = None + custom_parser: Callable[..., object] | None = None def _is_enum_type(target: Any) -> bool: @@ -209,6 +210,7 @@ def _merge_arg(outer: Arg, inner: Arg) -> Arg: dir_okay=outer.dir_okay and inner.dir_okay, readable=outer.readable or inner.readable, writable=outer.writable or inner.writable, + parser=outer.parser if outer.parser is not None else inner.parser, ) @@ -225,6 +227,7 @@ def _merge_opt(outer: Opt, inner: Opt) -> Opt: dir_okay=outer.dir_okay and inner.dir_okay, readable=outer.readable or inner.readable, writable=outer.writable or inner.writable, + parser=outer.parser if outer.parser is not None else inner.parser, ) @@ -461,15 +464,59 @@ def _coerce_tuple_value( return values +def _apply_custom_parser( + value: Any, + custom_parser: Callable[..., object], + param_name: str, + raw: str, +) -> Any: + try: + return custom_parser(value) + except (ValueError, TypeError) as exc: + raise argparse.ArgumentTypeError( + f"invalid value for {param_name!r}: {raw!r} ({exc})", + ) from exc + + +def _resolve_parser_input_type(custom_parser: Callable[..., object], param_name: str) -> Any: + try: + parser_sig = inspect.signature(custom_parser) + except (TypeError, ValueError) as exc: + raise YeetrError(f"parser for {param_name!r} must take exactly one argument") from exc + parser_params = list(parser_sig.parameters.values()) + bad_kinds = {Parameter.KEYWORD_ONLY, Parameter.VAR_KEYWORD} + if len(parser_params) != 1 or parser_params[0].kind in bad_kinds: + raise YeetrError(f"parser for {param_name!r} must take exactly one argument") + only = parser_params[0] + if only.annotation is Parameter.empty: + raise YeetrError( + f"parser for {param_name!r} has an untyped parameter; add a type annotation", + ) + annotation = only.annotation + if isinstance(annotation, str): + hint_source = custom_parser if inspect.isroutine(custom_parser) else custom_parser.__call__ + annotation = typing.get_type_hints(hint_source)[only.name] + input_type = _resolve_type_alias(annotation) + if input_type is bool or get_origin(input_type) in {list, tuple}: + raise YeetrError( + f"parser for {param_name!r} takes {_type_name(input_type)!r}; " + "bool, list, and tuple parser input types are not supported", + ) + return input_type + + def _type_caster( target: Any, param_name: str, path_checks: _PathChecks | None = None, + custom_parser: Callable[..., object] | None = None, ) -> Callable[[str], Any]: def cast(raw: str) -> Any: - value = _coerce_value(raw, target, param_name) + value = _coerce_nested_value(raw, target, param_name) if target is Path and path_checks is not None and path_checks.is_active(): - return _apply_path_checks(value, path_checks) + value = _apply_path_checks(value, path_checks) + if custom_parser is not None: + return _apply_custom_parser(value, custom_parser, param_name, raw) return value cast.__name__ = getattr(target, "__name__", "value") @@ -530,6 +577,10 @@ def _add_var_positional( ) arg_meta = metadata if isinstance(metadata, Arg) else None + if arg_meta is not None and arg_meta.parser is not None: + raise YeetrError( + f"parser= is not supported on variadic positional parameter {param.name!r}.", + ) help_text = arg_meta.help if arg_meta is not None else None metavar = arg_meta.metavar if arg_meta is not None else None minimum = arg_meta.min if arg_meta is not None else 0 @@ -590,11 +641,20 @@ def _add_parameter( # pylint: disable=too-many-locals "use `Arg` for positional parameters.", ) + custom_parser = metadata.parser if metadata is not None else None + if custom_parser is not None: + if get_origin(effective) in {list, tuple}: + raise YeetrError( + f"parser= is not supported on list/tuple parameters; " + f"parameter {param.name!r} is annotated as {effective}.", + ) + effective = _resolve_parser_input_type(custom_parser, param.name) + origin = get_origin(effective) is_list = origin is list is_tuple = origin is tuple - is_literal = origin is Literal - is_enum = _is_enum_type(effective) + is_literal = custom_parser is None and origin is Literal + is_enum = custom_parser is None and _is_enum_type(effective) help_text = metadata.help if metadata is not None else None metavar = metadata.metavar if metadata is not None else None @@ -624,6 +684,7 @@ def _add_parameter( # pylint: disable=too-many-locals path_checks=path_checks, has_default=has_default, default=default, + custom_parser=custom_parser, ) if is_keyword_only: @@ -644,6 +705,7 @@ def _add_parameter( # pylint: disable=too-many-locals metavar=metavar, path_checks=path_checks, envvar_active=envvar is not None, + custom_parser=custom_parser, ) long_flag, aliases = _split_flags_for_display(param.name, flags, effective, default) info.long_flag = long_flag @@ -662,6 +724,7 @@ def _add_parameter( # pylint: disable=too-many-locals help_text=help_text, metavar=metavar, path_checks=path_checks, + custom_parser=custom_parser, ) return info @@ -728,6 +791,7 @@ def _add_option( # pylint: disable=too-many-arguments,too-many-branches,too-man metavar: str | None, path_checks: _PathChecks, envvar_active: bool, + custom_parser: Callable[..., object] | None, ) -> None: if effective is bool: if not has_default and not envvar_active: @@ -831,7 +895,7 @@ def _add_option( # pylint: disable=too-many-arguments,too-many-branches,too-man parser.add_argument( *flags, dest=param_name, - type=_type_caster(effective, param_name, path_checks), + type=_type_caster(effective, param_name, path_checks, custom_parser), default=argparse_default, required=argparse_required, help=rendered_help, @@ -853,6 +917,7 @@ def _add_positional( # pylint: disable=too-many-arguments help_text: str | None, metavar: str | None, path_checks: _PathChecks, + custom_parser: Callable[..., object] | None, ) -> None: dest = param_name display_metavar = metavar or _snake_to_kebab(param_name).upper() @@ -920,7 +985,7 @@ def _add_positional( # pylint: disable=too-many-arguments return kwargs = { - "type": _type_caster(effective, param_name, path_checks), + "type": _type_caster(effective, param_name, path_checks, custom_parser), "help": rendered_help, "metavar": display_metavar, } @@ -1228,7 +1293,10 @@ def _coerce_envvar_value(raw: str, info: _ParamInfo) -> Any: return _coerce_tuple_value(parts, effective, info.name, info.path_checks) if info.is_literal: return _literal_caster(get_args(effective), info.name)(raw) - return _coerce_value(raw, effective, info.name) + value = _coerce_nested_value(raw, effective, info.name) + if info.custom_parser is not None: + return _apply_custom_parser(value, info.custom_parser, info.name, raw) + return value def _resolve_envvars( From 93ceb1c79098d617b59391bb0a0437f1b52e4d87 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:43:38 +0100 Subject: [PATCH 2/4] Update function running approach --- README.md | 7 ++- docs/comparison.md | 22 ++++--- docs/guide/getting-started.md | 26 ++++++-- docs/index.md | 3 +- tests/test_yeetr.py | 113 +++++++++++++++++++++++++++++++++- yeetr/_cli.py | 96 +++++++++++++++++++++++++---- 6 files changed, 233 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index a038b9d..0525976 100644 --- a/README.md +++ b/README.md @@ -61,9 +61,10 @@ uv add yeetr ## Highlights -- **Zero boilerplate** — `yeet app.py` finds and runs `main`, and scaffolds - the file if it doesn't exist yet. -- **Executable shebangs** — `#!yeet` makes a script runnable on its own. +- **Zero boilerplate** — `yeet app.py` finds and runs `main` — or any + public function you name — and scaffolds the file if it doesn't exist yet. +- **Executable shebangs** — `#!yeet` makes a script runnable on its own; + `#!yeet FUNC` picks the function to run. - **Fully typed** — `str`, `int`, `float`, `bool`, `Path`, `datetime`, `date`, `time`, `UUID`, `Decimal`, `Literal`, `Enum`, `T | None`, `list[T]`, tuples, and structured `dataclass`/`NamedTuple` args, all diff --git a/docs/comparison.md b/docs/comparison.md index 6c1fc4d..f103a59 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -9,23 +9,25 @@ problem. Quick honest comparison so you can pick the right tool: | Topic | yeetr | typer | | --------------------------- | ------------------------------------------------------------------------ | -------------------------------------------------------------------- | | Style | Plain function signature, no decorators | Decorators (`@app.command()`) or `typer.run` | -| Zero-boilerplate runner | `yeet main.py [func] [args...]` script — no `if __name__ == "__main__"` / `yeetr.run(...)` block needed | Always need a `typer.run(...)` call or a decorated `@app.command()` entry point | -| 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(...)` | +| Zero-boilerplate runner | `yeet main.py [func] [args...]` script — no `if __name__ == "__main__"` / `yeetr.run(...)` block needed | Similar: `typer main.py run [args...]` runs scripts, even ones that don't import typer; in code you need `typer.run(...)` or an `@app.command()` entry point | +| Executable shebang | `#!yeet` or `#!uv run yeet` can make the script itself executable without extra wrapper code; `#!yeet FUNC` dispatches to a specific function | No shebang equivalent: the `typer` command needs its `run` subcommand between the file and the args, which a shebang can't inject | +| Arg vs. option mapping | Python's `*` separator decides: before `*` = positional args, after `*` = `--options` | Presence of a default decides: no default = positional arg, default = `--option`; override per parameter via `typer.Argument(...)` / `typer.Option(...)` | | Per-param metadata | `Annotated[T, Arg(...)]` / `Annotated[T, Opt(...)]` | `Annotated[T, typer.Argument(...)]` / `typer.Option(...)` | +| Custom parsers | `Arg/Opt(parser=...)` — the CLI string is first coerced to the parser's annotated input type (path validators compose), then the parser runs | `typer.Argument/Option(parser=...)` — the parser receives the raw string | | Structured arg object | Accept one `dataclass` or `NamedTuple`; fields become the CLI and the function receives the object | Declare command params individually, then construct your object inside the command | | 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 | -| Async functions | Native: `async def` is run via `asyncio.run` / `uvloop.run` | Not built-in; wrap with `asyncio.run(...)` yourself | +| Boolean flags | One flag, driven by the default: `= False` -> `--flag`, `= True` -> `--no-flag` | Auto-generates the pair `--flag / --no-flag`; explicit declaration only to customize the names | +| Subcommands | Subcommand-style dispatch via the runner: `yeet main.py FUNC [args...]` picks any public function by name (also `yeet FILE:FUNC` and `#!yeet FUNC` shebangs); no command groups, nesting, or function listing in `--help` | First-class subcommands, command groups, nested apps | +| Async functions | Native: `async def` is run via `asyncio.run` / `uvloop.run` | Not built-in; an `async def` command is silently never awaited — wrap with `asyncio.run(...)` yourself | | Shell completion | Not built-in | Built-in (bash/zsh/fish/PowerShell) | | Help rendering | Rich tables for args and options | Rich-formatted help via `rich` | -| Type-checker friendliness | Designed to be Pyright-strict clean end-to-end | Some patterns require `# type: ignore` under strict settings | +| Type-checker friendliness | Pyright-strict clean end-to-end; `Arg`/`Opt` are plain, precisely-typed dataclasses | Also clean under strict Pyright; `typer.Argument()`/`typer.Option()` return `Any`, which relaxes checking at the parameter site | | Logging | Rich logging set up by default (opt-out) | Not opinionated about logging | | Dependencies | `rich`, `rich-argparse` (small footprint) | `click`, `rich`, `shellingham`, `typing-extensions` | | Maturity / ecosystem | New and small | Widely adopted, large ecosystem | | Best for | Single-purpose scripts and tools where the function *is* the CLI | Multi-command CLIs, distributed apps, anything needing completion | -If you need subcommands or shell completion, use typer. If you want one -function = one CLI with minimal ceremony and strict typing, yeetr is -designed for that. +If you need nested command groups or shell completion, use typer. If you +want one function = one CLI with minimal ceremony and strict typing — with +`yeet FILE FUNC` covering the simple multi-command case — yeetr is designed +for that. diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 0ea7e22..76a0afa 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -23,12 +23,13 @@ script for you, mark it executable, and print the created path. Run the same command a second time, or call `./app.py` directly, and it will execute normally. -The default function name is `main`. Pass a different one to pick another -top-level function in the same file: +The default function name is `main`. To run another function, name it — the +target must be a **public** `def`/`async def` defined in that file (not an +import, a class, or a `_`-prefixed helper): ```python # app.py -def main(...) -> None: ... +def main(thing: int) -> None: ... def greet(name: str, *, loud: bool = False) -> None: ... ``` @@ -36,8 +37,21 @@ def greet(name: str, *, loud: bool = False) -> None: ... yeet app.py greet world --loud ``` +Because the `.py` file identifies itself, the function can also come first or +be attached with a colon — pick whichever reads best: + +```bash +yeet greet app.py world --loud # function first +yeet app.py:greet world --loud # FILE:FUNC +``` + +If `main` takes a **string** first argument, a bare `yeet app.py greet` is +genuinely ambiguous — `greet` could be the function to run *or* a value for +`main` — so yeetr raises instead of guessing. Disambiguate with +`yeet app.py main greet` (pass it to `main`) or the `app.py:greet` / +function-first forms (run `greet`). + `yeet app.py --help` prints the **target function's** help, not yeet's. -`yeet` itself only has `yeet FILE [FUNC] [args...]`. You can still use the explicit `yeetr.run(main)` form when you prefer — the `yeet` script is just sugar on top of it. @@ -95,8 +109,8 @@ chmod +x greet.py ./greet.py world --loud ``` -If you need a different entry function, keep the shebang simple and call -`uv run yeet app.py other_func ...` explicitly instead. +To make the script run a function other than `main`, name it in the shebang — +`#!yeet greet` — and running `./greet.py ...` invokes that function directly. ## Next steps diff --git a/docs/index.md b/docs/index.md index 545756a..524c79e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -70,7 +70,8 @@ Parameters **before** the bare `*` become positional CLI args. Parameters ## Where to go next - [Getting Started](guide/getting-started.md) — installation, the `yeet` - script, the explicit `yeetr.run(main)` form, and executable shebangs. + script and how it picks the function to run, the explicit `yeetr.run(main)` + form, and executable shebangs. - [Async Support](guide/async.md) — `async def main`, handled natively. - [Parameter Types](guide/parameter-types.md) — every type yeetr understands, from `Path` to `Enum` to tuples and variadic `*args`. diff --git a/tests/test_yeetr.py b/tests/test_yeetr.py index b94b0df..6bec835 100644 --- a/tests/test_yeetr.py +++ b/tests/test_yeetr.py @@ -1666,7 +1666,7 @@ def test_yeet_cli_errors_when_target_function_missing( yeet_main([str(file)]) assert exc.value.code == 2 err = capsys.readouterr().err - assert "has no callable attribute 'main'" in err + assert "has no public function 'main' to run" in err def test_yeet_cli_errors_when_named_attribute_is_not_callable( @@ -1681,7 +1681,7 @@ def test_yeet_cli_errors_when_named_attribute_is_not_callable( yeet_main([str(file), "thing"]) assert exc.value.code == 2 err = capsys.readouterr().err - assert "has no callable attribute 'main'" in err + assert "has no public function 'main' to run" in err def test_yeet_cli_keeps_non_callable_candidate_as_argument( @@ -1697,3 +1697,112 @@ def test_yeet_cli_keeps_non_callable_candidate_as_argument( yeet_main([str(file), "thing"]) out = capsys.readouterr().out assert "thing" in out + + +def _write_thing_and_str_main(tmp_path: Path) -> Path: + file = tmp_path / "demo.py" + file.write_text( + "def thing() -> None:\n" + " print('ran thing')\n" + "\n" + "def main(arg: str) -> None:\n" + " print(f'main got {arg!r}')\n", + ) + return file + + +def test_yeet_cli_ambiguous_function_and_str_main_errors( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + from yeetr._cli import main as yeet_main + + file = _write_thing_and_str_main(tmp_path) + with pytest.raises(SystemExit) as exc: + yeet_main([str(file), "thing"]) + assert exc.value.code == 2 + assert "ambiguous" in capsys.readouterr().err + + +def test_yeet_cli_explicit_main_escapes_ambiguity( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + from yeetr._cli import main as yeet_main + + file = _write_thing_and_str_main(tmp_path) + yeet_main([str(file), "main", "thing"]) + assert "main got 'thing'" in capsys.readouterr().out + + +def test_yeet_cli_imported_callable_is_not_dispatched( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + from yeetr._cli import main as yeet_main + + file = tmp_path / "demo.py" + file.write_text( + "from logging import getLogger\n" + "\n" + "def main(arg: str) -> None:\n" + " print(f'main got {arg!r}')\n", + ) + # `getLogger` is imported, not defined here -> treated as main's value, not run. + yeet_main([str(file), "getLogger"]) + assert "main got 'getLogger'" in capsys.readouterr().out + + +def test_yeet_cli_private_function_is_not_dispatched( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + from yeetr._cli import main as yeet_main + + file = tmp_path / "demo.py" + file.write_text( + "def _secret() -> None:\n" + " print('secret')\n" + "\n" + "def main(arg: str) -> None:\n" + " print(f'main got {arg!r}')\n", + ) + yeet_main([str(file), "_secret"]) + assert "main got '_secret'" in capsys.readouterr().out + + +def test_yeet_cli_function_before_file( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + from yeetr._cli import main as yeet_main + + file = _write_thing_and_str_main(tmp_path) + # `yeet FUNC FILE` — the form a `#!yeet thing` shebang produces. + yeet_main(["thing", str(file)]) + assert "ran thing" in capsys.readouterr().out + + +def test_yeet_cli_file_colon_function( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + from yeetr._cli import main as yeet_main + + file = _write_thing_and_str_main(tmp_path) + yeet_main([f"{file}:thing"]) + assert "ran thing" in capsys.readouterr().out + + +def test_yeet_cli_non_function_main_rejected( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + from yeetr._cli import main as yeet_main + + file = tmp_path / "demo.py" + file.write_text("class main:\n pass\n") + with pytest.raises(SystemExit) as exc: + yeet_main([str(file)]) + assert exc.value.code == 2 + assert "has no public function 'main' to run" in capsys.readouterr().err diff --git a/yeetr/_cli.py b/yeetr/_cli.py index 843cfcc..deb86a4 100644 --- a/yeetr/_cli.py +++ b/yeetr/_cli.py @@ -3,20 +3,20 @@ from __future__ import annotations import importlib.util +import inspect import stat import sys +import types +import typing from contextlib import suppress from pathlib import Path -from typing import TYPE_CHECKING +from typing import get_args, get_origin from rich.console import Console from rich.text import Text from ._runner import run -if TYPE_CHECKING: - import types - def _logo_text(style: str) -> Text: text = Text(style=style) @@ -90,20 +90,80 @@ def _load_module(path: Path) -> types.ModuleType: return module +def _is_public_local_function(module_name: str, name: str, obj: object) -> bool: + """Return whether ``obj`` is a public ``def``/``async def`` defined in this module. + + Strict on purpose: excludes anything with a leading underscore, anything not a + plain function (classes, callable instances, ``functools.partial``), imported + functions (``__module__`` differs), aliases and lambdas (``__name__`` differs). + """ + return ( + not name.startswith("_") + and inspect.isfunction(obj) + and obj.__module__ == module_name + and obj.__name__ == name + ) + + +def _is_str_type(annotation: object) -> bool: + if annotation is str: + return True + if get_origin(annotation) in (typing.Union, types.UnionType): + non_none = [arg for arg in get_args(annotation) if arg is not types.NoneType] + return non_none == [str] + return False + + +def _main_accepts_string_positional(module: types.ModuleType) -> bool: + """Return whether ``main`` takes a string-typed first positional CLI argument.""" + main_func = getattr(module, "main", None) + if main_func is None or not _is_public_local_function(module.__name__, "main", main_func): + return False + try: + hints = typing.get_type_hints(main_func) + except (NameError, TypeError): + return False + for param in inspect.signature(main_func).parameters.values(): + if param.kind in ( + param.POSITIONAL_ONLY, + param.POSITIONAL_OR_KEYWORD, + param.VAR_POSITIONAL, + ): + return _is_str_type(hints.get(param.name)) + if param.kind is param.KEYWORD_ONLY: + return False + return False + + def main(argv: list[str] | None = None) -> None: """Run the ``yeet`` command-line interface.""" raw = list(sys.argv[1:] if argv is None else argv) if not raw or raw[0] in {"-h", "--help"}: sys.stdout.write( "Usage: yeet FILE [FUNC] [args...]\n" + " yeet FUNC FILE [args...] (as produced by a `#!yeet FUNC` shebang)\n" + " yeet FILE:FUNC [args...]\n" "\n" - "Run a function from a Python file as a CLI.\n" - "FUNC defaults to `main`. Anything after FILE/FUNC is forwarded\n" + "Run a function from a Python file as a CLI. The `.py` file identifies\n" + "itself; FUNC defaults to `main`. Anything after FILE/FUNC is forwarded\n" "to the function's own CLI (try `yeet FILE --help`).\n", ) sys.exit(0 if raw else 2) + # The `.py` file identifies itself. There are two explicit ways to pick a + # function: a bare token *before* the file (what a `#!yeet FUNC` shebang + # produces, since the kernel runs `yeet FUNC /path/to/script.py`), or the + # `FILE.py:FUNC` form. Both bypass the file-first ambiguity check. + explicit_func: str | None = None file_arg, *rest = raw + if not file_arg.endswith(".py") and rest and rest[0].endswith(".py"): + explicit_func, file_arg, *rest = raw + + # `FILE.py:FUNC` — split on the last colon so Windows drive letters survive. + head, sep, tail = file_arg.rpartition(":") + if sep and head.endswith(".py") and tail.isidentifier(): + file_arg, explicit_func = head, tail + path = Path(file_arg).resolve() if not path.is_file(): _create_script(path) @@ -111,17 +171,29 @@ def main(argv: list[str] | None = None) -> None: module = _load_module(path) - func_name = "main" - if rest and not rest[0].startswith("-"): + func_name = explicit_func or "main" + if explicit_func is None and rest and not rest[0].startswith("-"): candidate = rest[0] - attr = getattr(module, candidate, None) - if callable(attr): + if _is_public_local_function(module.__name__, candidate, getattr(module, candidate, None)): + if candidate != "main" and _main_accepts_string_positional(module): + _print_error( + f"ambiguous: {candidate!r} is a function in {path.name}, but main() also " + f"takes a string argument, so {candidate!r} could be the function to run or " + f"a value for main.\n" + f"To pass it to main, run: yeet {path.name} main {candidate}\n" + f"To run {candidate}(), give main a non-string first parameter or rename to " + f"remove the clash.", + ) + sys.exit(2) func_name = candidate rest = rest[1:] func = getattr(module, func_name, None) - if func is None or not callable(func): - _print_error(f"{path.name} has no callable attribute {func_name!r}") + if func is None or not _is_public_local_function(module.__name__, func_name, func): + _print_error( + f"{path.name} has no public function {func_name!r} to run " + f"(yeetr runs a `def` or `async def` defined in the file).", + ) sys.exit(2) run(func, argv=rest, prog=f"yeet {path.name}") From f6118fe4807e51209fc684b15a67258783d43732 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:46:51 +0100 Subject: [PATCH 3/4] add-doc --- docs/guide/getting-started.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 76a0afa..075b756 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -25,7 +25,9 @@ execute normally. The default function name is `main`. To run another function, name it — the target must be a **public** `def`/`async def` defined in that file (not an -import, a class, or a `_`-prefixed helper): +import, a class, or a `_`-prefixed helper). Decorated functions qualify only +if the decorator preserves the function's identity with `functools.wraps` — +otherwise the name is not recognised as a runnable function: ```python # app.py From f8b5e7203f33dc22b3e3403562ac7ce10bf53b48 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:20:32 +0100 Subject: [PATCH 4/4] increase-code-cov --- tests/test_yeetr.py | 180 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) diff --git a/tests/test_yeetr.py b/tests/test_yeetr.py index 6bec835..1d9c32d 100644 --- a/tests/test_yeetr.py +++ b/tests/test_yeetr.py @@ -1516,6 +1516,82 @@ def main(*, value: Annotated[int, Opt(parser=_double, envvar="VALUE")] = 0) -> N assert captured == {"value": 6} +def _keyword_only_parser(*, value: int) -> int: + return value + + +def _bool_input_parser(value: bool) -> str: # noqa: FBT001 + return str(value) + + +def _list_input_parser(value: list[str]) -> str: + return ",".join(value) + + +def _quoted_annotation_parser(value: "int") -> int: + return value * 2 + + +@dataclass(slots=True) +class _MultiplierParser: + factor: int + + def __call__(self, value: "int") -> int: + return value * self.factor + + +def test_parser_without_introspectable_signature_rejected() -> None: + def main(*, value: Annotated[str, Opt(parser=min)]) -> None: + del value + + with pytest.raises(YeetrError, match="must take exactly one argument"): + yeetr.run(main, argv=["--value", "x"]) + + +def test_parser_keyword_only_param_rejected() -> None: + def main(*, value: Annotated[int, Opt(parser=_keyword_only_parser)]) -> None: + del value + + with pytest.raises(YeetrError, match="must take exactly one argument"): + yeetr.run(main, argv=["--value", "1"]) + + +def test_parser_bool_input_type_rejected() -> None: + def main(*, value: Annotated[str, Opt(parser=_bool_input_parser)]) -> None: + del value + + with pytest.raises(YeetrError, match="not supported"): + yeetr.run(main, argv=["--value", "true"]) + + +def test_parser_list_input_type_rejected() -> None: + def main(*, value: Annotated[str, Opt(parser=_list_input_parser)]) -> None: + del value + + with pytest.raises(YeetrError, match="not supported"): + yeetr.run(main, argv=["--value", "x"]) + + +def test_parser_string_annotation_resolved() -> None: + captured: dict[str, object] = {} + + def main(*, value: Annotated[int, Opt(parser=_quoted_annotation_parser)]) -> None: + captured["value"] = value + + yeetr.run(main, argv=["--value", "3"]) + assert captured == {"value": 6} + + +def test_parser_callable_object() -> None: + captured: dict[str, object] = {} + + def main(*, value: Annotated[int, Opt(parser=_MultiplierParser(3))]) -> None: + captured["value"] = value + + yeetr.run(main, argv=["--value", "2"]) + assert captured == {"value": 6} + + def _write_demo(tmp_path: Path) -> Path: file = tmp_path / "demo.py" file.write_text( @@ -1806,3 +1882,107 @@ def test_yeet_cli_non_function_main_rejected( yeet_main([str(file)]) assert exc.value.code == 2 assert "has no public function 'main' to run" in capsys.readouterr().err + + +def test_yeet_cli_optional_str_main_is_ambiguous( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + from yeetr._cli import main as yeet_main + + file = tmp_path / "demo.py" + file.write_text( + "def thing() -> None:\n" + " print('ran thing')\n" + "\n" + "def main(arg: str | None = None) -> None:\n" + " print(f'main got {arg!r}')\n", + ) + with pytest.raises(SystemExit) as exc: + yeet_main([str(file), "thing"]) + assert exc.value.code == 2 + assert "ambiguous" in capsys.readouterr().err + + +def test_yeet_cli_dispatches_function_when_main_missing( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + from yeetr._cli import main as yeet_main + + file = tmp_path / "demo.py" + file.write_text("def thing() -> None:\n print('ran thing')\n") + yeet_main([str(file), "thing"]) + assert "ran thing" in capsys.readouterr().out + + +def test_yeet_cli_unresolvable_main_hints_not_ambiguous( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + from yeetr._cli import main as yeet_main + + file = tmp_path / "demo.py" + file.write_text( + "def thing() -> None:\n" + " print('ran thing')\n" + "\n" + "def main(arg: 'Nope') -> None:\n" + " del arg\n", + ) + yeet_main([str(file), "thing"]) + assert "ran thing" in capsys.readouterr().out + + +def test_yeet_cli_keyword_only_main_not_ambiguous( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + from yeetr._cli import main as yeet_main + + file = tmp_path / "demo.py" + file.write_text( + "def thing() -> None:\n" + " print('ran thing')\n" + "\n" + "def main(*, n: int = 1) -> None:\n" + " print(f'main n={n}')\n", + ) + yeet_main([str(file), "thing"]) + assert "ran thing" in capsys.readouterr().out + + +def test_yeet_cli_var_keyword_main_not_ambiguous( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + from yeetr._cli import main as yeet_main + + file = tmp_path / "demo.py" + file.write_text( + "def thing() -> None:\n" + " print('ran thing')\n" + "\n" + "def main(**kwargs: str) -> None:\n" + " del kwargs\n", + ) + yeet_main([str(file), "thing"]) + assert "ran thing" in capsys.readouterr().out + + +def test_yeet_cli_zero_param_main_not_ambiguous( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + from yeetr._cli import main as yeet_main + + file = tmp_path / "demo.py" + file.write_text( + "def thing() -> None:\n" + " print('ran thing')\n" + "\n" + "def main() -> None:\n" + " print('ran main')\n", + ) + yeet_main([str(file), "thing"]) + assert "ran thing" in capsys.readouterr().out