From e0e5bf616399f7cf6136f9fe8528465396912a12 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 13:46:56 +0100 Subject: [PATCH 1/5] Add optional Pydantic model support --- README.md | 39 +++++++++++++- pyproject.toml | 5 ++ tests/test_yeetr.py | 83 ++++++++++++++++++++++++++++- uv.lock | 124 +++++++++++++++++++++++++++++++++++++++++++- yeetr/_runner.py | 81 +++++++++++++++++++++++++++++ 5 files changed, 329 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0f9d023..955554e 100644 --- a/README.md +++ b/README.md @@ -247,6 +247,41 @@ 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 +validation runs after CLI parsing. The model must be the function's only +parameter. + +--- + ## Parameter Metadata ### `Arg` and `Opt` @@ -424,7 +459,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`. --- diff --git a/pyproject.toml b/pyproject.toml index 1aa9be3..07db6ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] @@ -42,6 +43,10 @@ dev = [ "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"] diff --git a/tests/test_yeetr.py b/tests/test_yeetr.py index d3ffcec..2f6d5f5 100644 --- a/tests/test_yeetr.py +++ b/tests/test_yeetr.py @@ -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-many-lines import asyncio import enum @@ -254,6 +254,87 @@ 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_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] = {} diff --git a/uv.lock b/uv.lock index 3371dcb..b66b621 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,24 @@ version = 1 revision = 3 requires-python = ">=3.14" +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + [[package]] name = "astroid" version = "4.0.4" @@ -537,6 +555,62 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/be/5a/cdd1d5e7a92c4b30137f229420c9f1c78adf6023330a214e85d20fc62226/prek-0.4.2-py3-none-win_arm64.whl", hash = "sha256:e02f499a0fd0038756159604d41e0355bd82c5c1d04296f5a081ca3261e45177", size = 5444961, upload-time = "2026-05-26T09:01:58.057Z" }, ] +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -772,6 +846,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" }, ] +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -872,6 +955,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5f/53/4a33dc81da39db7b31e5622333df361e8fe055b7ec636bd5fea762c9182d/tox_uv_bare-1.35.2-py3-none-any.whl", hash = "sha256:c0d590a41d1054a1ad0874e9e5943ff52402786e3d4599d8f8d37a65b566ef53", size = 22307, upload-time = "2026-05-05T01:34:17.681Z" }, ] +[[package]] +name = "typer" +version = "0.26.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/d3/90c1ee19209cb59f6ad185883fd4ccfcf72f8f0bfd549d5a8b70474611d0/typer-0.26.4.tar.gz", hash = "sha256:25b128964de66c5ea36d5ac82adc579e5e113509b17469edf9f5a4a1864ff2a9", size = 201191, upload-time = "2026-05-30T17:05:04.213Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/6d/5a525c69df4a90892135e5d490b00e9e46402491f3416d4395fcb0d0201e/typer-0.26.4-py3-none-any.whl", hash = "sha256:11bfd7b43557137e373c2b10f6967a555f9678a61ed72c808968b011d95534d6", size = 122436, upload-time = "2026-05-30T17:05:05.812Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -881,6 +979,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "urllib3" version = "2.7.0" @@ -979,6 +1089,9 @@ dependencies = [ ] [package.optional-dependencies] +pydantic = [ + { name = "pydantic" }, +] uvloop = [ { name = "uvloop" }, ] @@ -997,14 +1110,19 @@ dev = [ { name = "ruff" }, { name = "tox-uv" }, ] +local-dev = [ + { name = "pydantic" }, + { name = "typer" }, +] [package.metadata] requires-dist = [ + { name = "pydantic", marker = "extra == 'pydantic'", specifier = ">=2.13.4" }, { name = "rich", specifier = ">=15.0.0" }, { name = "rich-argparse", specifier = ">=1.5.2" }, { name = "uvloop", marker = "extra == 'uvloop'", specifier = ">=0.21.0" }, ] -provides-extras = ["uvloop"] +provides-extras = ["pydantic", "uvloop"] [package.metadata.requires-dev] dev = [ @@ -1020,3 +1138,7 @@ dev = [ { name = "ruff", specifier = ">=0.15.7" }, { name = "tox-uv", specifier = ">=1.33.4" }, ] +local-dev = [ + { name = "pydantic", specifier = ">=2.13.4" }, + { name = "typer", specifier = ">=0.26.4" }, +] diff --git a/yeetr/_runner.py b/yeetr/_runner.py index dfa758a..8725d02 100644 --- a/yeetr/_runner.py +++ b/yeetr/_runner.py @@ -5,6 +5,7 @@ import argparse import asyncio import enum +import importlib import inspect import logging import os @@ -92,6 +93,19 @@ def _is_enum_type(target: Any) -> bool: return inspect.isclass(target) and issubclass(target, enum.Enum) +def _pydantic_base_model() -> Any: + try: + module = importlib.import_module("pydantic") + except ImportError: + return None + return module.BaseModel + + +def _is_pydantic_model_type(target: Any) -> bool: + base_model = _pydantic_base_model() + return base_model is not None and inspect.isclass(target) and issubclass(target, base_model) + + def _type_name(target: Any) -> str: return getattr(target, "__name__", str(target)) @@ -584,6 +598,44 @@ def _add_parameter( # pylint: disable=too-many-locals return info +def _pydantic_field_default(model_field: Any) -> Any: + if model_field.is_required(): + return Parameter.empty + if model_field.default_factory is not None: + return model_field.default_factory() + return model_field.default + + +def _pydantic_field_alias(model_field: Any) -> str | None: + alias = model_field.alias + if alias is None: + return None + if not isinstance(alias, str): + raise YeetrError("Pydantic field aliases must be strings.") + return f"-{alias}" if len(alias) == 1 else f"--{_snake_to_kebab(alias)}" + + +def _add_pydantic_model( + parser: argparse.ArgumentParser, + model_type: Any, +) -> list[_ParamInfo]: + infos: list[_ParamInfo] = [] + for name, model_field in model_type.model_fields.items(): + annotation = model_field.annotation + if annotation is None: + raise YeetrError(f"Pydantic field {name!r} is missing a type annotation.") + metadata = Opt(alias=_pydantic_field_alias(model_field), help=model_field.description) + annotated = typing.Annotated[annotation, metadata] + param = Parameter( + name, + kind=Parameter.KEYWORD_ONLY, + default=_pydantic_field_default(model_field), + annotation=annotated, + ) + infos.append(_add_parameter(parser, param)) + return infos + + def _split_flags_for_display( param_name: str, flags: list[str], @@ -842,7 +894,17 @@ def _build_parser( formatter_class=RichHelpFormatter, ) infos: list[_ParamInfo] = [] + pydantic_params = [ + param + for param in sig.parameters.values() + if param.annotation is not Parameter.empty and _is_pydantic_model_type(param.annotation) + ] + if pydantic_params and len(sig.parameters) != 1: + raise YeetrError("A Pydantic model must be the function's only parameter.") for param in sig.parameters.values(): + if param in pydantic_params: + infos.extend(_add_pydantic_model(parser, param.annotation)) + continue if param.kind is Parameter.VAR_KEYWORD: raise YeetrError( f"Variadic keyword parameter **{param.name} is not supported.", @@ -1023,6 +1085,24 @@ def _build_call_args( return args, kwargs +def _normalize_pydantic_model( + parser: argparse.ArgumentParser, + sig: Signature, + namespace: argparse.Namespace, +) -> None: + for name, param in sig.parameters.items(): + model_type = param.annotation + if not _is_pydantic_model_type(model_type): + continue + values = { + field_name: getattr(namespace, field_name) for field_name in model_type.model_fields + } + try: + setattr(namespace, name, model_type.model_validate(values, by_name=True)) + except ValueError as exc: + parser.error(str(exc)) + + def _normalize_parsed_values( parser: argparse.ArgumentParser, namespace: argparse.Namespace, @@ -1130,6 +1210,7 @@ def run[T]( namespace = parser.parse_args(raw_argv) _normalize_parsed_values(parser, namespace, infos) _resolve_envvars(parser, namespace, infos) + _normalize_pydantic_model(parser, sig, namespace) if should_setup_logging: _setup_logging(namespace) call_args, call_kwargs = _build_call_args(sig, namespace) From 6e75aeb861f94ca67c426c01aac67fa59bd3ba2c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 13:48:04 +0100 Subject: [PATCH 2/5] Document Pydantic support comparison --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 955554e..e289341 100644 --- a/README.md +++ b/README.md @@ -562,6 +562,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 | From bcc38d9c1d818c0173b9070dd890058c75674a57 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 13:53:47 +0100 Subject: [PATCH 3/5] Normalize option alias shorthand --- README.md | 5 +++++ tests/test_yeetr.py | 53 +++++++++++++++++++++++++++++++++++++++++++++ yeetr/_metadata.py | 5 ++++- yeetr/_runner.py | 9 +++++++- 4 files changed, 70 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e289341..1f0d66f 100644 --- a/README.md +++ b/README.md @@ -277,6 +277,7 @@ 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. @@ -312,6 +313,10 @@ 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`. +Option aliases accept either shorthand or explicit CLI spelling: +`Opt(alias="w")` and `Opt(alias="-w")` both become `-w`; `Opt(alias="who")` +and `Opt(alias="--who")` both become `--who`. + You can also define aliases once and reuse them: ```python diff --git a/tests/test_yeetr.py b/tests/test_yeetr.py index 2f6d5f5..b4e3998 100644 --- a/tests/test_yeetr.py +++ b/tests/test_yeetr.py @@ -270,6 +270,36 @@ def main(request: Request) -> None: 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 @@ -386,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] = {} diff --git a/yeetr/_metadata.py b/yeetr/_metadata.py index 69c83c8..7dba810 100644 --- a/yeetr/_metadata.py +++ b/yeetr/_metadata.py @@ -43,12 +43,15 @@ class Opt: from typing import Annotated from yeetr import Opt - def main(*, workers: Annotated[int, Opt(alias="-w", help="Workers")] = 4) -> None: ... + def main(*, workers: Annotated[int, Opt(alias="w", help="Workers")] = 4) -> None: ... This is the only Pyright-strict-clean way to attach per-parameter CLI metadata in Python's type system: calls are only permitted inside the metadata slot of ``Annotated``. + ``alias`` and ``aliases`` accept either shorthand (``"w"`` / ``"who"``) + or explicit CLI spelling (``"-w"`` / ``"--who"``). + ``envvar`` provides a fallback value from the environment when the flag is not given on the command line. Precedence: explicit CLI > env var > default. For ``list[T]`` opts, the env var is split on ``os.pathsep``. diff --git a/yeetr/_runner.py b/yeetr/_runner.py index 8725d02..b14995d 100644 --- a/yeetr/_runner.py +++ b/yeetr/_runner.py @@ -606,13 +606,19 @@ def _pydantic_field_default(model_field: Any) -> Any: return model_field.default +def _normalize_opt_alias(alias: str) -> str: + if alias.startswith("-"): + return alias + return f"-{alias}" if len(alias) == 1 else f"--{_snake_to_kebab(alias)}" + + def _pydantic_field_alias(model_field: Any) -> str | None: alias = model_field.alias if alias is None: return None if not isinstance(alias, str): raise YeetrError("Pydantic field aliases must be strings.") - return f"-{alias}" if len(alias) == 1 else f"--{_snake_to_kebab(alias)}" + return _normalize_opt_alias(alias) def _add_pydantic_model( @@ -656,6 +662,7 @@ def _build_flags(param_name: str, metadata: Opt | None) -> list[str]: if metadata.alias: extras.append(metadata.alias) extras.extend(metadata.aliases) + extras = [_normalize_opt_alias(alias) for alias in extras] shorts = [f for f in extras if f.startswith("-") and not f.startswith("--")] longs = [f for f in extras if f.startswith("--")] return [*shorts, long_flag, *longs] From 5393bbbdf5076dad2346a14ce6af07aee57993ab Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:08:00 +0100 Subject: [PATCH 4/5] Document recommended option alias style --- README.md | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 1f0d66f..e3e8004 100644 --- a/README.md +++ b/README.md @@ -299,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: ... ``` @@ -313,9 +313,26 @@ 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`. -Option aliases accept either shorthand or explicit CLI spelling: -`Opt(alias="w")` and `Opt(alias="-w")` both become `-w`; `Opt(alias="who")` -and `Opt(alias="--who")` both become `--who`. +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: @@ -326,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: From 63cb3a1fffaae03021a3c537d7f969f2af021599 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:24:14 +0100 Subject: [PATCH 5/5] Install Pydantic for integration tests --- pyproject.toml | 4 ++++ tests/test_yeetr.py | 2 +- uv.lock | 2 ++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 07db6ff..c7dabef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ 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", @@ -140,3 +141,6 @@ max-args = 10 max-positional-arguments = 10 max-locals = 20 max-attributes = 20 + +[tool.deptry.per_rule_ignores] +DEP002 = ["pydantic"] diff --git a/tests/test_yeetr.py b/tests/test_yeetr.py index b4e3998..200f9f8 100644 --- a/tests/test_yeetr.py +++ b/tests/test_yeetr.py @@ -1,6 +1,6 @@ """Tests for yeetr's signature-driven CLI runner.""" -# pylint: disable=import-outside-toplevel,missing-class-docstring,missing-function-docstring,redefined-builtin,too-many-lines +# pylint: disable=import-outside-toplevel,missing-class-docstring,missing-function-docstring,redefined-builtin,too-few-public-methods,too-many-lines import asyncio import enum diff --git a/uv.lock b/uv.lock index b66b621..8d68e90 100644 --- a/uv.lock +++ b/uv.lock @@ -1103,6 +1103,7 @@ dev = [ { name = "mkdocs-material" }, { name = "mkdocstrings", extra = ["python"] }, { name = "prek" }, + { name = "pydantic" }, { name = "pylint" }, { name = "pyright" }, { name = "pytest" }, @@ -1131,6 +1132,7 @@ dev = [ { name = "mkdocs-material", specifier = ">=9.7.6" }, { name = "mkdocstrings", extras = ["python"], specifier = ">=1.0.3" }, { name = "prek", specifier = ">=0.4.1" }, + { name = "pydantic", specifier = ">=2.13.4" }, { name = "pylint", specifier = ">=3.3.8" }, { name = "pyright", specifier = ">=1.1.380" }, { name = "pytest", specifier = ">=9.0.2" },