Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
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
- id: no-commit-to-branch
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
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,12 @@ $ 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).

## 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/).
3 changes: 2 additions & 1 deletion arcparse/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__ = [
Expand All @@ -28,4 +28,5 @@
"InvalidParser",
"InvalidArgument",
"InvalidTypehint",
"Parser",
]
53 changes: 42 additions & 11 deletions arcparse/parser.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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]:
Expand Down
2 changes: 1 addition & 1 deletion examples/basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,5 @@ class Args:
n_siblings: int = 0


if __name__ == "main":
if __name__ == "__main__":
print(vars(Args.parse()))
2 changes: 1 addition & 1 deletion examples/conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,5 @@ class Args:
results: list[bool] = option(converter=itemwise(parse_result))


if __name__ == "main":
if __name__ == "__main__":
print(vars(Args.parse()))
2 changes: 1 addition & 1 deletion examples/flag.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,5 @@ class Args:
married: bool | None


if __name__ == "main":
if __name__ == "__main__":
print(vars(Args.parse()))
2 changes: 1 addition & 1 deletion examples/inheritance.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,5 @@ class Args:
action: FooArgs | BarArgs = subparsers("foo", "bar")


if __name__ == "main":
if __name__ == "__main__":
print(vars(Args.parse()))
2 changes: 1 addition & 1 deletion examples/multiple.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
2 changes: 1 addition & 1 deletion examples/mutual_exclusion.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@ class Args:
flag: bool | None = flag(mx_group=group)


if __name__ == "main":
if __name__ == "__main__":
print(vars(Args.parse()))
27 changes: 27 additions & 0 deletions examples/override.py
Original file line number Diff line number Diff line change
@@ -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()))
2 changes: 1 addition & 1 deletion examples/subparsers.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,5 @@ class Args:
action: Action = subparsers(hi=HiAction, bye=ByeAction)


if __name__ == "main":
if __name__ == "__main__":
print(vars(Args.parse()))
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 <jakub.rozek314@gmail.com>"]
Expand Down
9 changes: 7 additions & 2 deletions tests/test_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
58 changes: 58 additions & 0 deletions tests/test_override.py
Original file line number Diff line number Diff line change
@@ -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")
Loading