Do a lot of changes

- Add ty to dev deps;
- Add some tests covering previously uncovered cases;
- Fix some bugs;
- Introduce argfield for nicer argparse interface for things that can't
  be expressed via the type system;
- Fix line length.
This commit is contained in:
2026-04-01 20:56:44 +02:00
parent 96af0718ee
commit 02ee0dab3d
4 changed files with 164 additions and 80 deletions

View File

@@ -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",
]

View File

@@ -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:
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)

113
test.py
View File

@@ -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()

30
uv.lock generated
View File

@@ -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" },
]