diff --git a/pyproject.toml b/pyproject.toml index c7b5286..24af009 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ requires = [ build-backend = "uv_build" [tool.ruff] -line-length = 120 +line-length = 79 [tool.ruff.lint] select = ["F", "B", "I"] @@ -34,4 +34,5 @@ fixable = ["I"] [dependency-groups] dev = [ "ruff>=0.15", + "ty>=0.0.27", ] diff --git a/src/argclass/__init__.py b/src/argclass/__init__.py index 367f619..ddf86f8 100644 --- a/src/argclass/__init__.py +++ b/src/argclass/__init__.py @@ -1,14 +1,24 @@ -# Copyright 2021-2025 Pavel Lutskov +# Copyright 2021-2026 Pavel Lutskov import typing from argparse import ArgumentParser -from dataclasses import MISSING, dataclass +from dataclasses import MISSING, Field, dataclass, field + +SHORTOPT = "__argclass__shortopt" +CHOICES = "__argclass__choices" +ALLOW_EMPTY = "__argclass__allow_empty" +HELP = "__argclass__help" -def make_gnu_option(name): - return f'--{name.replace("_", "-")}' +class ArgclassError(Exception): + pass -def decide_default(field_): +def _make_gnu_option(name: str): + assert name.isidentifier() + return f"--{name.replace('_', '-')}" + + +def _decide_default(field_: Field): arg_cfg = {} if field_.default != MISSING: arg_cfg["default"] = field_.default @@ -19,50 +29,53 @@ def decide_default(field_): return arg_cfg -def get_choices(field_): +def _get_choices(field_: Field): arg_cfg = {} try: - arg_cfg["choices"] = field_.metadata["choices"] + arg_cfg["choices"] = field_.metadata[CHOICES] except KeyError: pass return arg_cfg -def compute_arg_names(name, field_): - names = [make_gnu_option(name)] # long option - try: - names.append(f'-{field_.metadata["shortopt"]}') - except KeyError: - pass +def _compute_arg_names(name, field_): + names = [_make_gnu_option(name)] # long option + if SHORTOPT in field_.metadata: + if len(field_.metadata[SHORTOPT]) != 1: + raise ArgclassError( + f"Short option for field {name} must be a single character" + ) + names.append(f"-{field_.metadata[SHORTOPT]}") return names def _prepare_bool(ap: ArgumentParser, name, field_): - arg_cfg = decide_default(field_) - required = arg_cfg["required"] + arg_cfg = _decide_default(field_) + required = arg_cfg.get("required", False) bool_parser = ap.add_mutually_exclusive_group(required=required) bool_parser.add_argument( - make_gnu_option(name), action="store_true", dest=name + _make_gnu_option(name), action="store_true", dest=name ) bool_parser.add_argument( - make_gnu_option(f"no_{name}"), action="store_false", dest=name + _make_gnu_option(f"no_{name}"), action="store_false", dest=name ) if not required: - ap.set_defaults(name=arg_cfg["default"]) + ap.set_defaults(**{name: arg_cfg["default"]}) -def _prepare_list_cfg(name, field_): +def _prepare_list_cfg(name: str, field_: Field): arg_cfg = { - **decide_default(field_), - **get_choices(field_), + **_decide_default(field_), + **_get_choices(field_), } subtype = typing.get_args(field_.type) if not subtype: - arg_cfg["type"] = str - else: - arg_cfg["type"] = subtype[0] + raise ArgclassError( + f"List field {name} must have a subtype. Did you mean {name}: list[str]?" + ) + arg_cfg["type"] = subtype[0] - if field_.metadata.get("allow_empty", False): + if field_.metadata.get(ALLOW_EMPTY, False): arg_cfg["nargs"] = "*" else: arg_cfg["nargs"] = "+" @@ -71,8 +84,8 @@ def _prepare_list_cfg(name, field_): def _prepare_trivial_cfg(name, field_): arg_cfg = { - **decide_default(field_), - **get_choices(field_), + **_decide_default(field_), + **_get_choices(field_), } arg_cfg["type"] = field_.type return arg_cfg @@ -80,20 +93,20 @@ def _prepare_trivial_cfg(name, field_): def _prepare_list(ap: ArgumentParser, name, field_): arg_cfg = _prepare_list_cfg(name, field_) - arg_names = compute_arg_names(name, field_) + arg_names = _compute_arg_names(name, field_) ap.add_argument(*arg_names, **arg_cfg) def _prepare_trivial(ap: ArgumentParser, name, field_): arg_cfg = _prepare_trivial_cfg(name, field_) - arg_names = compute_arg_names(name, field_) + arg_names = _compute_arg_names(name, field_) ap.add_argument(*arg_names, **arg_cfg) -def prepare_field(ap, name, field_): +def _prepare_field(ap: ArgumentParser, name: str, field_: Field): if field_.type is bool: _prepare_bool(ap, name, field_) - elif field_.type is list or typing.get_origin(field_.type) is list: + elif typing.get_origin(field_.type) is list: _prepare_list(ap, name, field_) else: _prepare_trivial(ap, name, field_) @@ -104,9 +117,30 @@ def argclass(cls): def parse_args(cls, argv): ap = ArgumentParser() for name, field_ in cls.__dataclass_fields__.items(): - prepare_field(ap, name, field_) + _prepare_field(ap, name, field_) return cls(**vars(ap.parse_args(argv))) cls = dataclass(cls) cls.parse_args = parse_args return cls + + +def argfield( + *, + shortopt: str | None = None, + choices: list[typing.Any] | None = None, + allow_empty: bool | None = None, + **kwargs, +): + metadata = kwargs.pop("metadata", {}) + + if shortopt is not None: + metadata[SHORTOPT] = shortopt + + if choices is not None: + metadata[CHOICES] = choices + + if allow_empty is not None: + metadata[ALLOW_EMPTY] = allow_empty + + return field(**kwargs, metadata=metadata) diff --git a/test.py b/test.py index 7f98c3e..b38d8c2 100644 --- a/test.py +++ b/test.py @@ -1,7 +1,9 @@ +# type: ignore[ty:unresolved-attribute] +# type: ignore[ty:unknown-argument] import unittest -from typing import List +from enum import Enum -from argclass import argclass +from argclass import argclass, argfield class TestArgClass(unittest.TestCase): @@ -11,19 +13,7 @@ class TestArgClass(unittest.TestCase): arg1: str assert A.parse_args(["--arg1", "hello"]) == A(arg1="hello") - - def test__required_argument_missing(self): - @argclass - class A: - arg1: str - self.assertRaises(SystemExit, A.parse_args, []) - - def test__required_argument_wrong_given(self): - @argclass - class A: - arg1: str - self.assertRaises(SystemExit, A.parse_args, ["--arg2", "hello"]) def test__optional_argument_missing(self): @@ -32,33 +22,50 @@ class TestArgClass(unittest.TestCase): arg2: str = "world" assert A.parse_args([]) == A(arg2="world") - - def test__optional_argument_given(self): - @argclass - class A: - arg2: str = "world" - assert A.parse_args(["--arg2", "welt"]) == A(arg2="welt") - - def test__optional_argument_wrong_given(self): - @argclass - class A: - arg2: str = "world" - self.assertRaises(SystemExit, A.parse_args, ["--arg3", "welt"]) - def test__boolean_true(self): + def test__str_enum_choices(self): + + class MyEnum(str, Enum): + hello = "hello" + world = "world" + + @argclass + class A: + arg3: MyEnum + + assert A.parse_args(["--arg3", "hello"]) == A(arg3=MyEnum.hello) + self.assertRaises(SystemExit, A.parse_args, ["--arg3", "foo"]) + + def test__str_enum_default(self): + + class MyEnum(str, Enum): + hello = "hello" + world = "world" + + @argclass + class A: + arg3: MyEnum = MyEnum.hello + + assert A.parse_args([]) == A(arg3=MyEnum.hello) + assert A.parse_args(["--arg3", "world"]) == A(arg3=MyEnum.world) + + def test__boolean(self): @argclass class A: arg3: bool assert A.parse_args(["--arg3"]) == A(arg3=True) + assert A.parse_args(["--no-arg3"]) == A(arg3=False) - def test__boolean_false(self): + def test__boolean_default_true(self): @argclass class A: - arg3: bool + arg3: bool = True + assert A.parse_args([]) == A(arg3=True) + assert A.parse_args(["--arg3"]) == A(arg3=True) assert A.parse_args(["--no-arg3"]) == A(arg3=False) def test__int(self): @@ -67,27 +74,12 @@ class TestArgClass(unittest.TestCase): arg4: int assert A.parse_args(["--arg4", "42"]) == A(arg4=42) - - def test__int_malformed(self): - @argclass - class A: - arg4: int - self.assertRaises(SystemExit, A.parse_args, ["--arg4", "4e2"]) - def test__list(self): - @argclass - class A: - arg5: List - - assert A.parse_args(["--arg5", "hello", "world"]) == A( - arg5=["hello", "world"] - ) - def test__list_str(self): @argclass class A: - arg5: List[str] + arg5: list[str] assert A.parse_args(["--arg5", "hello", "world"]) == A( arg5=["hello", "world"] @@ -96,10 +88,39 @@ class TestArgClass(unittest.TestCase): def test__list_int(self): @argclass class A: - arg5: List[int] + arg5: list[int] assert A.parse_args(["--arg5", "23", "42"]) == A(arg5=[23, 42]) + def test__list_empty(self): + @argclass + class A: + arg5: list[str] = argfield(allow_empty=True) + + assert A.parse_args(["--arg5"]) == A(arg5=[]) + + def test__shortoptions(self): + @argclass + class A: + world: str = argfield(shortopt="w") + + assert A.parse_args(["-w", "hello"]) == A(world="hello") + + def test__choices_str_valid(self): + @argclass + class A: + world: str = argfield(choices=["hello", "goodbye"]) + + assert A.parse_args(["--world", "hello"]) == A(world="hello") + self.assertRaises(SystemExit, A.parse_args, ["--world", "foo"]) + + def test__choices_int(self): + @argclass + class A: + number: int = argfield(choices=[4, 5, 6]) + + assert A.parse_args(["--number", "5"]) == A(number=5) + if __name__ == "__main__": unittest.main() diff --git a/uv.lock b/uv.lock index 8300609..b7cc6d0 100644 --- a/uv.lock +++ b/uv.lock @@ -10,12 +10,16 @@ source = { editable = "." } [package.dev-dependencies] dev = [ { name = "ruff" }, + { name = "ty" }, ] [package.metadata] [package.metadata.requires-dev] -dev = [{ name = "ruff", specifier = ">=0.15" }] +dev = [ + { name = "ruff", specifier = ">=0.15" }, + { name = "ty", specifier = ">=0.0.27" }, +] [[package]] name = "ruff" @@ -41,3 +45,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1f/a2/ef467cb77099062317154c63f234b8a7baf7cb690b99af760c5b68b9ee7f/ruff-0.15.8-py3-none-win_amd64.whl", hash = "sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89", size = 11743893, upload-time = "2026-03-26T18:39:25.01Z" }, { url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" }, ] + +[[package]] +name = "ty" +version = "0.0.27" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/de/e5cf1f151cf52fe1189e42d03d90909d7d1354fdc0c1847cbb63a0baa3da/ty-0.0.27.tar.gz", hash = "sha256:d7a8de3421d92420b40c94fe7e7d4816037560621903964dd035cf9bd0204a73", size = 5424130, upload-time = "2026-03-31T19:07:20.806Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/20/2a9ea661758bd67f2bfd54ce9daacb5a26c56c5f8b49fbd9a43b365a8a7d/ty-0.0.27-py3-none-linux_armv6l.whl", hash = "sha256:eb14456b8611c9e8287aa9b633f4d2a0d9f3082a31796969e0b50bdda8930281", size = 10571211, upload-time = "2026-03-31T19:07:23.28Z" }, + { url = "https://files.pythonhosted.org/packages/da/b2/8887a51f705d075ddbe78ae7f0d4755ef48d0a90235f67aee289e9cee950/ty-0.0.27-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:02e662184703db7586118df611cf24a000d35dae38d950053d1dd7b6736fd2c4", size = 10427576, upload-time = "2026-03-31T19:07:15.499Z" }, + { url = "https://files.pythonhosted.org/packages/1d/c3/79d88163f508fb709ce19bc0b0a66c7c64b53d372d4caa56172c3d9b3ae8/ty-0.0.27-py3-none-macosx_11_0_arm64.whl", hash = "sha256:be5fc2899441f7f8f7ef40f9ffd006075a5ff6b06c44e8d2aa30e1b900c12f51", size = 9870359, upload-time = "2026-03-31T19:07:36.852Z" }, + { url = "https://files.pythonhosted.org/packages/dc/4d/ed1b0db0e1e46b5ed4976bbfe0d1825faf003b4e3774ef28c785ed73e4bb/ty-0.0.27-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30231e652b14742a76b64755e54bf0cb1cd4c128bcaf625222e0ca92a2094887", size = 10380488, upload-time = "2026-03-31T19:07:31.268Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f2/20372f6d510b01570028433064880adec2f8abe68bf0c4603be61a560bef/ty-0.0.27-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5a119b1168f64261b3205a37e40b5b6c4aac8fd58e4587988f4e4b22c3c79847", size = 10390248, upload-time = "2026-03-31T19:07:28.345Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/46b31a7311306be1a560f7f20fdc37b5bf718787f60626cd265d9b637554/ty-0.0.27-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e38f4e187b6975d2cbebf0f1eb1221f8f64f6e509bad14d7bb2a91afc97e4956", size = 10878479, upload-time = "2026-03-31T19:07:39.393Z" }, + { url = "https://files.pythonhosted.org/packages/42/ba/5231a2a1fb1cebe053a25de8fded95e1a30a1e77d3628a9e58487297bafc/ty-0.0.27-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a07b1a8fbb23844f6d22091275430d9ac617175f34aa99159b268193de210389", size = 11461232, upload-time = "2026-03-31T19:07:02.518Z" }, + { url = "https://files.pythonhosted.org/packages/c3/37/558abab3e1f6670493524f61280b4dfcc3219555f13889223e733381dfab/ty-0.0.27-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d3ec4033031f240836bb0337274bac5c49dde312c7c6d7575451ed719bf8ffa3", size = 11133002, upload-time = "2026-03-31T19:07:18.371Z" }, + { url = "https://files.pythonhosted.org/packages/32/38/188c14a57f52160407ce62c6abb556011718fd0bcbe1dca690529ce84c46/ty-0.0.27-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:924a8849afd500d260bf5b7296165a05b7424fbb6b19113f30f3b999d682873f", size = 10986624, upload-time = "2026-03-31T19:07:13.066Z" }, + { url = "https://files.pythonhosted.org/packages/9f/f1/667a71393f47d2cd6ba9ed07541b8df3eb63aab1f2ee658e77d91b8362fa/ty-0.0.27-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d8270026c07e7423a1b3a3fd065b46ed1478748f0662518b523b57744f3fa025", size = 10366721, upload-time = "2026-03-31T19:07:00.131Z" }, + { url = "https://files.pythonhosted.org/packages/8b/aa/8edafe41be898bda774249abc5be6edd733e53fb1777d59ea9331e38537d/ty-0.0.27-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e26e9735d3bdfd95d881111ad1cf570eab8188d8c3be36d6bcaad044d38984d8", size = 10412239, upload-time = "2026-03-31T19:07:05.297Z" }, + { url = "https://files.pythonhosted.org/packages/53/ff/8bafaed4a18d38264f46bdfc427de7ea2974cf9064e4e0bdb1b6e6c724e3/ty-0.0.27-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7c09cc9a699810609acc0090af8d0db68adaee6e60a7c3e05ab80cc954a83db7", size = 10573507, upload-time = "2026-03-31T19:06:57.064Z" }, + { url = "https://files.pythonhosted.org/packages/16/2e/63a8284a2fefd08ab56ecbad0fde7dd4b2d4045a31cf24c1d1fcd9643227/ty-0.0.27-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2d3e02853bb037221a456e034b1898aaa573e6374fbb53884e33cb7513ccb85a", size = 11090233, upload-time = "2026-03-31T19:07:34.139Z" }, + { url = "https://files.pythonhosted.org/packages/14/d3/d6fa1cafdfa2b34dbfa304fc6833af8e1669fc34e24d214fa76d2a2e5a25/ty-0.0.27-py3-none-win32.whl", hash = "sha256:34e7377f2047c14dbbb7bf5322e84114db7a5f2cb470db6bee63f8f3550cfc1e", size = 9984415, upload-time = "2026-03-31T19:07:07.98Z" }, + { url = "https://files.pythonhosted.org/packages/85/e6/dd4e27da9632b3472d5711ca49dbd3709dbd3e8c73f3af6db9c254235ca9/ty-0.0.27-py3-none-win_amd64.whl", hash = "sha256:3f7e4145aad8b815ed69b324c93b5b773eb864dda366ca16ab8693ff88ce6f36", size = 10961535, upload-time = "2026-03-31T19:07:10.566Z" }, + { url = "https://files.pythonhosted.org/packages/0e/1a/824b3496d66852ed7d5d68d9787711131552b68dce8835ce9410db32e618/ty-0.0.27-py3-none-win_arm64.whl", hash = "sha256:95bf8d01eb96bb2ba3ffc39faff19da595176448e80871a7b362f4d2de58476c", size = 10376689, upload-time = "2026-03-31T19:07:25.732Z" }, +]