From a217c2ee6b13cd35d01624796c1e27e79f1e63e5 Mon Sep 17 00:00:00 2001 From: Kuba314 <35199722+Kuba314@users.noreply.github.com> Date: Mon, 23 Dec 2024 19:27:07 +0100 Subject: [PATCH 1/6] Updated PEP 649 python version note The PEP was postponed to Python 3.14. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b81c595..9fc3d1e 100644 --- a/README.md +++ b/README.md @@ -43,4 +43,4 @@ This project was inspired by [swansonk14/typed-argument-parser](https://github.c ## Known issues ### Annotations -`from __future__ import annotations` makes all annotations strings at runtime. This library relies on class variable annotations's types being actual types. `inspect.get_annotations(obj, eval_str=True)` is used to evaluate string annotations to types in order to assign converters. If an argument is annotated with a non-builtin type which is defined outside of the argument-defining class body the type can't be found which results in `NameError`s. This is avoidable either by only using custom types which have been defined in the argument-defining class body (which is restrictive), or alternatively by not using the `annotations` import which should not be necessary from python 3.13 forward thanks to [PEP 649](https://peps.python.org/pep-0649/). +`from __future__ import annotations` makes all annotations strings at runtime. This library relies on class variable annotations's types being actual types. `inspect.get_annotations(obj, eval_str=True)` is used to evaluate string annotations to types in order to assign converters. If an argument is annotated with a non-builtin type which is defined outside of the argument-defining class body the type can't be found which results in `NameError`s. This is avoidable either by only using custom types which have been defined in the argument-defining class body (which is restrictive), or alternatively by not using the `annotations` import which should not be necessary from python 3.14 forward thanks to [PEP 649](https://peps.python.org/pep-0649/). From 16f2676937256c914f9112ec91cc8c36f6cd3795 Mon Sep 17 00:00:00 2001 From: Kuba314 <35199722+Kuba314@users.noreply.github.com> Date: Mon, 23 Dec 2024 14:02:11 +0100 Subject: [PATCH 2/6] Update pre-commit hooks version --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 542f0ad..dede8b5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v2.3.0 + rev: v5.0.0 hooks: - id: end-of-file-fixer - id: trailing-whitespace From 408d41ab4db45f986d7a1d4d60b6afe4a1d2ea66 Mon Sep 17 00:00:00 2001 From: Kuba314 <35199722+Kuba314@users.noreply.github.com> Date: Mon, 23 Dec 2024 14:02:11 +0100 Subject: [PATCH 3/6] Update .pre-commit-config.yaml with ruff changes --- .pre-commit-config.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index dede8b5..24e2498 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,15 +8,15 @@ repos: args: ["--branch", "master", "--branch", "main"] - repo: local hooks: - - id: ruff - name: ruff + - id: format + name: format language: system - entry: poetry run ruff check + entry: poetry run ruff format --check --diff types: [python] - - id: isort - name: isort + - id: lint + name: lint language: system - entry: poetry run isort --check --diff + entry: poetry run ruff check --extend-select I types: [python] - id: pyright name: pyright From c38e2cd7499a7709755813c8508bf42bc536e05b Mon Sep 17 00:00:00 2001 From: Kuba314 <35199722+Kuba314@users.noreply.github.com> Date: Mon, 23 Dec 2024 19:09:38 +0100 Subject: [PATCH 4/6] Fix examples' entrypoints --- examples/basic.py | 2 +- examples/conversion.py | 2 +- examples/flag.py | 2 +- examples/inheritance.py | 2 +- examples/multiple.py | 2 +- examples/mutual_exclusion.py | 2 +- examples/subparsers.py | 2 +- tests/test_examples.py | 9 +++++++-- 8 files changed, 14 insertions(+), 9 deletions(-) diff --git a/examples/basic.py b/examples/basic.py index ac7e748..c0e0501 100644 --- a/examples/basic.py +++ b/examples/basic.py @@ -21,5 +21,5 @@ class Args: n_siblings: int = 0 -if __name__ == "main": +if __name__ == "__main__": print(vars(Args.parse())) diff --git a/examples/conversion.py b/examples/conversion.py index d6e0ce3..b76947f 100644 --- a/examples/conversion.py +++ b/examples/conversion.py @@ -42,5 +42,5 @@ class Args: results: list[bool] = option(converter=itemwise(parse_result)) -if __name__ == "main": +if __name__ == "__main__": print(vars(Args.parse())) diff --git a/examples/flag.py b/examples/flag.py index 0858f65..8b4582c 100644 --- a/examples/flag.py +++ b/examples/flag.py @@ -13,5 +13,5 @@ class Args: married: bool | None -if __name__ == "main": +if __name__ == "__main__": print(vars(Args.parse())) diff --git a/examples/inheritance.py b/examples/inheritance.py index 0c64349..a35e345 100644 --- a/examples/inheritance.py +++ b/examples/inheritance.py @@ -18,5 +18,5 @@ class Args: action: FooArgs | BarArgs = subparsers("foo", "bar") -if __name__ == "main": +if __name__ == "__main__": print(vars(Args.parse())) diff --git a/examples/multiple.py b/examples/multiple.py index d9d8139..b262089 100644 --- a/examples/multiple.py +++ b/examples/multiple.py @@ -14,5 +14,5 @@ class Args: liked_movies: list[str] = option(at_least_one=True) -if __name__ == "main": +if __name__ == "__main__": print(vars(Args.parse())) diff --git a/examples/mutual_exclusion.py b/examples/mutual_exclusion.py index f1ae898..5e0c411 100644 --- a/examples/mutual_exclusion.py +++ b/examples/mutual_exclusion.py @@ -8,5 +8,5 @@ class Args: flag: bool | None = flag(mx_group=group) -if __name__ == "main": +if __name__ == "__main__": print(vars(Args.parse())) diff --git a/examples/subparsers.py b/examples/subparsers.py index 8a68458..85918b8 100644 --- a/examples/subparsers.py +++ b/examples/subparsers.py @@ -26,5 +26,5 @@ class Args: action: Action = subparsers(hi=HiAction, bye=ByeAction) -if __name__ == "main": +if __name__ == "__main__": print(vars(Args.parse())) diff --git a/tests/test_examples.py b/tests/test_examples.py index f53fd4c..201c58c 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -4,9 +4,14 @@ import pytest -example_files = list((Path(__file__).parent.parent / "examples").iterdir()) +example_files = [path for path in (Path(__file__).parent.parent / "examples").iterdir() if path.is_file()] @pytest.mark.parametrize("path", example_files) def test_example_functional(path: Path) -> None: - subprocess.check_call(["python3", str(path)]) + try: + subprocess.run(["poetry", "run", "python3", str(path)], check=True, stderr=subprocess.PIPE) + except subprocess.CalledProcessError as e: + if b"usage:" not in e.stderr: + # process didn't exit from a parser error, reraise + raise From 923fef68f7aa2fe2e73fdb5184283f44cac66aff Mon Sep 17 00:00:00 2001 From: Kuba314 <35199722+Kuba314@users.noreply.github.com> Date: Mon, 23 Dec 2024 19:21:47 +0100 Subject: [PATCH 5/6] Enable overriding/modifying inherited arguments --- README.md | 2 +- arcparse/__init__.py | 3 ++- arcparse/parser.py | 53 ++++++++++++++++++++++++++++++-------- examples/override.py | 27 ++++++++++++++++++++ tests/test_override.py | 58 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 130 insertions(+), 13 deletions(-) create mode 100644 examples/override.py create mode 100644 tests/test_override.py diff --git a/README.md b/README.md index 9fc3d1e..ed9b83c 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ $ pip install arcparse - Type conversions - Mutually exclusive groups - Subparsers -- Parser inheritance +- Parser inheritance (with overriding) ## Credits This project was inspired by [swansonk14/typed-argument-parser](https://github.com/swansonk14/typed-argument-parser). diff --git a/arcparse/__init__.py b/arcparse/__init__.py index e525bac..aa82a58 100644 --- a/arcparse/__init__.py +++ b/arcparse/__init__.py @@ -10,7 +10,7 @@ tri_flag, ) from .converters import itemwise -from .parser import InvalidArgument, InvalidParser, InvalidTypehint, arcparser +from .parser import InvalidArgument, InvalidParser, InvalidTypehint, Parser, arcparser __all__ = [ @@ -28,4 +28,5 @@ "InvalidParser", "InvalidArgument", "InvalidTypehint", + "Parser", ] diff --git a/arcparse/parser.py b/arcparse/parser.py index 7d38d81..9378774 100644 --- a/arcparse/parser.py +++ b/arcparse/parser.py @@ -1,6 +1,6 @@ -from collections.abc import Iterator, Sequence +from collections.abc import Container, Iterator, Sequence from dataclasses import dataclass, field -from typing import Any +from typing import Any, cast import argparse import inspect @@ -51,6 +51,15 @@ def all_arguments(self) -> Iterator[tuple[str, BaseArgument]]: for subparser in self.subparsers[1].sub_parsers.values(): yield from subparser.all_arguments + @property + def shallow_names(self) -> Iterator[str]: + yield from self.arguments.keys() + for mx_group in self.mx_groups: + yield from mx_group.arguments.keys() + + if self.subparsers is not None: + yield self.subparsers[0] + def parse(self, args: Sequence[str] | None = None) -> T: ap_parser = argparse.ArgumentParser() self.apply(ap_parser) @@ -109,18 +118,36 @@ def _construct_object_with_parsed(self, parsed: dict[str, Any]) -> T: else: parsed[name] = argument.converter(value) if isinstance(value, str) else value - return _instantiate_from_dict(self.shape, parsed) + return _instantiate_from_dict(self.shape, parsed, list(self.shallow_names)) -def _instantiate_from_dict[T](cls: type[T], dict_: dict[str, Any]) -> T: +def _instantiate_from_dict[T](cls: type[T], dict_: dict[str, Any], move_names: Container[str]) -> T: + """Instantiate shape class moving only some attributes to it.""" values = {} - for _cls in cls.__mro__: - for name in inspect.get_annotations(_cls, eval_str=True).keys(): - values[name] = dict_.pop(name) + for key in list(dict_.keys()): + if key in move_names: + values[key] = dict_.pop(key) + + def instantiate_arg_proxy(cls: type[T], __dict__: dict[str, Any]) -> T: + """Instantiate cls while allowing some attribute gets and disallowing gets of BasePartialArgument values""" - obj = cls() - obj.__dict__ = values - return obj + class ArgProxy(cls): + def __getattribute__(self, name: str): + if name == "__dict__": + return __dict__ + + if name in __dict__: + return __dict__[name] + + value = super().__getattribute__(name) + if not isinstance(value, BasePartialArgument): + return value + + raise AttributeError(f"'{cls.__name__}' parser didn't define argument '{name}'", name=name, obj=self) + + return cast(T, ArgProxy()) + + return instantiate_arg_proxy(cls, values) def _reduce_tri_flags(dict_: dict[str, Any], tri_flag_names: list[str]) -> None: @@ -224,12 +251,16 @@ def _make_parser[T](shape: type[T]) -> Parser[T]: case _: subparsers = None - return Parser( + parser = Parser( shape, arguments, list(mx_groups.values()), subparsers, ) + if (post_init := getattr(shape, "__post_init__", None)) and callable(post_init): + post_init(parser) + + return parser def arcparser[T](shape: type[T]) -> Parser[T]: diff --git a/examples/override.py b/examples/override.py new file mode 100644 index 0000000..4a8790a --- /dev/null +++ b/examples/override.py @@ -0,0 +1,27 @@ +from typing import cast + +from arcparse import Parser, arcparser +from arcparse.arguments import Option + + +class BaseArgs: + foo: str + bar: int + + +@arcparser +class Args(BaseArgs): + @staticmethod + def __post_init__(parser: Parser) -> None: + # set "foo" argument's default to "foo" + cast(Option, parser.arguments["foo"]).default = "foo" + + # delete "bar" argument + del parser.arguments["bar"] + + # create new argument + parser.arguments["baz"] = Option("baz", default="baz") + + +if __name__ == "__main__": + print(vars(Args.parse())) diff --git a/tests/test_override.py b/tests/test_override.py new file mode 100644 index 0000000..26c1cae --- /dev/null +++ b/tests/test_override.py @@ -0,0 +1,58 @@ +from typing import cast + +import pytest + +from arcparse import arcparser, option +from arcparse.arguments import Option +from arcparse.parser import Parser + + +def test_override_make_optional() -> None: + class BaseArgs: + foo: str = option() + + @arcparser + class Args(BaseArgs): + @staticmethod + def __post_init__(parser: Parser) -> None: + option = cast(Option, parser.arguments["foo"]) + option.optional = True + + parsed = Args.parse("".split()) + assert parsed.foo is None + + +def test_override_add_argument() -> None: + class BaseArgs: + foo: str = option() + + @arcparser + class Args(BaseArgs): + bar: str + + @staticmethod + def __post_init__(parser: Parser) -> None: + parser.arguments["bar"] = Option("bar") + + with pytest.raises(SystemExit): + Args.parse("--foo foo".split()) + + parsed = Args.parse("--foo foo --bar bar".split()) + assert parsed.foo == "foo" and parsed.bar == "bar" + + +def test_override_remove_argument() -> None: + class BaseArgs: + foo: str = option() + + @arcparser + class Args(BaseArgs): + @staticmethod + def __post_init__(parser: Parser) -> None: + del parser.arguments["foo"] + + with pytest.raises(SystemExit): + Args.parse("--foo foo".split()) + + parsed = Args.parse("".split()) + assert not hasattr(parsed, "foo") From 886638d11d50da4a3d13dd45df98327bedff0abc Mon Sep 17 00:00:00 2001 From: Kuba314 <35199722+Kuba314@users.noreply.github.com> Date: Mon, 23 Dec 2024 19:23:47 +0100 Subject: [PATCH 6/6] Release 1.2.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b3d72f4..4be5db4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "arcparse" -version = "1.1.0" +version = "1.2.0" description = "Declare program arguments in a type-safe way" license = "MIT" authors = ["Jakub Rozek "]