├── argtyped ├── py.typed ├── __version__.py ├── __init__.py ├── attrs_arguments.py ├── custom_types.py └── arguments.py ├── codecov.yml ├── tests ├── conftest.py ├── test_attrs.py ├── test_custom_types.py ├── test_invalid_args.py ├── test_inheritance.py └── test_parsing.py ├── LICENSE ├── setup.cfg ├── pyproject.toml ├── .gitignore ├── CHANGELOG.md ├── .github └── workflows │ └── build.yml └── README.md /argtyped/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | target: 90% 6 | -------------------------------------------------------------------------------- /argtyped/__version__.py: -------------------------------------------------------------------------------- 1 | __all__ = ["VERSION"] 2 | 3 | _MAJOR = "0" 4 | _MINOR = "4" 5 | _PATCH = "0" 6 | 7 | VERSION = f"{_MAJOR}.{_MINOR}.{_PATCH}" 8 | -------------------------------------------------------------------------------- /argtyped/__init__.py: -------------------------------------------------------------------------------- 1 | from argtyped.__version__ import VERSION as __version__ 2 | from argtyped.arguments import Arguments, argument_specs 3 | from argtyped.attrs_arguments import AttrsArguments, positional_arg 4 | from argtyped.custom_types import Enum, Switch, auto 5 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import re 2 | from contextlib import contextmanager 3 | 4 | import pytest 5 | 6 | 7 | @pytest.fixture(name="catch_parse_error") 8 | def _catch_parse_error(capsys): 9 | @contextmanager 10 | def catch_parse_error(match: str = ""): 11 | try: 12 | yield 13 | except SystemExit: 14 | captured = capsys.readouterr() 15 | assert re.search(match, captured.err) is not None 16 | else: 17 | assert False, "Parse should fail" 18 | 19 | return catch_parse_error 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Zecong Hu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = argtyped 3 | version = attr: argtyped.__version__.VERSION 4 | author = Zecong Hu 5 | author_email = huzecong@gmail.com 6 | url = https://github.com/huzecong/argtyped 7 | description = Command line arguments, with types 8 | long_description = file: README.md, CHANGELOG.md, LICENSE 9 | long_description_content_type = text/markdown 10 | license = MIT License 11 | classifiers = 12 | Development Status :: 4 - Beta 13 | Environment :: Console 14 | Intended Audience :: Developers 15 | Intended Audience :: Science/Research 16 | Operating System :: OS Independent 17 | License :: OSI Approved :: MIT License 18 | Programming Language :: Python :: 3 :: Only 19 | Programming Language :: Python :: 3.7 20 | Programming Language :: Python :: 3.8 21 | Programming Language :: Python :: 3.9 22 | Programming Language :: Python :: 3.10 23 | Topic :: System :: Shells 24 | Topic :: Utilities 25 | Typing :: Typed 26 | 27 | [options] 28 | packages = find: 29 | python_requires = >= 3.6 30 | install_requires = 31 | typing-inspect >= 0.7.1 32 | 33 | [options.extras_require] 34 | dev = 35 | pylint == 2.13.9 36 | black == 22.6.0 37 | isort == 5.10.1 38 | mypy == 0.961 39 | pytest 40 | pytest-cov 41 | 42 | [options.packages.find] 43 | exclude = 44 | build/* 45 | tests/* 46 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # Packaging 2 | 3 | [build-system] 4 | requires = ["setuptools >= 59"] 5 | build-backend = "setuptools.build_meta" 6 | # Note: `setuptools >= 61` supports having project metadata in `pyproject.toml` as well, 7 | # but it dropped support for Python 3.6. To have our project buildable on Python 3.6, I 8 | # had to keep metadata in `setup.cfg`. 9 | 10 | # Tools - Pylint 11 | 12 | [tool.pylint.main] 13 | load-plugins = [ 14 | "pylint.extensions.bad_builtin", 15 | "pylint.extensions.docstyle", 16 | "pylint.extensions.set_membership", 17 | ] 18 | disable = [ 19 | "missing-module-docstring", 20 | "bad-mcs-classmethod-argument", # I prefer `mcs` over `cls` 21 | "too-few-public-methods", 22 | "too-many-branches", 23 | "too-many-locals", 24 | "too-many-statements", 25 | "docstring-first-line-empty", 26 | ] 27 | enable = [ 28 | "useless-suppression", 29 | ] 30 | 31 | [tool.pylint.basic] 32 | include-naming-hint = true 33 | typevar-rgx = "^([A-Z]|T([A-Z][a-z]*)*)$" 34 | # snake_case, but with no lower bound on name length 35 | argument-rgx = "([^\\W\\dA-Z][^\\WA-Z]*|_[^\\WA-Z]*|__[^\\WA-Z\\d_][^\\WA-Z]+__)$" 36 | variable-rgx = "([^\\W\\dA-Z][^\\WA-Z]*|_[^\\WA-Z]*|__[^\\WA-Z\\d_][^\\WA-Z]+__)$" 37 | 38 | # Tools - Linting 39 | 40 | [tool.isort] 41 | profile = "black" 42 | 43 | # Tools - Mypy 44 | 45 | [tool.mypy] 46 | warn_unused_configs = true 47 | warn_redundant_casts = true 48 | no_implicit_optional = true 49 | follow_imports = "silent" 50 | ignore_missing_imports = true 51 | mypy_path = "./" 52 | allow_redefinition = true 53 | show_error_codes = true 54 | exclude = [ 55 | "build/" 56 | ] 57 | 58 | [[tool.mypy.overrides]] 59 | module = [ 60 | "argtyped.*", 61 | ] 62 | disallow_untyped_defs = true 63 | disallow_incomplete_defs = true 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Python template 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | pip-wheel-metadata/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | *.py,cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | db.sqlite3 64 | db.sqlite3-journal 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | 76 | # PyBuilder 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | .python-version 88 | 89 | # pipenv 90 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 91 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 92 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 93 | # install all needed dependencies. 94 | #Pipfile.lock 95 | 96 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 97 | __pypackages__/ 98 | 99 | # Celery stuff 100 | celerybeat-schedule 101 | celerybeat.pid 102 | 103 | # SageMath parsed files 104 | *.sage.py 105 | 106 | # Environments 107 | .env 108 | .venv 109 | env/ 110 | venv/ 111 | ENV/ 112 | env.bak/ 113 | venv.bak/ 114 | 115 | # Spyder project settings 116 | .spyderproject 117 | .spyproject 118 | 119 | # Rope project settings 120 | .ropeproject 121 | 122 | # mkdocs documentation 123 | /site 124 | 125 | # mypy 126 | .mypy_cache/ 127 | .dmypy.json 128 | dmypy.json 129 | 130 | # Pyre type checker 131 | .pyre/ 132 | 133 | -------------------------------------------------------------------------------- /tests/test_attrs.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | import attr 4 | 5 | from argtyped.attrs_arguments import AttrsArguments, positional_arg 6 | 7 | 8 | def test_attrs_attributes(): 9 | def _convert_d(x: str) -> Optional[str]: 10 | return None if x == "NOTHING" else x 11 | 12 | @attr.s(auto_attribs=True, kw_only=True) 13 | class AttrsArgs(AttrsArguments): 14 | a: int 15 | b: Optional[float] = None 16 | c: List[str] = attr.ib() 17 | d: Optional[str] = attr.ib(converter=_convert_d) 18 | e: int = 3 19 | 20 | args = AttrsArgs.parse_args("--a 1 --c x y z --d=none".split()) 21 | assert args == AttrsArgs(a=1, c=["x", "y", "z"], d="none") 22 | 23 | 24 | def test_attrs_nullable_required(catch_parse_error): 25 | @attr.s 26 | class TestArgs(AttrsArguments): 27 | a: Optional[int] = attr.ib() 28 | 29 | with catch_parse_error(): 30 | _ = TestArgs.parse_args([]) 31 | args = TestArgs.parse_args(["--a=none"]) 32 | assert args == TestArgs(a=None) 33 | 34 | 35 | def test_attrs_positional_arguments(): 36 | @attr.s 37 | class PositionalArgs(AttrsArguments): 38 | a: str = attr.ib(metadata={"positional": True}) 39 | b: int = positional_arg(default=2) 40 | c: float = positional_arg(default=1.2) 41 | 42 | args = PositionalArgs.parse_args("x 1 0.1".split()) 43 | assert args == PositionalArgs(a="x", b=1, c=0.1) 44 | 45 | args = PositionalArgs.parse_args("x 1".split()) 46 | assert args == PositionalArgs(a="x", b=1) 47 | 48 | args = PositionalArgs.parse_args(["x"]) 49 | assert args == PositionalArgs(a="x") 50 | 51 | 52 | def test_attrs_custom_argparse_options(catch_parse_error): 53 | def _convert_c(xss: List[List[str]]) -> List[int]: 54 | # In Python 3.8+ we'd just set `action="extend"`. 55 | return [int(x) for xs in xss for x in xs] 56 | 57 | @attr.s 58 | class CustomOptionArgs(AttrsArguments): 59 | a: List[int] = positional_arg(metadata={"nargs": "+"}) 60 | b: List[int] = attr.ib() 61 | c: List[int] = attr.ib(metadata={"action": "append"}, converter=_convert_c) 62 | 63 | with catch_parse_error(): 64 | _ = CustomOptionArgs.parse_args("--b --c".split()) 65 | args = CustomOptionArgs.parse_args("1 2 --b --c".split()) 66 | assert attr.asdict(args) == {"a": [1, 2], "b": [], "c": []} 67 | 68 | with catch_parse_error(): 69 | _ = CustomOptionArgs.parse_args("1 --b 1 2 --b 3 4".split()) 70 | args = CustomOptionArgs.parse_args("1 --c 1 2 --c 3 --b 1 2 --c 4 5".split()) 71 | assert attr.asdict(args) == {"a": [1], "b": [1, 2], "c": [1, 2, 3, 4, 5]} 72 | -------------------------------------------------------------------------------- /argtyped/attrs_arguments.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from collections import OrderedDict 3 | from typing import TYPE_CHECKING, ClassVar, List, Optional, Type, TypeVar 4 | 5 | from argtyped.arguments import _NOTHING, _build_parser, _generate_argument_spec 6 | 7 | __all__ = ["positional_arg", "AttrsArguments"] 8 | 9 | if TYPE_CHECKING: 10 | import attr 11 | 12 | positional_arg = attr.ib 13 | else: 14 | 15 | def positional_arg(*args, **kwargs): 16 | """Declare an attrs attribute that will become a positional argument.""" 17 | import attr # pylint: disable=import-outside-toplevel 18 | 19 | metadata = kwargs.get("metadata", {}) 20 | kwargs["metadata"] = {**metadata, "positional": True} 21 | return attr.ib(*args, **kwargs) 22 | 23 | 24 | TArgs = TypeVar("TArgs", bound="AttrsArguments") 25 | 26 | 27 | class AttrsArguments: 28 | """ 29 | A typed version of ``argparse`` that works for ``attrs`` classes. 30 | """ 31 | 32 | __parser__: ClassVar[argparse.ArgumentParser] 33 | 34 | @classmethod 35 | def parse_args(cls: Type[TArgs], args: Optional[List[str]] = None) -> TArgs: 36 | """ 37 | Parse arguments and create an instance of this class. If ``args`` is not 38 | specified, then ``sys.argv`` is used. 39 | """ 40 | import attr # pylint: disable=import-outside-toplevel 41 | 42 | if not hasattr(cls, "__parser__"): 43 | # Build argument specs from attrs attributes. 44 | specs = OrderedDict() 45 | annotations = getattr(cls, "__annotations__", {}) 46 | for arg_name, attribute in attr.fields_dict(cls).items(): 47 | if not attribute.init: 48 | continue # skip `init=False` attributes 49 | if arg_name not in annotations: 50 | raise TypeError( 51 | f"Argument {arg_name!r} does not have type annotation" 52 | ) 53 | arg_type = annotations[arg_name] 54 | has_default = attribute.default is not attr.NOTHING 55 | spec = _generate_argument_spec(arg_name, arg_type, has_default) 56 | if attribute.metadata: 57 | spec = spec.with_options(**attribute.metadata) 58 | if attribute.converter is not None: 59 | # Do not parse values for attributes with custom converters. 60 | spec = spec._replace(parse=False) 61 | specs[arg_name] = spec 62 | parser = _build_parser(specs, cls) 63 | cls.__parser__ = parser 64 | namespace = cls.__parser__.parse_args(args) 65 | # Unfilled values will have a special sentinel value; filter them out and let 66 | # `attrs` fill in defaults. 67 | values = {k: v for k, v in vars(namespace).items() if v is not _NOTHING} 68 | return cls(**values) 69 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [0.4.0] - 2022-07-07 8 | ### Added 9 | - Support for parsing arguments based on `attrs` attributes. 10 | 11 | ### Changed 12 | - Removed `Choices` in favor of `Literal`. 13 | - Changed packaging and tool configurations to using `pyproject.toml`. 14 | - Changed certain fields in `ArgumentSpecs`; added a note that this class is internal and offers no stability 15 | guarantees. 16 | 17 | ### Fixed 18 | - Fixed a bug where arguments are not collected in the correct method resolution order (MRO). 19 | 20 | ## [0.3.1] - 2021-05-16 21 | ### Added 22 | - Support for `snake_case` (underscore style) arguments. (#6) 23 | - A `DeprecationWarning` is now shown when using `Choices` instead of `Literal`. 24 | 25 | ## [0.3.0] - 2021-01-08 26 | ### Added 27 | - Support for list arguments. (#1) 28 | - `__repr__` method for `Arguments`. (#2) 29 | - Defined arguments are now stored in a special class variable `__arguments__` in the `Arguments` subclass namespace. 30 | A utility function `argtyped.argument_specs(Args)` is provided to inspect the specifications of the arguments. 31 | 32 | ### Changed 33 | - Annotations are now parsed on class construction, and the `argparse.ArgumentParser` object is stored for the whole 34 | lifetime of the class as `__parser__`. 35 | - Exceptions thrown for invalid type annotations are changed from `ValueError`s to `TypeError`s. 36 | 37 | ### Fixed 38 | - It is now possible to override an argument with default value defined in the base class with a new argument that does 39 | not have a default. Namely, the following code is now valid (although discouraged): 40 | ```python 41 | from argtyped import Arguments, Choices 42 | 43 | class BaseArgs(Arguments): 44 | foo: int = 0 45 | 46 | class DerivedArgs(BaseArgs): 47 | foo: Choices["a", "b"] 48 | ``` 49 | - `Optional[Literal[...]]` is now correctly supported. 50 | - `Optional[Switch]` is now correctly detected (although invalid). 51 | 52 | ## [0.2.0] - 2020-06-15 53 | ### Added 54 | - Literal types: `Literal`. They act mostly the same as `Choices`. 55 | 56 | ### Changed 57 | - `Arguments` is now pickle-able. 58 | 59 | ## [0.1] - 2020-02-16 60 | ### Added 61 | - The base of everything: the `Argument` class. 62 | - Choice types: `Choices`. 63 | - Custom enum types: `Enum`. They differ from built-in `Enum` in that `auto()` produces the enum name instead of 64 | numbers. 65 | - Switch types: `Switch`. 66 | 67 | [Unreleased]: https://github.com/huzecong/argtyped/compare/v0.4.0...HEAD 68 | [0.4.0]: https://github.com/huzecong/argtyped/compare/v0.3.1...v0.4.0 69 | [0.3.1]: https://github.com/huzecong/argtyped/compare/v0.3.0...v0.3.1 70 | [0.3.0]: https://github.com/huzecong/argtyped/compare/v0.2.0...v0.3.0 71 | [0.2.0]: https://github.com/huzecong/argtyped/compare/v0.1...v0.2.0 72 | [0.1]: https://github.com/huzecong/argtyped/releases/tag/v0.1 73 | -------------------------------------------------------------------------------- /tests/test_custom_types.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from enum import Enum as BuiltinEnum 3 | from enum import auto 4 | from typing import List, Optional, Tuple, TypeVar, Union 5 | 6 | from typing_extensions import Literal 7 | 8 | from argtyped import Enum as ArgtypedEnum 9 | from argtyped.custom_types import ( 10 | is_choices, 11 | is_enum, 12 | is_list, 13 | is_optional, 14 | unwrap_choices, 15 | unwrap_list, 16 | unwrap_optional, 17 | ) 18 | 19 | T = TypeVar("T") 20 | 21 | 22 | def test_is_enum(): 23 | class A(ArgtypedEnum): 24 | foo = auto() 25 | bar = auto() 26 | 27 | class B(BuiltinEnum): 28 | foo = auto() 29 | bar = auto() 30 | 31 | assert is_enum(A) 32 | assert is_enum(B) 33 | assert not is_enum(1234) 34 | assert not is_enum(Optional[A]) 35 | 36 | 37 | def test_is_optional(): 38 | assert is_optional(Optional[int]) 39 | assert is_optional(Union[Optional[int], Optional[float]]) 40 | assert is_optional(Optional[Union[int, float]]) 41 | assert is_optional(Union[int, None]) 42 | assert is_optional(Union[int, type(None)]) 43 | assert is_optional(Optional["int"]) 44 | assert not is_optional(Optional) 45 | assert not is_optional(Union[int, float]) 46 | 47 | 48 | def test_unwrap_optional(): 49 | assert unwrap_optional(Optional[int]) is int 50 | assert unwrap_optional(Optional["int"]).__forward_arg__ == "int" 51 | literal = Literal["a", "b", "c"] 52 | assert unwrap_choices(unwrap_optional(Optional[literal])) == unwrap_choices(literal) 53 | assert unwrap_optional(Union[int, type(None)]) is int 54 | 55 | 56 | def test_is_choices(): 57 | assert is_choices(Literal["a"]) 58 | assert is_choices(Literal["a", "b", "c"]) 59 | assert not is_choices(Union[int, float, "c"]) 60 | assert not is_choices(Optional["a"]) 61 | assert not is_choices(Optional[Literal["a"]]) 62 | 63 | 64 | def test_unwrap_choices(): 65 | assert unwrap_choices(Literal["a"]) == ("a",) 66 | assert unwrap_choices(Literal["a", "b", "c"]) == ("a", "b", "c") 67 | 68 | 69 | def test_is_list(): 70 | assert is_list(List[int]) 71 | assert is_list(List[T][int]) 72 | assert is_list(List[Optional[int]]) 73 | assert is_list(List[List[int]]) 74 | assert is_list(List[Union[int, float]]) 75 | assert not is_list(Optional[List[Optional[int]]]) 76 | assert not is_list(Tuple[int, int]) 77 | if sys.version_info >= (3, 9): 78 | # PEP-585 was implemented in Python 3.9, which allows using standard types as 79 | # typing containers. 80 | assert is_list(list[int]) 81 | assert is_list(list[T][int]) 82 | assert not is_list(Optional[list[int]]) 83 | assert not is_list(dict[str, list[int]]) 84 | assert not is_list(tuple[int, int]) 85 | 86 | 87 | def test_unwrap_list(): 88 | assert unwrap_list(List[int]) == int 89 | assert unwrap_list(List[T][int]) == int 90 | assert unwrap_optional(unwrap_list(List[Optional[int]])) == int 91 | if sys.version_info >= (3, 9): 92 | assert unwrap_list(list[int]) == int 93 | assert unwrap_list(list[T][int]) == int 94 | assert unwrap_optional(unwrap_list(list[Optional[int]])) == int 95 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: [3.6, 3.7, 3.8, 3.9, "3.10"] 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Set up Python ${{ matrix.python-version }} 15 | uses: actions/setup-python@v2 16 | with: 17 | python-version: ${{ matrix.python-version }} 18 | - name: Cache pip 19 | uses: actions/cache@v2 20 | with: 21 | path: ~/.cache/pip 22 | key: ${{ runner.os }}-pip-${{ hashFiles('**/pyproject.toml') }} 23 | restore-keys: | 24 | ${{ runner.os }}-pip- 25 | - name: Install dependencies 26 | run: | 27 | pip install .[dev] 28 | - name: Linting 29 | run: | 30 | # stop the build if code doesn't conform to style guide 31 | isort --check . 32 | black --check . 33 | # stop the build if there are Python syntax errors or undefined names 34 | pylint argtyped/ 35 | - name: Typecheck 36 | run: | 37 | mypy . 38 | - name: Unit test 39 | run: | 40 | # test against local non-installed version of library 41 | python -m pytest --cov=argtyped/ --cov-report=xml 42 | - name: Codecov 43 | uses: codecov/codecov-action@v1.0.5 44 | with: 45 | token: ${{ secrets.CODECOV_TOKEN }} 46 | file: ./coverage.xml 47 | flags: unittests 48 | name: codecov-umbrella 49 | yml: ./codecov.yml 50 | fail_ci_if_error: true 51 | 52 | deploy: 53 | runs-on: ubuntu-latest 54 | needs: build 55 | if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags') 56 | strategy: 57 | matrix: 58 | python-version: [3.7] 59 | 60 | steps: 61 | - uses: actions/checkout@v2 62 | - name: Set up Python ${{ matrix.python-version }} 63 | uses: actions/setup-python@v1 64 | with: 65 | python-version: ${{ matrix.python-version }} 66 | - name: Build dists 67 | run: | 68 | pip install build wheel 69 | python -m build 70 | - name: Publish to PyPI 71 | run: | 72 | pip install --upgrade twine 73 | TWINE_USERNAME="__token__" \ 74 | TWINE_PASSWORD="${{ secrets.pypi_password }}" \ 75 | exec twine upload --skip-existing dist/* 76 | 77 | test-deploy: 78 | runs-on: ubuntu-latest 79 | needs: deploy 80 | strategy: 81 | matrix: 82 | python-version: [3.7] 83 | 84 | steps: 85 | - uses: actions/checkout@v2 86 | with: 87 | path: "repo" 88 | - name: Set up Python ${{ matrix.python-version }} 89 | uses: actions/setup-python@v2 90 | with: 91 | python-version: ${{ matrix.python-version }} 92 | - name: Install PyPI package 93 | run: | 94 | pip install argtyped 95 | - name: Unit test with PyPI package 96 | run: | 97 | pip install pytest 98 | pip install typing-extensions attrs 99 | python -m pytest repo/ 100 | -------------------------------------------------------------------------------- /tests/test_invalid_args.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional, Union 2 | 3 | import pytest 4 | from typing_extensions import Literal 5 | 6 | from argtyped.arguments import Arguments 7 | from argtyped.custom_types import Enum, Switch, auto 8 | 9 | 10 | def test_no_type_annotation(): 11 | with pytest.raises(TypeError, match=r"does not have type annotation"): 12 | 13 | class Args(Arguments): 14 | a = 1 # wrong 15 | b = 2 # wrong 16 | c: int = 3 17 | 18 | 19 | def test_non_nullable(): 20 | with pytest.raises(TypeError, match=r"not nullable"): 21 | 22 | class Args(Arguments): 23 | a: Optional[int] = None 24 | b: Optional[int] 25 | c: int = None # wrong 26 | 27 | 28 | def test_switch_not_bool(): 29 | with pytest.raises(TypeError, match=r"must have a boolean default value"): 30 | 31 | class Args(Arguments): 32 | a: Switch = True 33 | b: Switch # wrong 34 | c: Switch = 0 # wrong 35 | 36 | 37 | def test_invalid_choice(): 38 | with pytest.raises(TypeError, match=r"must be string"): 39 | 40 | class Args1(Arguments): 41 | a: Literal["1", 2] 42 | 43 | 44 | def test_invalid_list(): 45 | with pytest.raises(TypeError, match="must be of list type"): 46 | 47 | class Args1(Arguments): 48 | a: List[Optional[int]] = None 49 | 50 | 51 | def test_invalid_nesting(): 52 | with pytest.raises(TypeError, match="'List' cannot be nested inside 'List'"): 53 | 54 | class Args1(Arguments): 55 | a: List[List[int]] 56 | 57 | with pytest.raises(TypeError, match="'List' cannot be nested inside 'Optional'"): 58 | 59 | class Args2(Arguments): 60 | a: Optional[List[int]] 61 | 62 | with pytest.raises(TypeError, match="'Switch' cannot be nested inside 'List'"): 63 | 64 | class Args3(Arguments): 65 | a: List[Switch] 66 | 67 | with pytest.raises(TypeError, match="'Switch' cannot be nested inside 'Optional'"): 68 | 69 | class Args4(Arguments): 70 | a: Optional[Switch] 71 | 72 | with pytest.raises(TypeError, match="cannot be nested"): 73 | 74 | class Args5(Arguments): 75 | a: List[Optional[List[int]]] 76 | 77 | with pytest.raises(TypeError, match=r"'Union'.*not supported"): 78 | 79 | class Args6(Arguments): 80 | a: Optional[Union[int, float]] 81 | 82 | 83 | def test_invalid_bool(catch_parse_error): 84 | class Args(Arguments): 85 | a: bool 86 | 87 | _ = Args(["--a", "True"]) 88 | _ = Args(["--a", "Y"]) 89 | with catch_parse_error(r"Invalid value .* for bool"): 90 | _ = Args(["--a=nah"]) 91 | 92 | 93 | def test_invalid_type(): 94 | with pytest.raises(TypeError, match="invalid type"): 95 | 96 | class Args1(Arguments): 97 | a: 5 = 0 98 | 99 | with pytest.raises(TypeError, match=r"forward reference.*not.*supported"): 100 | 101 | class Args2(Arguments): 102 | b: "str" = 1 103 | 104 | 105 | def test_invalid_enum(): 106 | class MyEnum(Enum): 107 | a = auto() 108 | b = auto() 109 | 110 | with pytest.raises(TypeError, match="invalid default value"): 111 | 112 | class Args1(Arguments): 113 | enum: MyEnum = "c" 114 | 115 | class Args(Arguments): 116 | enum: MyEnum = "b" # must be enum value, not string 117 | -------------------------------------------------------------------------------- /argtyped/custom_types.py: -------------------------------------------------------------------------------- 1 | import enum 2 | from typing import Any, List, Optional, Tuple, Type, TypeVar, Union 3 | 4 | import typing_inspect 5 | 6 | __all__ = [ 7 | "Enum", 8 | "auto", # also export auto for convenience 9 | "Switch", 10 | "is_choices", 11 | "is_enum", 12 | "is_list", 13 | "is_optional", 14 | "unwrap_choices", 15 | "unwrap_list", 16 | "unwrap_optional", 17 | ] 18 | 19 | auto = enum.auto # pylint: disable=invalid-name 20 | 21 | NoneType = type(None) 22 | T = TypeVar("T") 23 | 24 | 25 | class Enum(enum.Enum): 26 | """ 27 | A subclass of the builtin :class:`enum.Enum` class, but uses the lower-cased names 28 | as enum values when used with ``auto()``. For example:: 29 | 30 | from argtyped import Enum, auto 31 | 32 | class MyEnum(Enum): 33 | OPTION_A = auto() 34 | OPTION_B = auto() 35 | 36 | is equivalent to:: 37 | 38 | from enum import Enum 39 | 40 | class MyEnum(Enum): 41 | OPTION_A = "option_a" 42 | OPTION_B = "option_b" 43 | """ 44 | 45 | @staticmethod 46 | def _generate_next_value_( 47 | name: str, start: int, count: int, last_values: List[str] 48 | ) -> str: 49 | return name.lower() 50 | 51 | def __eq__(self, other: object) -> bool: 52 | return self.value == other or super().__eq__(other) 53 | 54 | 55 | # Switch is a type that's different but equivalent to `bool`. 56 | # It is defined as the `Union` of `bool` and a dummy type, because: 57 | # 1. `bool` cannot be sub-typed. 58 | # >> Switch = type('Switch', (bool,), {}) 59 | # 2. `Union` with a single (possibly duplicated) type is flattened into that type. 60 | # >> Switch = Union[bool] 61 | # 3. `NewType` forbids implicit casts from `bool`. 62 | # >> Switch = NewType('Switch', bool) 63 | __dummy_type__ = type( # pylint: disable=invalid-name 64 | "__dummy_type__", (), {} # names must match for pickle to work 65 | ) 66 | Switch = Union[bool, __dummy_type__] # type: ignore[valid-type] 67 | 68 | 69 | def is_choices(typ: type) -> bool: 70 | r""" 71 | Check whether a type is a choices type (:class:`Choices` or :class:`Literal`). 72 | This cannot be checked using traditional methods, since :class:`Choices` is a 73 | metaclass. 74 | """ 75 | return typing_inspect.is_literal_type(typ) 76 | 77 | 78 | def unwrap_choices(typ: type) -> Tuple[str, ...]: 79 | r""" 80 | Return the string literals associated with the choices type. Literal type in 81 | Python 3.7+ stores the literals in ``typ.__args__``, but in Python 3.6- it's in 82 | ``typ.__values__``. 83 | """ 84 | return typing_inspect.get_args(typ, evaluate=True) 85 | 86 | 87 | def is_enum(typ: Any) -> bool: 88 | r""" 89 | Check whether a type is an Enum type. Since we're using ``issubclass``, we need to 90 | check whether :arg:`typ` is a type first. 91 | """ 92 | return isinstance(typ, type) and issubclass(typ, enum.Enum) 93 | 94 | 95 | def is_optional(typ: type) -> bool: 96 | r""" 97 | Check whether a type is `Optional[T]`. `Optional` is internally implemented as 98 | `Union` with `type(None)`. 99 | """ 100 | return typing_inspect.is_optional_type(typ) 101 | 102 | 103 | def is_list(typ: type) -> bool: 104 | r"""Check whether a type if `List[T]`.""" 105 | # Note: The origin is `List` in Python 3.6, and `list` in Python 3.7+. 106 | return typing_inspect.get_origin(typ) in (list, List) 107 | 108 | 109 | def unwrap_optional(typ: Type[Optional[T]]) -> Type[T]: 110 | r"""Return the inner type inside an `Optional[T]` type.""" 111 | # Note: In Python 3.6, `get_args` returns a tuple if `evaluate` is not set to True, 112 | # due to it having a different internal representation. For compatibility, we need 113 | # to always set `evaluate` to True. 114 | remain_types = [ 115 | t for t in typing_inspect.get_args(typ, evaluate=True) if t is not NoneType 116 | ] 117 | if len(remain_types) >= 2: 118 | if set(remain_types) == set(typing_inspect.get_args(Switch, evaluate=True)): 119 | return Switch # type: ignore[return-value] 120 | raise TypeError(f"Invalid type {typ}: 'Union' types are not supported") 121 | return remain_types[0] 122 | 123 | 124 | def unwrap_list(typ: Type[List[T]]) -> Type[T]: 125 | r"""Return the inner type inside an `List[T]` type.""" 126 | return typing_inspect.get_args(typ, evaluate=True)[0] 127 | -------------------------------------------------------------------------------- /tests/test_inheritance.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from typing_extensions import Literal 4 | 5 | from argtyped.arguments import ArgumentKind, Arguments, ArgumentSpec, argument_specs 6 | from argtyped.custom_types import Enum, Switch, auto 7 | 8 | 9 | class MyEnum(Enum): 10 | A = auto() 11 | B = auto() 12 | 13 | 14 | class BaseArgs(Arguments): 15 | a: int 16 | b: Optional[bool] 17 | c: str = "abc" 18 | d: Switch = False 19 | 20 | 21 | class UnderscoreArgs(Arguments, underscore=True): 22 | underscore_arg: int 23 | underscore_switch: Switch = True 24 | 25 | 26 | class DerivedArgs(BaseArgs, UnderscoreArgs): 27 | e: float 28 | b: Literal["a", "b", "c"] = "b" # type: ignore 29 | c: MyEnum # type: ignore # override base argument w/ default 30 | 31 | 32 | class FinalArgs(DerivedArgs, underscore=True): 33 | final_arg: str 34 | 35 | 36 | def test_underscore_inheritance(): 37 | underscore_args = {"underscore_arg", "underscore_switch", "final_arg"} 38 | for name, spec in argument_specs(FinalArgs).items(): 39 | assert spec.underscore == (name in underscore_args) 40 | 41 | 42 | def test_argument_specs(): 43 | base_specs = { 44 | "a": ArgumentSpec( 45 | name="a", kind=ArgumentKind.NORMAL, nullable=False, required=True, type=int 46 | ), 47 | "b": ArgumentSpec( 48 | name="b", 49 | kind=ArgumentKind.NORMAL, 50 | nullable=True, 51 | required=False, 52 | type=bool, 53 | default=None, 54 | ), 55 | "c": ArgumentSpec( 56 | name="c", 57 | kind=ArgumentKind.NORMAL, 58 | nullable=False, 59 | required=False, 60 | type=str, 61 | default="abc", 62 | ), 63 | "d": ArgumentSpec( 64 | name="d", 65 | kind=ArgumentKind.SWITCH, 66 | nullable=False, 67 | required=False, 68 | type=bool, 69 | default=False, 70 | ), 71 | } 72 | underscore_specs = { 73 | "underscore_arg": ArgumentSpec( 74 | name="underscore_arg", 75 | kind=ArgumentKind.NORMAL, 76 | nullable=False, 77 | required=True, 78 | type=int, 79 | underscore=True, 80 | ), 81 | "underscore_switch": ArgumentSpec( 82 | name="underscore_switch", 83 | kind=ArgumentKind.SWITCH, 84 | nullable=False, 85 | required=False, 86 | type=bool, 87 | default=True, 88 | underscore=True, 89 | ), 90 | } 91 | derived_specs = { 92 | **{name: specs._replace(inherited=True) for name, specs in base_specs.items()}, 93 | **{ 94 | name: specs._replace(inherited=True) 95 | for name, specs in underscore_specs.items() 96 | }, 97 | "b": ArgumentSpec( 98 | name="b", 99 | kind=ArgumentKind.NORMAL, 100 | nullable=False, 101 | required=False, 102 | type=str, 103 | choices=("a", "b", "c"), 104 | default="b", 105 | ), 106 | "c": ArgumentSpec( 107 | name="c", 108 | kind=ArgumentKind.NORMAL, 109 | nullable=False, 110 | required=True, 111 | type=MyEnum, 112 | choices=(MyEnum.A, MyEnum.B), 113 | ), 114 | "e": ArgumentSpec( 115 | name="e", 116 | kind=ArgumentKind.NORMAL, 117 | nullable=False, 118 | required=True, 119 | type=float, 120 | ), 121 | } 122 | assert dict(argument_specs(BaseArgs)) == base_specs 123 | assert dict(argument_specs(DerivedArgs)) == derived_specs 124 | 125 | # Test parsing 126 | _ = DerivedArgs( 127 | "--a 1 --e 1.0 --c a --underscore_arg 1 --no_underscore_switch".split() 128 | ) 129 | 130 | 131 | def test_correct_resolution_order(): 132 | """ 133 | Test that attributes are collected using the correct MRO, instead of simply looping 134 | over the base classes. The wrong base class approach will incorrectly set `y`'s 135 | type to `int`. 136 | """ 137 | 138 | class A(Arguments): 139 | x: int 140 | y: int 141 | 142 | class B(A): 143 | x: float # type: ignore 144 | 145 | class C(A): 146 | y: float # type: ignore 147 | 148 | class D(B, C): 149 | pass 150 | 151 | specs = argument_specs(D) 152 | assert set(specs.keys()) == {"x", "y"} 153 | assert specs["x"].type == float 154 | assert specs["y"].type == float 155 | -------------------------------------------------------------------------------- /tests/test_parsing.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import enum 3 | import pickle 4 | from typing import List, Optional 5 | 6 | import pytest 7 | from typing_extensions import Literal 8 | 9 | from argtyped.arguments import _TYPE_CONVERSION_FN, Arguments, argument_specs 10 | from argtyped.custom_types import Enum, Switch, auto 11 | 12 | 13 | class MyLoggingLevels(Enum): 14 | Debug = auto() 15 | Info = auto() 16 | Warning = auto() 17 | Error = auto() 18 | Critical = auto() 19 | 20 | 21 | class LoggingLevels(enum.Enum): 22 | Debug = "debug" 23 | Info = "info" 24 | Warning = "warning" 25 | Error = "error" 26 | Critical = "critical" 27 | 28 | def __eq__(self, other): 29 | if isinstance(other, MyLoggingLevels): 30 | return self.value == other.value 31 | return super().__eq__(other) 32 | 33 | 34 | class MyArguments(Arguments): 35 | model_name: str 36 | hidden_size: int = 512 37 | activation: Literal["relu", "tanh", "sigmoid"] = "relu" 38 | logging_level: MyLoggingLevels = MyLoggingLevels.Info 39 | use_dropout: Switch = True 40 | dropout_prob: Optional[float] = 0.5 41 | label_smoothing: Optional[float] 42 | some_true_arg: bool 43 | some_false_arg: bool 44 | some_list_arg: List[Optional[Literal["a", "b", "c"]]] = ["a", None, "b"] 45 | 46 | 47 | CMD = r""" 48 | --model-name LSTM 49 | --activation sigmoid 50 | --logging-level=debug 51 | --no-use-dropout 52 | --dropout-prob none 53 | --label-smoothing 0.1 54 | --some-true-arg=yes 55 | --some-false-arg n 56 | --some-list-arg a c none b c 57 | """.split() 58 | 59 | RESULT = dict( 60 | model_name="LSTM", 61 | hidden_size=512, 62 | activation="sigmoid", 63 | logging_level=MyLoggingLevels.Debug, 64 | use_dropout=False, 65 | dropout_prob=None, 66 | label_smoothing=0.1, 67 | some_true_arg=True, 68 | some_false_arg=False, 69 | some_list_arg=["a", "c", None, "b", "c"], 70 | ) 71 | 72 | 73 | def test_parse(): 74 | parser = argparse.ArgumentParser() 75 | parser.add_argument("--model-name", type=str, required=True) 76 | parser.add_argument("--hidden-size", type=int, default=512) 77 | parser.add_argument( 78 | "--activation", choices=["relu", "tanh", "sigmoid"], default="relu" 79 | ) 80 | parser.add_argument( 81 | "--logging-level", 82 | choices=list(LoggingLevels), 83 | type=LoggingLevels, 84 | default="info", 85 | ) 86 | parser.add_argument( 87 | "--use-dropout", action="store_true", dest="use_dropout", default=True 88 | ) 89 | parser.add_argument("--no-use-dropout", action="store_false", dest="use_dropout") 90 | parser.add_argument( 91 | "--dropout-prob", 92 | type=lambda s: None if s.lower() == "none" else float(s), 93 | default=0.5, 94 | ) 95 | parser.add_argument( 96 | "--label-smoothing", 97 | type=lambda s: None if s.lower() == "none" else float(s), 98 | default=None, 99 | ) 100 | parser.add_argument( 101 | "--some-true-arg", type=_TYPE_CONVERSION_FN[bool], required=True 102 | ) 103 | parser.add_argument( 104 | "--some-false-arg", type=_TYPE_CONVERSION_FN[bool], required=True 105 | ) 106 | parser.add_argument( 107 | "--some-list-arg", 108 | type=lambda s: None if s.lower() == "none" else str(s), 109 | choices=["a", "b", "c", "none", None], 110 | nargs="*", 111 | default=["a", None, "b"], 112 | ) 113 | 114 | namespace = parser.parse_args(CMD) 115 | assert isinstance(namespace, argparse.Namespace) 116 | for key in RESULT: 117 | assert RESULT[key] == getattr(namespace, key) 118 | args = MyArguments(CMD) 119 | assert isinstance(args, MyArguments) 120 | for key in RESULT: 121 | assert RESULT[key] == getattr(args, key) 122 | 123 | assert dict(args.to_dict()) == RESULT 124 | 125 | 126 | def test_list_optional_literal(): 127 | class Args1(Arguments): 128 | a: List[Optional[int]] = [1, None] 129 | 130 | assert Args1("--a 1 2 none 1".split()).a == [1, 2, None, 1] 131 | 132 | class Args2(Arguments): 133 | a: Optional[Literal["a", "b"]] 134 | 135 | assert Args2("--a none".split()).a is None 136 | 137 | class Args3(Arguments): 138 | a: List[Optional[Literal["a", "b"]]] = ["a", None, "b"] 139 | 140 | assert Args3("--a a b None a b".split()).a == ["a", "b", None, "a", "b"] 141 | 142 | 143 | def test_list_optional_enum(): 144 | class MyEnum(Enum): 145 | A = auto() 146 | B = auto() 147 | 148 | class Args(Arguments): 149 | a: List[Optional[MyEnum]] = [MyEnum.A, None] 150 | 151 | assert Args("--a a b None a".split()).a == [MyEnum.A, MyEnum.B, None, MyEnum.A] 152 | 153 | 154 | def test_reject_invalid_choice_and_enum(catch_parse_error): 155 | class Args1(Arguments): 156 | a: List[Optional[Literal["a", "b"]]] 157 | 158 | _ = Args1("--a a b none".split()) 159 | with catch_parse_error("invalid choice"): 160 | _ = Args1("--a a b none c".split()) 161 | 162 | class MyEnum(Enum): 163 | A = auto() 164 | B = auto() 165 | 166 | class Args2(Arguments): 167 | a: List[Optional[MyEnum]] 168 | 169 | _ = Args2("--a a b none".split()) 170 | with catch_parse_error("not a valid"): 171 | _ = Args2("--a a b none c".split()) 172 | 173 | 174 | def test_print(): 175 | args = MyArguments(CMD) 176 | width = 50 177 | 178 | output = args.to_string(width) 179 | for line in output.strip().split("\n")[1:]: # first line is class type 180 | assert len(line) == width 181 | for key in argument_specs(args): 182 | assert key in output 183 | 184 | output = args.to_string(max_width=width) 185 | for line in output.strip().split("\n")[1:]: 186 | assert len(line) <= width 187 | 188 | print(args) # check that `__repr__` works 189 | 190 | with pytest.raises(ValueError, match=r"must be None"): 191 | args.to_string(width, width) 192 | with pytest.raises(ValueError, match=r"cannot be drawn"): 193 | invalid_width = max(map(len, argument_specs(args))) + 7 + 6 - 1 194 | args.to_string(invalid_width) 195 | 196 | 197 | def test_pickle(): 198 | args = MyArguments(CMD) 199 | args_restored = pickle.loads(pickle.dumps(args)) 200 | for key in RESULT: 201 | assert RESULT[key] == getattr(args_restored, key) 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `argtyped`: Command Line Argument Parser, with Types 2 | 3 | [![Build Status](https://github.com/huzecong/argtyped/workflows/Build/badge.svg)](https://github.com/huzecong/argtyped/actions?query=workflow%3ABuild+branch%3Amaster) 4 | [![CodeCov](https://codecov.io/gh/huzecong/argtyped/branch/master/graph/badge.svg?token=ELHfYJ2Ydq)](https://codecov.io/gh/huzecong/argtyped) 5 | [![PyPI](https://img.shields.io/pypi/v/argtyped.svg)](https://pypi.org/project/argtyped/) 6 | [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/huzecong/argtyped/blob/master/LICENSE) 7 | 8 | `argtyped` is an command line argument parser with that relies on type annotations. It is built on 9 | [`argparse`](https://docs.python.org/3/library/argparse.html), the command line argument parser library built into 10 | Python. Compared with `argparse`, this library gives you: 11 | 12 | - More concise and intuitive syntax, less boilerplate code. 13 | - Type checking and IDE auto-completion for command line arguments. 14 | - A drop-in replacement for `argparse` in most cases. 15 | 16 | Since v0.4.0, `argtyped` also supports parsing arguments defined with an [attrs](https://attrs.org/)-class. See 17 | [Attrs Support](#attrs-support-new) for more details. 18 | 19 | 20 | ## Installation 21 | 22 | Install stable release from [PyPI](https://pypi.org/project/argtyped/): 23 | ```bash 24 | pip install argtyped 25 | ``` 26 | 27 | Or, install the latest commit from GitHub: 28 | ```bash 29 | pip install git+https://github.com/huzecong/argtyped.git 30 | ``` 31 | 32 | ## Usage 33 | 34 | With `argtyped`, you can define command line arguments in a syntax similar to 35 | [`typing.NamedTuple`](https://docs.python.org/3/library/typing.html#typing.NamedTuple). The syntax is intuitive and can 36 | be illustrated with an example: 37 | ```python 38 | from typing import Optional 39 | from typing_extensions import Literal # or directly import from `typing` in Python 3.8+ 40 | 41 | from argtyped import Arguments, Switch 42 | from argtyped import Enum, auto 43 | 44 | class LoggingLevels(Enum): 45 | Debug = auto() 46 | Info = auto() 47 | Warning = auto() 48 | Error = auto() 49 | Critical = auto() 50 | 51 | class MyArguments(Arguments): 52 | model_name: str # required argument of `str` type 53 | hidden_size: int = 512 # `int` argument with default value of 512 54 | 55 | activation: Literal["relu", "tanh", "sigmoid"] = "relu" # argument with limited choices 56 | logging_level: LoggingLevels = LoggingLevels.Info # using `Enum` class as choices 57 | 58 | use_dropout: Switch = True # switch argument, enable with "--use-dropout" and disable with "--no-use-dropout" 59 | dropout_prob: Optional[float] = 0.5 # optional argument, "--dropout-prob=none" parses into `None` 60 | 61 | args = MyArguments() 62 | ``` 63 | 64 | This is equivalent to the following code with Python built-in `argparse`: 65 | ```python 66 | import argparse 67 | from enum import Enum 68 | 69 | class LoggingLevels(Enum): 70 | Debug = "debug" 71 | Info = "info" 72 | Warning = "warning" 73 | Error = "error" 74 | Critical = "critical" 75 | 76 | parser = argparse.ArgumentParser() 77 | 78 | parser.add_argument("--model-name", type=str, required=True) 79 | parser.add_argument("--hidden-size", type=int, default=512) 80 | 81 | parser.add_argument("--activation", choices=["relu", "tanh", "sigmoid"], default="relu") 82 | parser.add_argument("--logging-level", choices=list(LoggingLevels), type=LoggingLevels, default="info") 83 | 84 | parser.add_argument("--use-dropout", action="store_true", dest="use_dropout", default=True) 85 | parser.add_argument("--no-use-dropout", action="store_false", dest="use_dropout") 86 | parser.add_argument("--dropout-prob", type=lambda s: None if s.lower() == 'none' else float(s), default=0.5) 87 | 88 | args = parser.parse_args() 89 | ``` 90 | 91 | Save the code into a file named `main.py`. Suppose the following arguments are provided: 92 | ```bash 93 | python main.py \ 94 | --model-name LSTM \ 95 | --activation sigmoid \ 96 | --logging-level debug \ 97 | --no-use-dropout \ 98 | --dropout-prob none 99 | ``` 100 | Then the parsed arguments will be equivalent to the following structure returned by `argparse`: 101 | ```python 102 | argparse.Namespace( 103 | model_name="LSTM", hidden_size=512, activation="sigmoid", logging_level="debug", 104 | use_dropout=False, dropout_prob=None) 105 | ``` 106 | 107 | Arguments can also be pretty-printed: 108 | ``` 109 | >>> print(args) 110 | 111 | ╔═════════════════╤══════════════════════════════════╗ 112 | ║ Arguments │ Values ║ 113 | ╠═════════════════╪══════════════════════════════════╣ 114 | ║ model_name │ 'LSTM' ║ 115 | ║ hidden_size │ 512 ║ 116 | ║ activation │ 'sigmoid' ║ 117 | ║ logging_level │ ║ 118 | ║ use_dropout │ False ║ 119 | ║ dropout_prob │ None ║ 120 | ║ label_smoothing │ 0.1 ║ 121 | ║ some_true_arg │ True ║ 122 | ║ some_false_arg │ False ║ 123 | ╚═════════════════╧══════════════════════════════════╝ 124 | ``` 125 | It is recommended though to use the `args.to_string()` method, which gives you control of the table width. 126 | 127 | ## Attrs Support (New) 128 | 129 | The way we define the arguments is very similar to defining a [dataclass](https://docs.python.org/3/library/dataclasses.html) 130 | or an [attrs](https://attrs.org)-class, so it seems natural to just write an attrs-class, and add parsing support to it. 131 | 132 | To use `argtyped` with `attrs`, simply define an attrs-class as usual, and have it subclass `AttrsArguments`. Here's 133 | the same example above, but implemented with attrs-classes, and with some bells and whistles: 134 | ```python 135 | import attr # note: new style `attrs` syntax is also supported 136 | from argtyped import AttrsArguments 137 | 138 | def _convert_logging_level(s: str) -> LoggingLevels: 139 | # Custom conversion function that takes the raw string value from the command line. 140 | return LoggingLevels[s.lower()] 141 | 142 | @attr.s(auto_attribs=True) 143 | class MyArguments(AttrsArguments): 144 | model_name: str = attr.ib(metadata={"positional": True}) # positional argument 145 | # Or: `model_name: str = argtyped.positional_arg()`. 146 | layer_sizes: List[int] = attr.ib(metadata={"nargs": "+"}) # other metadata are treated as `argparse` options 147 | 148 | activation: Literal["relu", "tanh", "sigmoid"] = "relu" 149 | logging_level: LoggingLevels = attr.ib(default=LoggingLevels.Info, converter=_convert_logging_level) 150 | 151 | use_dropout: Switch = True 152 | dropout_prob: Optional[float] = 0.5 153 | 154 | _activation_fn: Callable[[float], float] = attr.ib(init=False) # `init=False` attributes are not parsed 155 | 156 | @dropout_prob.validator # validators still work as you would expect 157 | def _dropout_prob_validator(self, attribute, value): 158 | if not 0.0 <= value <= 1.0: 159 | raise ValueError(f"Invalid probability {value}") 160 | 161 | @_activation_fn.default 162 | def _activation_fn(self): 163 | return _ACTIVATION_FNS[self.activation] 164 | ``` 165 | 166 | A few things to note here: 167 | - You can define positional arguments by adding `"positional": True` as metadata. If this feels unnatural, you could 168 | also use `argtyped.positional_arg()`, which takes the same arguments as `attr.ib`. 169 | - You can pass additional options to `ArgumentParser.add_argument` by listing them as metadata as well. Note that 170 | these options take precedence over `argtyped`'s computed arguments, for example, sequence arguments (`List[T]`) by 171 | default uses `nargs="*"`, but you could override it with metadata. 172 | - Attributes with custom converters will not be parsed; its converter will be called with the raw string value from 173 | command line. If the attribute also has a default value, you should make sure that your converter works with both 174 | strings and the default value. 175 | - `init=False` attributes are not treated as arguments, but they can be useful for storing computed values based on 176 | arguments. 177 | - The default value logic is the same as normal attrs classes, and thus could be different from non-attrs `argtyped` 178 | classes. For example, optional arguments are not considered to have an implicit default of `None`, and no type 179 | validation is performed on default values. 180 | 181 | Here are the (current) differences between an attrs-based arguments class (`AttrsArguments`) versus the normal arguments 182 | class (`Arguments`): 183 | - `AttrsArguments` supports positional arguments and custom options via metadata. 184 | - `AttrsArguments` handles default values with attrs, so there's no validation of default value types. This also 185 | means that nullable arguments must have an explicit default value of `None`, otherwise it becomes a required 186 | argument. 187 | - `AttrsArguments` does not support `underscore=True`. 188 | - `AttrsArguments` does not have `to_dict`, `to_string` methods. 189 | - `AttrsArguments` needs to be called with the factory `parse_args` method to parse, while `Arguments` parses command 190 | line arguments on construction. 191 | 192 | 193 | ## Reference 194 | 195 | ### The `argtyped.Arguments` Class 196 | 197 | The `argtyped.Arguments` class is main class of the package, from which you should derive your custom class that holds 198 | arguments. Each argument takes the form of a class attribute, with its type annotation and an optional default value. 199 | 200 | When an instance of your custom class is initialized, the command line arguments are parsed from `sys.argv` into values 201 | with your annotated types. You can also provide the list of strings to parse by passing them as the parameter. 202 | 203 | The parsed arguments are stored in an object of your custom type. This gives you arguments that can be auto-completed 204 | by the IDE, and type-checked by a static type checker like [mypy](http://mypy-lang.org/). 205 | 206 | The following example illustrates the keypoints: 207 | ```python 208 | class MyArgs(argtyped.Arguments): 209 | # name: type [= default_val] 210 | value: int = 0 211 | 212 | args = MyArgs() # equivalent to `parser.parse_args()` 213 | args = MyArgs(["--value", "123"]) # equivalent to `parser.parse_args(["--value", "123"]) 214 | assert isinstance(args, MyArgs) 215 | ``` 216 | 217 | #### `Arguments.to_dict(self)` 218 | 219 | Convert the set of arguments to a dictionary (`OrderedDict`). 220 | 221 | #### `Arguments.to_string(self, width: Optional[int] = None, max_width: Optional[int] = None)` 222 | 223 | Represent the arguments as a table. 224 | - `width`: Width of the printed table. Defaults to `None`, which fits the table to its contents. An exception is raised 225 | when the table cannot be drawn with the given width. 226 | - `max_width`: Maximum width of the printed table. Defaults to `None`, meaning no limits. Must be `None` if `width` is 227 | not `None`. 228 | 229 | #### `argtyped.argument_specs` 230 | 231 | Return a dictionary mapping argument names to their specifications, represented as the `argtyped.ArgumentSpec` type. 232 | This is useful for programmatically accessing the list of arguments. 233 | 234 | ### Argument Types 235 | 236 | To summarize, whatever works for `argparse` works here. The following types are supported: 237 | 238 | - **Built-in types** such as `int`, `float`, `str`. 239 | - **Boolean type** `bool`. Accepted values (case-insensitive) for `True` are: `y`, `yes`, `true`, `ok`; accepted values 240 | for `False` are: `n`, `no`, `false`. 241 | - **Choice types** `Literal[...]`. A choice argument is essentially an `str` argument with limited 242 | choice of values. The ellipses can be filled with a tuple of `str`s, or an expression that evaluates to a list of 243 | `str`s: 244 | ```python 245 | from argtyped import Arguments 246 | from typing_extensions import Literal 247 | 248 | class MyArgs(Arguments): 249 | foo: Literal["debug", "info", "warning", "error"] # 4 choices 250 | 251 | # argv: ["--foo=debug"] => foo="debug" 252 | ``` 253 | This is equivalent to the `choices` keyword in `argparse.add_argument`. 254 | 255 | **Note:** The choice type was previously named `Choices`. This is deprecated in favor of the 256 | [`Literal` type](https://mypy.readthedocs.io/en/stable/literal_types.html) introduced in Python 3.8 and back-ported to 257 | 3.6 and 3.7 in the `typing_extensions` library. `Choices` was removed since version 0.4.0. 258 | - **Enum types** derived from `enum.Enum`. It is recommended to use `argtyped.Enum` which uses the instance names as 259 | values: 260 | ```python 261 | from argtyped import Enum 262 | 263 | class MyEnum(Enum): 264 | Debug = auto() # "debug" 265 | Info = auto() # "info" 266 | Warning = auto() # "warning" 267 | ``` 268 | - **Switch types** `Switch`. `Switch` arguments are like `bool` arguments, but they don't take values. Instead, a switch 269 | argument `switch` requires `--switch` to enable and `--no-switch` to disable: 270 | ```python 271 | from argtyped import Arguments, Switch 272 | 273 | class MyArgs(Arguments): 274 | switch: Switch = True 275 | bool_arg: bool = False 276 | 277 | # argv: [] => flag=True, bool_arg=False 278 | # argv: ["--switch", "--bool-arg=false"] => flag=True, bool_arg=False 279 | # argv: ["--no-switch", "--bool-arg=true"] => flag=False, bool_arg=True 280 | # argv: ["--switch=false"] => WRONG 281 | # argv: ["--no-bool-arg"] => WRONG 282 | ``` 283 | - **List types** `List[T]`, where `T` is any supported type except switch types. List arguments allow passing multiple 284 | values on the command line following the argument flag, it is equivalent to setting `nargs="*"` in `argparse`. 285 | 286 | Although there is no built-in support for other `nargs` settings such as `"+"` (one or more) or `N` (fixed number), 287 | you can add custom validation logic by overriding the `__init__` method in your `Arguments` subclass. 288 | - **Optional types** `Optional[T]`, where `T` is any supported type except list or switch types. An optional argument 289 | will be filled with `None` if no value is provided. It could also be explicitly set to `None` by using `none` as value 290 | in the command line: 291 | ```python 292 | from argtyped import Arguments 293 | from typing import Optional 294 | 295 | class MyArgs(Arguments): 296 | opt_arg: Optional[int] # implicitly defaults to `None` 297 | 298 | # argv: [] => opt_arg=None 299 | # argv: ["--opt-arg=1"] => opt_arg=1 300 | # argv: ["--opt-arg=none"] => opt_arg=None 301 | ``` 302 | - Any other type that takes a single `str` as `__init__` parameters. It is also theoretically possible to use a function 303 | that takes an `str` as input, but it's not recommended as it's not type-safe. 304 | 305 | ## Composing `Arguments` Classes 306 | 307 | You can split your arguments into separate `Arguments` classes and then compose them together by inheritance. A subclass 308 | will have the union of all arguments in its base classes. If the subclass contains an argument with the same name as an 309 | argument in a base class, then the subclass definition takes precedence. For example: 310 | 311 | ```python 312 | class BaseArgs(Arguments): 313 | a: int = 1 314 | b: Switch = True 315 | 316 | class DerivedArgs(BaseArgs): 317 | b: str 318 | 319 | # args = DerivedArgs([]) # bad; `b` is required 320 | args = DerivedArgs(["--b=1234"]) 321 | ``` 322 | 323 | **Caveat:** For simplicity, we do not completely follow the [C3 linearization algorithm]( 324 | https://en.wikipedia.org/wiki/C3_linearization) that determines the class MRO in Python. Thus, it is a bad idea to have 325 | overridden arguments in cases where there's diamond inheritance. 326 | 327 | If you don't understand the above, that's fine. Just note that generally, it's a bad idea to have too complicated 328 | inheritance relationships with overridden arguments. 329 | 330 | ## Argument Naming Styles 331 | 332 | By default `argtyped` uses `--kebab-case` (with hyphens connecting words), which is the convention for UNIX command line 333 | tools. However, many existing tools use the awkward `--snake_case` (with underscores connecting words), and sometimes 334 | consistency is preferred over aesthetics. If you want to use underscores, you can do so by setting `underscore=True` 335 | inside the parentheses where you specify base classes, like this: 336 | 337 | ```python 338 | class UnderscoreArgs(Arguments, underscore=True): 339 | underscore_arg: int 340 | underscore_switch: Switch = True 341 | 342 | args = UnderscoreArgs(["--underscore_arg", "1", "--no_underscore_switch"]) 343 | ``` 344 | 345 | The underscore settings only affect arguments defined in the class scope; (non-overridden) inherited arguments are not 346 | affects. Thus, you can mix-and-match `snake_case` and `kebab-case` arguments: 347 | 348 | ```python 349 | class MyArgs(UnderscoreArgs): 350 | kebab_arg: str 351 | 352 | class MyFinalArgs(MyArgs, underscore=True): 353 | new_underscore_arg: float 354 | 355 | args = MyArgs(["--underscore_arg", "1", "--kebab-arg", "kebab", "--new_underscore_arg", "1.0"]) 356 | ``` 357 | 358 | ## Notes 359 | 360 | - Advanced `argparse` features such as subparsers, groups, argument lists, and custom actions are not supported. 361 | - Using switch arguments may result in name clashes: if a switch argument has name `arg`, there can be no argument with 362 | the name `no_arg`. 363 | - Optional types: 364 | - `Optional` can be used with `Literal`: 365 | ```python 366 | from argtyped import Arguments 367 | from typing import Literal, Optional 368 | 369 | class MyArgs(Arguments): 370 | foo: Optional[Literal["a", "b"]] # valid 371 | bar: Literal["a", "b", "none"] # also works but is less obvious 372 | ``` 373 | - `Optional[str]` would parse a value of `"none"` (case-insensitive) into `None`. 374 | - List types: 375 | - `List[Optional[T]]` is a valid type. For example: 376 | ```python 377 | from argtyped import Arguments 378 | from typing import List, Literal, Optional 379 | 380 | class MyArgs(Arguments): 381 | foo: List[Optional[Literal["a", "b"]]] = ["a", None, "b"] # valid 382 | 383 | # argv: ["--foo", "a", "b", "none", "a", "b"] => foo=["a", "b", None, "a", "b"] 384 | ``` 385 | - List types cannot be nested inside a list or an optional type. Types such as `Optional[List[int]]` and 386 | `List[List[int]]` are not accepted. 387 | 388 | ## Under the Hood 389 | 390 | This is what happens under the hood: 391 | 1. When a subclass of `argtyped.Arguments` is constructed, type annotations and class-level attributes (i.e., the 392 | default values) are collected to form argument declarations. 393 | 2. After verifying the validity of declared arguments, `argtyped.ArgumentSpec` are created for each argument and stored 394 | within the subclass as the `__arguments__` class attribute. 395 | 3. When an instance of the subclass is initialized, if it's the first time, an instance of `argparse.ArgumentParser` is 396 | created and arguments are registered with the parser. The parser is cached in the subclass as the `__parser__` 397 | attribute. 398 | 4. The parser's `parse_args` method is invoked with either `sys.argv` or strings provided as parameters, returning 399 | parsed arguments. 400 | 5. The parsed arguments are assigned to `self` (the instance of the `Arguments` subclass being initialized). 401 | 402 | ## Todo 403 | 404 | - [ ] Support `action="append"` or `action="extend"` for `List[T]` types. 405 | - Technically this is not a problem, but there's no elegant way to configure whether this behavior is desired. 406 | - [ ] Throw (suppressible) warnings on using non-type callables as types. 407 | - [ ] Support converting an `attrs` class into `Arguments`. 408 | - [ ] Support forward references in type annotations. 409 | -------------------------------------------------------------------------------- /argtyped/arguments.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import enum 3 | import functools 4 | import shutil 5 | import sys 6 | from abc import ABCMeta 7 | from collections import OrderedDict 8 | from typing import ( 9 | Any, 10 | Callable, 11 | Dict, 12 | List, 13 | NamedTuple, 14 | NoReturn, 15 | Optional, 16 | Tuple, 17 | Type, 18 | TypeVar, 19 | Union, 20 | ) 21 | 22 | from argtyped.custom_types import ( 23 | Switch, 24 | is_choices, 25 | is_enum, 26 | is_list, 27 | is_optional, 28 | unwrap_choices, 29 | unwrap_list, 30 | unwrap_optional, 31 | ) 32 | 33 | __all__ = [ 34 | "Arguments", 35 | "ArgumentKind", 36 | "ArgumentSpec", 37 | "argument_specs", 38 | ] 39 | 40 | T = TypeVar("T") 41 | ConversionFn = Callable[[str], T] 42 | 43 | 44 | class ArgumentParser(argparse.ArgumentParser): 45 | r"""A class to override some of ``ArgumentParser``\ 's behaviors.""" 46 | 47 | def _get_value(self, action: argparse.Action, arg_string: str) -> Any: 48 | r""" 49 | The original ``_get_value`` method catches exceptions in user-defined 50 | ``type_func``\ s and ignores the error message. Here we don't do that. 51 | """ 52 | type_func = self._registry_get("type", action.type, action.type) 53 | 54 | try: 55 | result = type_func(arg_string) 56 | except (argparse.ArgumentTypeError, TypeError, ValueError) as e: 57 | message = f"value '{arg_string}', {e.__class__.__name__}: {str(e)}" 58 | raise argparse.ArgumentError(action, message) 59 | 60 | return result 61 | 62 | def error(self, message: str) -> NoReturn: 63 | r""" 64 | The original ``error`` method only prints the usage and force quits. Here we 65 | print the full help. 66 | """ 67 | self.print_help(sys.stderr) 68 | sys.stderr.write(f"{self.prog}: error: {message}\n") 69 | self.exit(2) 70 | 71 | def add_switch_argument( 72 | self, name: str, default: bool = False, underscore: bool = False 73 | ) -> None: 74 | r""" 75 | Add a "switch" argument to the parser. A switch argument with name ``"flag"`` 76 | has value ``True`` if the argument ``--flag`` exists, and ``False`` if 77 | ``--no-flag`` exists. 78 | """ 79 | assert name.startswith("--") 80 | name = name[2:] 81 | var_name = name.replace("-", "_") 82 | self.add_argument( 83 | f"--{name}", action="store_true", default=default, dest=var_name 84 | ) 85 | off_arg_name = f"--no_{name}" if underscore else f"--no-{name}" 86 | self.add_argument(off_arg_name, action="store_false", dest=var_name) 87 | 88 | 89 | def _bool_conversion_fn(s: str) -> bool: 90 | if s.lower() in {"y", "yes", "true", "ok"}: 91 | return True 92 | if s.lower() in {"n", "no", "false"}: 93 | return False 94 | raise ValueError(f"Invalid value {s!r} for bool argument") 95 | 96 | 97 | def _optional_wrapper_fn(fn: ConversionFn[T]) -> ConversionFn[Optional[T]]: 98 | @functools.wraps(fn) 99 | def wrapped(s: str) -> Optional[T]: 100 | if s.lower() == "none": 101 | return None 102 | return fn(s) 103 | 104 | return wrapped 105 | 106 | 107 | _TYPE_CONVERSION_FN: Dict[type, ConversionFn[Any]] = { 108 | bool: _bool_conversion_fn, 109 | } 110 | 111 | 112 | class ArgumentKind(enum.Enum): 113 | """ 114 | The kind of argument: 115 | 116 | - ``NORMAL``: A normal argument that takes a single value. 117 | - ``SWITCH``: A boolean switch argument that takes no values; it is set to True with 118 | ``--argument`` and False with ``--no-argument`` 119 | (``action="store_true"/"store_false"``). 120 | - ``SEQUENCE``: A sequential argument that takes multiple values (``nargs="*"``). 121 | """ 122 | 123 | NORMAL = 0 124 | SWITCH = 1 125 | SEQUENCE = 2 126 | 127 | 128 | _NOTHING = object() # sentinel 129 | 130 | 131 | class ArgumentSpec(NamedTuple): 132 | """ 133 | Internal specs of an argument. 134 | 135 | This class is internal -- there's no stability guarantees on its attributes across 136 | versions. 137 | """ 138 | 139 | name: str 140 | nullable: bool 141 | required: bool 142 | type: type 143 | kind: ArgumentKind 144 | choices: Optional[Tuple[Any, ...]] = None 145 | default: Any = _NOTHING 146 | 147 | positional: bool = False 148 | # ^ Whether the argument is a positional argument. If False, it is a keyword(-only) 149 | # argument. 150 | argparse_options: Optional[Dict[str, Any]] = None 151 | # ^ Additional arguments to pass to `ArgumentParser.add_argument`. This takes 152 | # precedence over `argtyped`'s computed options, e.g. you can set `nargs="+"` for 153 | # sequence-type arguments. 154 | parse: bool = True 155 | # ^ Whether the argument value should be parsed. If False, it is the downstream's 156 | # responsibility to parse (e.g. in `attrs`). 157 | underscore: bool = False 158 | # ^ Argument naming convention: 159 | # True for `--snake_case` args, False for `--kebab-case` (default). 160 | inherited: bool = False 161 | # ^ True if argument was defined in a base class and is not overridden in the 162 | # current class. 163 | 164 | def with_options(self, positional: bool = False, **kwargs: Any) -> "ArgumentSpec": 165 | """Return a new spec with additional ``argparse`` options.""" 166 | return self._replace( # pylint: disable=no-member 167 | positional=positional, argparse_options=kwargs or None 168 | ) 169 | 170 | def with_default(self, value: Optional[Any]) -> "ArgumentSpec": 171 | """ 172 | Return a new spec with a default value, and perform validation on the value. 173 | 174 | By default, we don't store default values on the spec. This is because default 175 | value handling could happen outside ``argtyped``, e.g. for 176 | :class:`AttrsArguments` it is handled by ``attrs``. 177 | """ 178 | 179 | def value_error(message: str) -> NoReturn: 180 | raise TypeError( 181 | f"Argument {self.name!r} has invalid default value {value!r}: {message}" 182 | ) 183 | 184 | if not self.nullable and value is None: 185 | value_error( 186 | "Argument not nullable. Change type annotation to 'Optional[...]' " 187 | "to allow values of None" 188 | ) 189 | if self.kind == ArgumentKind.SWITCH: 190 | if not isinstance(value, bool): 191 | value_error("Switch argument must have a boolean default value") 192 | if self.kind == ArgumentKind.SEQUENCE: 193 | if not isinstance(value, list): 194 | value_error("Default for list argument must be of list type") 195 | value_seq = value 196 | else: 197 | value_seq = [value] 198 | if self.choices is not None: 199 | if not all( 200 | x in self.choices # pylint: disable=unsupported-membership-test 201 | or (x is None and self.nullable) 202 | for x in value_seq 203 | ): 204 | value_error("Value must be among valid choices") 205 | 206 | return self._replace(default=value, required=False) # pylint: disable=no-member 207 | 208 | 209 | def _generate_argument_spec( 210 | arg_name: str, 211 | arg_type: Any, 212 | has_default: bool, 213 | *, 214 | underscore: bool = False, 215 | ) -> ArgumentSpec: 216 | original_type = arg_type 217 | 218 | def type_error(message: str) -> None: 219 | raise TypeError( 220 | f"Argument {arg_name!r} has invalid type {original_type!r}: {message}" 221 | ) 222 | 223 | if isinstance(arg_type, str): 224 | type_error("forward references are not yet supported") 225 | 226 | # On sequence and nullable types: 227 | # - Nested lists (e.g. `List[List[T]]`) are not supported. 228 | # - When mixing `List` and `Optional`, the only allowed variant is 229 | # `List[Optional[T]]`. Anything else (`Optional[List[T]]`, 230 | # `Optional[List[Optional[T]]]`) is invalid. 231 | sequence = is_list(arg_type) 232 | if sequence: 233 | arg_type = unwrap_list(arg_type) 234 | nullable = is_optional(arg_type) 235 | if nullable: 236 | arg_type = unwrap_optional(arg_type) 237 | if (sequence or nullable) and (arg_type is Switch or is_list(arg_type)): 238 | type_error( 239 | f"{'List' if is_list(arg_type) else 'Switch'!r} cannot be " 240 | f"nested inside {'List' if sequence else 'Optional'!r}", 241 | ) 242 | 243 | if arg_type is Switch: 244 | return ArgumentSpec( 245 | name=arg_name, 246 | kind=ArgumentKind.SWITCH, 247 | required=False, 248 | nullable=False, 249 | type=bool, 250 | underscore=underscore, 251 | ) 252 | 253 | choices = None 254 | if is_enum(arg_type) or is_choices(arg_type): 255 | if is_enum(arg_type): 256 | value_type = arg_type 257 | choices = tuple(arg_type) 258 | else: 259 | value_type = str 260 | choices = unwrap_choices(arg_type) 261 | if any(not isinstance(choice, str) for choice in choices): 262 | type_error("All choices must be strings") 263 | else: 264 | if arg_type not in _TYPE_CONVERSION_FN and not callable(arg_type): 265 | type_error("Unsupported type") 266 | value_type = arg_type 267 | return ArgumentSpec( 268 | name=arg_name, 269 | kind=ArgumentKind.SEQUENCE if sequence else ArgumentKind.NORMAL, 270 | required=not has_default, 271 | nullable=nullable, 272 | type=value_type, 273 | choices=choices, 274 | underscore=underscore, 275 | ) 276 | 277 | 278 | def _build_parser( 279 | arguments: "OrderedDict[str, ArgumentSpec]", cls: type 280 | ) -> ArgumentParser: 281 | """Create the :class:`ArgumentParser` for this :class:`Arguments` class.""" 282 | parser = ArgumentParser() 283 | for name, spec in arguments.items(): 284 | arg_name = name if spec.underscore else name.replace("_", "-") 285 | if not spec.positional: 286 | arg_name = f"--{arg_name}" 287 | if spec.kind in {ArgumentKind.NORMAL, ArgumentKind.SEQUENCE}: 288 | arg_type = spec.type 289 | kwargs: Dict[str, Any] = {} 290 | if spec.positional: 291 | if not spec.required: 292 | kwargs["nargs"] = "?" 293 | kwargs["default"] = spec.default 294 | else: 295 | if spec.required: 296 | kwargs["required"] = True 297 | else: 298 | kwargs["default"] = spec.default 299 | if spec.parse: 300 | conversion_fn = _TYPE_CONVERSION_FN.get(arg_type, arg_type) 301 | if spec.nullable: 302 | conversion_fn = _optional_wrapper_fn(conversion_fn) 303 | kwargs["type"] = conversion_fn 304 | if spec.choices is not None: 305 | if spec.nullable: 306 | kwargs["choices"] = spec.choices + (None,) 307 | else: 308 | kwargs["choices"] = spec.choices 309 | if is_enum(spec.type): 310 | # Display only the enum names in help. 311 | kwargs["metavar"] = ( 312 | "{" + ",".join(val.name for val in spec.choices) + "}" 313 | ) 314 | if spec.kind == ArgumentKind.SEQUENCE: 315 | kwargs["nargs"] = "*" 316 | if spec.argparse_options is not None: 317 | kwargs.update(spec.argparse_options) 318 | parser.add_argument(arg_name, **kwargs) 319 | else: 320 | assert spec.default is not None 321 | parser.add_switch_argument(arg_name, spec.default, spec.underscore) 322 | 323 | if cls.__module__ != "__main__": 324 | # Usually arguments are defined in the same script that is directly 325 | # run (__main__). If this is not the case, add a note in help message 326 | # indicating where the arguments are defined. 327 | parser.epilog = f"Note: Arguments defined in {cls.__module__}.{cls.__name__}" 328 | return parser 329 | 330 | 331 | class ArgumentsMeta(ABCMeta): 332 | r""" 333 | Metaclass for :class:`Arguments`. The type annotations are parsed and converted into 334 | an ``argparse.ArgumentParser`` on class creation. 335 | """ 336 | __parser__: ArgumentParser 337 | __arguments__: "OrderedDict[str, ArgumentSpec]" 338 | 339 | def __new__( # type: ignore[misc] 340 | mcs, 341 | name: str, 342 | bases: Tuple[type, ...], 343 | namespace: Dict[str, Any], 344 | **kwargs: Any, 345 | ) -> "Type[Arguments]": 346 | cls: "Type[Arguments]" = super().__new__( # type: ignore[assignment] 347 | mcs, name, bases, namespace 348 | ) 349 | 350 | root = kwargs.get("_root", False) 351 | if not root and not issubclass(cls, Arguments): 352 | raise TypeError(f"Type {cls.__name__!r} must inherit from `Arguments`") 353 | if root: 354 | cls.__arguments__ = OrderedDict() 355 | return cls 356 | 357 | arguments: "OrderedDict[str, ArgumentSpec]" = OrderedDict() 358 | for base in reversed(cls.mro()[1:]): 359 | # Use reversed order so derived classes can override base annotations. 360 | if issubclass(base, Arguments): 361 | for arg_name, spec in argument_specs(base).items(): 362 | if spec.inherited: 363 | # Skip inherited attributes -- they should have been included 364 | # already when we processed the base class, which is higher up 365 | # in the MRO. 366 | continue 367 | arguments[arg_name] = spec._replace(inherited=True) 368 | 369 | # Check if there are arguments with default values but without annotations. 370 | annotations = getattr(cls, "__annotations__", {}) 371 | for key in annotations: 372 | if key.startswith("_") and not key.startswith("__"): 373 | raise TypeError(f"Argument {key!r} must not start with an underscore") 374 | annotations = OrderedDict( 375 | [(k, v) for k, v in annotations.items() if not k.startswith("__")] 376 | ) 377 | for key, value in namespace.items(): 378 | if not key.startswith("_") and not callable(value): 379 | if key not in annotations and key not in arguments: 380 | raise TypeError(f"Argument {key!r} does not have type annotation") 381 | 382 | # Check validity of arguments and create specs. 383 | underscore = kwargs.get("underscore", False) 384 | for arg_name, arg_type in annotations.items(): 385 | has_default = arg_name in namespace 386 | spec = _generate_argument_spec( 387 | arg_name, arg_type, has_default, underscore=underscore 388 | ) 389 | if has_default: 390 | spec = spec.with_default(namespace[arg_name]) 391 | elif spec.kind == ArgumentKind.NORMAL and spec.nullable: 392 | spec = spec.with_default(None) 393 | arguments[arg_name] = spec 394 | 395 | # The parser will be lazily constructed when the `Arguments` instance is first 396 | # initialized. 397 | cls.__arguments__ = arguments 398 | return cls 399 | 400 | 401 | class Arguments(metaclass=ArgumentsMeta, _root=True): 402 | r""" 403 | A typed version of ``argparse``. It's easier to illustrate using an example: 404 | 405 | .. code-block:: python 406 | 407 | from typing import Optional 408 | 409 | from argtyped import Arguments, Choices, Switch 410 | from argtyped import Enum, auto 411 | 412 | class LoggingLevels(Enum): 413 | Debug = auto() 414 | Info = auto() 415 | Warning = auto() 416 | Error = auto() 417 | Critical = auto() 418 | 419 | class MyArguments(Arguments): 420 | model_name: str 421 | hidden_size: int = 512 422 | activation: Choices['relu', 'tanh', 'sigmoid'] = 'relu' 423 | logging_level: LoggingLevels = LoggingLevels.Info 424 | use_dropout: Switch = True 425 | dropout_prob: Optional[float] = 0.5 426 | 427 | args = Arguments() 428 | 429 | This is equivalent to the following code with Python built-in ``argparse``: 430 | 431 | .. code-block:: python 432 | 433 | import argparse 434 | 435 | parser = argparse.ArgumentParser() 436 | parser.add_argument("--model-name", type=str, required=True) 437 | parser.add_argument("--hidden-size", type=int, default=512) 438 | parser.add_argument( 439 | "--activation", choices=["relu", "tanh", "sigmoid"], default="relu" 440 | ) 441 | parser.add_argument( 442 | "--logging-level", choices=ghcc.logging.get_levels(), default="info" 443 | ) 444 | parser.add_argument( 445 | "--use-dropout", action="store_true", dest="use_dropout", default=True 446 | ) 447 | parser.add_argument( 448 | "--no-use-dropout", action="store_false", dest="use_dropout" 449 | ) 450 | parser.add_argument( 451 | "--dropout-prob", 452 | type=lambda s: None if s.lower() == 'none' else float(s), 453 | default=0.5 454 | ) 455 | 456 | args = parser.parse_args() 457 | 458 | Suppose the following arguments are provided: 459 | 460 | .. code-block:: bash 461 | 462 | python main.py \ 463 | --model-name LSTM \ 464 | --activation sigmoid \ 465 | --logging-level debug \ 466 | --no-use-dropout \ 467 | --dropout-prob none 468 | 469 | the parsed arguments will be: 470 | 471 | .. code-block:: bash 472 | 473 | Namespace(model_name="LSTM", hidden_size=512, activation="sigmoid", 474 | logging_level="debug", use_dropout=False, dropout_prob=None) 475 | 476 | :class:`Arguments` provides the following features: 477 | 478 | - More concise and intuitive syntax over ``argparse``, less boilerplate code. 479 | - Arguments take the form of type-annotated class attributes, allowing IDEs to 480 | provide autocompletion. 481 | - Drop-in replacement for ``argparse``, since internally ``argparse`` is used. 482 | 483 | **Note:** Advanced features such as subparsers, groups, argument lists, custom 484 | actions are not supported. 485 | """ 486 | 487 | def __init__(self, args: Optional[List[str]] = None): 488 | cls = self.__class__ 489 | if not hasattr(cls, "__parser__"): 490 | parser = _build_parser(cls.__arguments__, cls) 491 | 492 | cls.__parser__ = parser 493 | namespace = cls.__parser__.parse_args(args) 494 | for arg_name in argument_specs(cls): 495 | setattr(self, arg_name, getattr(namespace, arg_name)) 496 | 497 | def to_dict(self) -> "OrderedDict[str, Any]": 498 | r""" 499 | Convert the set of arguments to a dictionary. 500 | 501 | :return: An ``OrderedDict`` mapping argument names to values. 502 | """ 503 | return OrderedDict( 504 | [(key, getattr(self, key)) for key in argument_specs(self.__class__).keys()] 505 | ) 506 | 507 | def to_string( 508 | self, width: Optional[int] = None, max_width: Optional[int] = None 509 | ) -> str: 510 | r""" 511 | Represent the arguments as a table. 512 | 513 | :param width: Width of the printed table. Defaults to ``None``, which fits the 514 | table to its contents. An exception is raised when the table cannot be drawn 515 | with the given width. 516 | :param max_width: Maximum width of the printed table. Defaults to ``None``, 517 | meaning no limits. Must be ``None`` if :arg:`width` is not ``None``. 518 | """ 519 | if width is not None and max_width is not None: 520 | raise ValueError("`max_width` must be None when `width` is specified") 521 | 522 | k_col = "Arguments" 523 | v_col = "Values" 524 | arg_reprs = {k: repr(v) for k, v in self.to_dict().items()} 525 | max_key = max(len(k_col), max(len(k) for k in arg_reprs.keys())) 526 | max_val = max(len(v_col), max(len(v) for v in arg_reprs.values())) 527 | margin_col = 7 # table frame & spaces 528 | if width is not None: 529 | max_val = width - max_key - margin_col 530 | elif max_width is not None: 531 | max_val = min(max_val, max_width - max_key - margin_col) 532 | if max_val < len(v_col): 533 | raise ValueError("Table cannot be drawn under the width constraints") 534 | 535 | def get_row(k: str, v: str) -> str: 536 | if len(v) > max_val: 537 | v = v[: ((max_val - 5) // 2)] + " ... " + v[-((max_val - 4) // 2) :] 538 | assert len(v) == max_val 539 | return f"║ {k.ljust(max_key)} │ {v.ljust(max_val)} ║\n" 540 | 541 | s = repr(self.__class__) + "\n" 542 | s += f"╔═{'═' * max_key}═╤═{'═' * max_val}═╗\n" 543 | s += get_row(k_col, v_col) 544 | s += f"╠═{'═' * max_key}═╪═{'═' * max_val}═╣\n" 545 | for k, v in arg_reprs.items(): 546 | s += get_row(k, v) 547 | s += f"╚═{'═' * max_key}═╧═{'═' * max_val}═╝\n" 548 | return s 549 | 550 | def __repr__(self) -> str: 551 | columns, _ = shutil.get_terminal_size() 552 | return self.to_string(max_width=columns) 553 | 554 | 555 | def argument_specs( 556 | args_class: Union[Arguments, Type[Arguments]] 557 | ) -> "OrderedDict[str, ArgumentSpec]": 558 | r""" 559 | Return a dictionary mapping argument names to their specs (:class:`ArgumentSpec` 560 | objects). 561 | """ 562 | if isinstance(args_class, Arguments): 563 | return args_class.__class__.__arguments__ 564 | return args_class.__arguments__ 565 | --------------------------------------------------------------------------------