├── .github └── workflows │ └── main.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── argparse_dataclass.py ├── pyproject.toml ├── requirements_dev.txt ├── tests ├── example.py ├── example_choices.py ├── test_argumentgroups.py ├── test_argumentparser.py ├── test_basic.py └── test_functional.py └── tox.ini /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | # Controls when the action will run. 4 | on: 5 | # Triggers the workflow on push or pull request events 6 | push: 7 | pull_request: 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | jobs: 13 | build: 14 | 15 | strategy: 16 | matrix: 17 | python: 18 | - "3.9" 19 | - "3.10" 20 | - "3.11" 21 | - "3.12" 22 | - "3.13" 23 | runs-on: ubuntu-latest 24 | 25 | steps: 26 | - uses: actions/checkout@v4 27 | - uses: actions/setup-python@v5 28 | with: 29 | python-version: ${{ matrix.python }} 30 | 31 | - name: Install tox 32 | run: | 33 | pip3 --version 34 | pip3 install setuptools tox 35 | 36 | - name: Run tox 37 | run: tox -e py 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | 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 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | 62 | # Flask stuff: 63 | instance/ 64 | .webassets-cache 65 | 66 | # Scrapy stuff: 67 | .scrapy 68 | 69 | # Sphinx documentation 70 | docs/_build/ 71 | 72 | # PyBuilder 73 | target/ 74 | 75 | # Jupyter Notebook 76 | .ipynb_checkpoints 77 | 78 | # IPython 79 | profile_default/ 80 | ipython_config.py 81 | 82 | # pyenv 83 | .python-version 84 | 85 | # celery beat schedule file 86 | celerybeat-schedule 87 | 88 | # SageMath parsed files 89 | *.sage.py 90 | 91 | # Environments 92 | .env 93 | .venv 94 | env/ 95 | venv/ 96 | ENV/ 97 | env.bak/ 98 | venv.bak/ 99 | 100 | # Spyder project settings 101 | .spyderproject 102 | .spyproject 103 | 104 | # Rope project settings 105 | .ropeproject 106 | 107 | # mkdocs documentation 108 | /site 109 | 110 | # mypy 111 | .mypy_cache/ 112 | .dmypy.json 113 | dmypy.json 114 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [2.1.0] - Unreleased 9 | 10 | ### Changed 11 | 12 | + Updated project structure to use `pyproject.toml` 13 | + Add `build` to `dev` dependencies 14 | 15 | ## [2.0.1] - Unreleased 16 | 17 | ### Fixed 18 | * Update `MANIFEST.in` file when building source distributions 19 | 20 | ## [2.0.0] - 2023-06-11 21 | ### Added 22 | * Support for using `metavar` in field metadata (#44) 23 | * Support for using `Literal` types as an alternative to providing `choices` in 24 | field metadata (#46) 25 | 26 | ### Fixed 27 | * `Union` type support (#42) 28 | 29 | ### Removed 30 | * Support for Python 3.6 and 3.7 (#50) 31 | 32 | ## [1.0.0] - 2023-01-21 33 | This is the 1.0 release of `argparse_dataclass`. 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Michael V. DePalatis 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 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include LICENSE 3 | include requirements_dev.txt 4 | include tox.ini 5 | include Makefile 6 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all 2 | all: 3 | @echo "Please choose a target" 4 | 5 | .PHONY: clean 6 | clean: 7 | rm -rf dist 8 | rm -rf build 9 | rm -rf .tox 10 | rm -rf *.egg-info 11 | 12 | .PHONY: readme 13 | readme: 14 | python -c "import argparse_dataclass; print(argparse_dataclass.__doc__.strip())" > README.rst 15 | 16 | .PHONY: test 17 | test: 18 | python -m doctest argparse_dataclass.py 19 | 20 | .PHONY: build 21 | build: test readme 22 | rm -rf dist build 23 | python -m build 24 | 25 | .PHONY: black 26 | black: 27 | black *.py 28 | black tests/ 29 | 30 | .PHONY: publish 31 | publish: clean readme build 32 | twine upload dist/* 33 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ``argparse_dataclass`` 2 | ====================== 3 | 4 | Declarative CLIs with ``argparse`` and ``dataclasses``. 5 | 6 | .. image:: https://github.com/mivade/argparse_dataclass/actions/workflows/main.yml/badge.svg 7 | :target: https://github.com/mivade/argparse_dataclass/actions 8 | :alt: GitHub Actions status 9 | .. image:: https://img.shields.io/pypi/v/argparse_dataclass 10 | :alt: PyPI 11 | 12 | Features 13 | -------- 14 | 15 | Features marked with a ✓ are currently implemented; features marked with a ⊘ 16 | are not yet implemented. 17 | 18 | - [✓] Positional arguments 19 | - [✓] Boolean flags 20 | - [✓] Integer, string, float, and other simple types as arguments 21 | - [✓] Default values 22 | - [✓] Arguments with a finite set of choices 23 | - [⊘] Subcommands 24 | - [⊘] Mutually exclusive groups 25 | 26 | Examples 27 | -------- 28 | Using dataclass decorator 29 | 30 | .. code-block:: pycon 31 | 32 | >>> from argparse_dataclass import dataclass 33 | >>> @dataclass 34 | ... class Options: 35 | ... x: int = 42 36 | ... y: bool = False 37 | ... 38 | >>> print(Options.parse_args(['--y'])) 39 | Options(x=42, y=True) 40 | 41 | A simple parser with flags: 42 | 43 | .. code-block:: pycon 44 | 45 | >>> from dataclasses import dataclass 46 | >>> from argparse_dataclass import ArgumentParser 47 | >>> @dataclass 48 | ... class Options: 49 | ... verbose: bool 50 | ... other_flag: bool 51 | ... 52 | >>> parser = ArgumentParser(Options) 53 | >>> print(parser.parse_args([])) 54 | Options(verbose=False, other_flag=False) 55 | >>> print(parser.parse_args(["--verbose", "--other-flag"])) 56 | Options(verbose=True, other_flag=True) 57 | 58 | Using defaults: 59 | 60 | .. code-block:: pycon 61 | 62 | >>> from dataclasses import dataclass, field 63 | >>> from argparse_dataclass import ArgumentParser 64 | >>> @dataclass 65 | ... class Options: 66 | ... x: int = 1 67 | ... y: int = field(default=2) 68 | ... z: float = field(default_factory=lambda: 3.14) 69 | ... 70 | >>> parser = ArgumentParser(Options) 71 | >>> print(parser.parse_args([])) 72 | Options(x=1, y=2, z=3.14) 73 | 74 | Enabling choices for an option: 75 | 76 | .. code-block:: pycon 77 | 78 | >>> from dataclasses import dataclass, field 79 | >>> from argparse_dataclass import ArgumentParser 80 | >>> from typing import Literal 81 | >>> @dataclass 82 | ... class Options: 83 | ... small_integer: Literal[1, 2, 3] 84 | ... 85 | >>> parser = ArgumentParser(Options) 86 | >>> print(parser.parse_args(["--small-integer", "3"])) 87 | Options(small_integer=3) 88 | 89 | Using different flag names and positional arguments: 90 | 91 | .. code-block:: pycon 92 | 93 | >>> from dataclasses import dataclass, field 94 | >>> from argparse_dataclass import ArgumentParser 95 | >>> @dataclass 96 | ... class Options: 97 | ... x: int = field(metadata=dict(args=["-x", "--long-name"])) 98 | ... positional: str = field(metadata=dict(args=["positional"])) 99 | ... 100 | >>> parser = ArgumentParser(Options) 101 | >>> print(parser.parse_args(["-x", "0", "positional"])) 102 | Options(x=0, positional='positional') 103 | >>> print(parser.parse_args(["--long-name", 0, "positional"])) 104 | Options(x=0, positional='positional') 105 | 106 | Using a custom type converter: 107 | 108 | .. code-block:: pycon 109 | 110 | >>> from dataclasses import dataclass, field 111 | >>> from argparse_dataclass import ArgumentParser 112 | >>> @dataclass 113 | ... class Options: 114 | ... name: str = field(metadata=dict(type=str.title)) 115 | ... 116 | >>> parser = ArgumentParser(Options) 117 | >>> print(parser.parse_args(["--name", "john doe"])) 118 | Options(name='John Doe') 119 | 120 | Configuring a flag to have a default value of True: 121 | 122 | .. code-block:: pycon 123 | 124 | >>> from dataclasses import dataclass, field 125 | >>> from argparse_dataclass import ArgumentParser 126 | >>> @dataclass 127 | ... class Options: 128 | ... verbose: bool = True 129 | ... logging: bool = field(default=True, metadata=dict(args=["--logging-off"])) 130 | ... 131 | >>> parser = ArgumentParser(Options) 132 | >>> print(parser.parse_args([])) 133 | Options(verbose=True, logging=True) 134 | >>> print(parser.parse_args(["--no-verbose", "--logging-off"])) 135 | Options(verbose=False, logging=False) 136 | 137 | 138 | Configuring a flag so it is required to set: 139 | 140 | .. code-block:: pycon 141 | 142 | >>> from dataclasses import dataclass, field 143 | >>> from argparse_dataclass import ArgumentParser 144 | >>> @dataclass 145 | ... class Options: 146 | ... logging: bool = field(metadata=dict(required=True)) 147 | ... 148 | >>> parser = ArgumentParser(Options) 149 | >>> print(parser.parse_args(["--logging"])) 150 | Options(logging=True) 151 | >>> print(parser.parse_args(["--no-logging"])) 152 | Options(logging=False) 153 | 154 | Parsing only the known arguments: 155 | 156 | .. code-block:: pycon 157 | 158 | >>> from dataclasses import dataclass, field 159 | >>> from argparse_dataclass import ArgumentParser 160 | >>> @dataclass 161 | ... class Options: 162 | ... name: str 163 | ... logging: bool = False 164 | ... 165 | >>> parser = ArgumentParser(Options) 166 | >>> print(parser.parse_known_args(["--name", "John", "--other-arg", "foo"])) 167 | (Options(name='John', logging=False), ['--other-arg', 'foo']) 168 | 169 | 170 | Configuring a field with the Optional generic type: 171 | 172 | .. code-block:: pycon 173 | 174 | >>> from dataclasses import dataclass, field 175 | >>> from typing import Optional 176 | >>> from argparse_dataclass import ArgumentParser 177 | >>> @dataclass 178 | ... class Options: 179 | ... name: str 180 | ... id: Optional[int] = None 181 | ... 182 | >>> parser = ArgumentParser(Options) 183 | >>> print(parser.parse_args(["--name", "John"])) 184 | Options(name='John', id=None) 185 | >>> print(parser.parse_args(["--name", "John", "--id", "1234"])) 186 | Options(name='John', id=1234) 187 | 188 | 189 | Creating argument groups by group title: 190 | 191 | .. code-block:: pycon 192 | 193 | >>> from dataclasses import dataclass, field 194 | >>> from argparse_dataclass import ArgumentParser 195 | >>> @dataclass 196 | ... class Options: 197 | ... foo: str = field(metadata=dict(group="string group")) 198 | ... bar: str = field(metadata=dict(group=dict(title="dict group", description="using a dict"))) 199 | ... baz: str = field(metadata=dict(group=("sequence group", "using a sequence"))) 200 | ... 201 | >>> parser = ArgumentParser(Options) 202 | >>> parser.print_help() 203 | usage: [-h] --foo FOO --bar BAR --baz BAZ 204 | 205 | options: 206 | -h, --help show this help message and exit 207 | 208 | string group: 209 | --foo FOO 210 | 211 | dict group: 212 | using a dict 213 | 214 | --bar BAR 215 | 216 | sequence group: 217 | using a sequence 218 | 219 | --baz BAZ 220 | 221 | Contributors 222 | ------------ 223 | 224 | * @adsharma 225 | * @asasine 226 | * @frank113 227 | * @jayvdb 228 | * @jcal-15 229 | * @mivade 230 | * @rafi-cohen 231 | 232 | License 233 | ------- 234 | 235 | MIT License 236 | 237 | Copyright (c) 2019-2023 argparse_dataclass contributors 238 | 239 | Permission is hereby granted, free of charge, to any person obtaining a copy 240 | of this software and associated documentation files (the "Software"), to deal 241 | in the Software without restriction, including without limitation the rights 242 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 243 | copies of the Software, and to permit persons to whom the Software is 244 | furnished to do so, subject to the following conditions: 245 | 246 | The above copyright notice and this permission notice shall be included in all 247 | copies or substantial portions of the Software. 248 | 249 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 250 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 251 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 252 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 253 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 254 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 255 | SOFTWARE. 256 | -------------------------------------------------------------------------------- /argparse_dataclass.py: -------------------------------------------------------------------------------- 1 | """ 2 | ``argparse_dataclass`` 3 | ====================== 4 | 5 | Declarative CLIs with ``argparse`` and ``dataclasses``. 6 | 7 | .. image:: https://github.com/mivade/argparse_dataclass/actions/workflows/main.yml/badge.svg 8 | :target: https://github.com/mivade/argparse_dataclass/actions 9 | :alt: GitHub Actions status 10 | .. image:: https://img.shields.io/pypi/v/argparse_dataclass 11 | :alt: PyPI 12 | 13 | Features 14 | -------- 15 | 16 | Features marked with a ✓ are currently implemented; features marked with a ⊘ 17 | are not yet implemented. 18 | 19 | - [✓] Positional arguments 20 | - [✓] Boolean flags 21 | - [✓] Integer, string, float, and other simple types as arguments 22 | - [✓] Default values 23 | - [✓] Arguments with a finite set of choices 24 | - [⊘] Subcommands 25 | - [⊘] Mutually exclusive groups 26 | 27 | Examples 28 | -------- 29 | Using dataclass decorator 30 | 31 | .. code-block:: pycon 32 | 33 | >>> from argparse_dataclass import dataclass 34 | >>> @dataclass 35 | ... class Options: 36 | ... x: int = 42 37 | ... y: bool = False 38 | ... 39 | >>> print(Options.parse_args(['--y'])) 40 | Options(x=42, y=True) 41 | 42 | A simple parser with flags: 43 | 44 | .. code-block:: pycon 45 | 46 | >>> from dataclasses import dataclass 47 | >>> from argparse_dataclass import ArgumentParser 48 | >>> @dataclass 49 | ... class Options: 50 | ... verbose: bool 51 | ... other_flag: bool 52 | ... 53 | >>> parser = ArgumentParser(Options) 54 | >>> print(parser.parse_args([])) 55 | Options(verbose=False, other_flag=False) 56 | >>> print(parser.parse_args(["--verbose", "--other-flag"])) 57 | Options(verbose=True, other_flag=True) 58 | 59 | Using defaults: 60 | 61 | .. code-block:: pycon 62 | 63 | >>> from dataclasses import dataclass, field 64 | >>> from argparse_dataclass import ArgumentParser 65 | >>> @dataclass 66 | ... class Options: 67 | ... x: int = 1 68 | ... y: int = field(default=2) 69 | ... z: float = field(default_factory=lambda: 3.14) 70 | ... 71 | >>> parser = ArgumentParser(Options) 72 | >>> print(parser.parse_args([])) 73 | Options(x=1, y=2, z=3.14) 74 | 75 | Enabling choices for an option: 76 | 77 | .. code-block:: pycon 78 | 79 | >>> from dataclasses import dataclass, field 80 | >>> from argparse_dataclass import ArgumentParser 81 | >>> from typing import Literal 82 | >>> @dataclass 83 | ... class Options: 84 | ... small_integer: Literal[1, 2, 3] 85 | ... 86 | >>> parser = ArgumentParser(Options) 87 | >>> print(parser.parse_args(["--small-integer", "3"])) 88 | Options(small_integer=3) 89 | 90 | Using different flag names and positional arguments: 91 | 92 | .. code-block:: pycon 93 | 94 | >>> from dataclasses import dataclass, field 95 | >>> from argparse_dataclass import ArgumentParser 96 | >>> @dataclass 97 | ... class Options: 98 | ... x: int = field(metadata=dict(args=["-x", "--long-name"])) 99 | ... positional: str = field(metadata=dict(args=["positional"])) 100 | ... 101 | >>> parser = ArgumentParser(Options) 102 | >>> print(parser.parse_args(["-x", "0", "positional"])) 103 | Options(x=0, positional='positional') 104 | >>> print(parser.parse_args(["--long-name", 0, "positional"])) 105 | Options(x=0, positional='positional') 106 | 107 | Using a custom type converter: 108 | 109 | .. code-block:: pycon 110 | 111 | >>> from dataclasses import dataclass, field 112 | >>> from argparse_dataclass import ArgumentParser 113 | >>> @dataclass 114 | ... class Options: 115 | ... name: str = field(metadata=dict(type=str.title)) 116 | ... 117 | >>> parser = ArgumentParser(Options) 118 | >>> print(parser.parse_args(["--name", "john doe"])) 119 | Options(name='John Doe') 120 | 121 | Parsing a list of values: 122 | 123 | .. code-block:: pycon 124 | 125 | >>> from dataclasses import dataclass, field 126 | >>> from argparse_dataclass import ArgumentParser 127 | >>> @dataclass 128 | ... class Options: 129 | ... names: list[str] = field(metadata=dict(type=str, nargs="+")) 130 | ... 131 | >>> parser = ArgumentParser(Options) 132 | >>> print(parser.parse_args(["--names", "john", "jane"])) 133 | Options(names=['john', 'jane']) 134 | 135 | Configuring a flag to have a default value of True: 136 | 137 | .. code-block:: pycon 138 | 139 | >>> from dataclasses import dataclass, field 140 | >>> from argparse_dataclass import ArgumentParser 141 | >>> @dataclass 142 | ... class Options: 143 | ... verbose: bool = True 144 | ... logging: bool = field(default=True, metadata=dict(args=["--logging-off"])) 145 | ... 146 | >>> parser = ArgumentParser(Options) 147 | >>> print(parser.parse_args([])) 148 | Options(verbose=True, logging=True) 149 | >>> print(parser.parse_args(["--no-verbose", "--logging-off"])) 150 | Options(verbose=False, logging=False) 151 | 152 | 153 | Configuring a flag so it is required to set: 154 | 155 | .. code-block:: pycon 156 | 157 | >>> from dataclasses import dataclass, field 158 | >>> from argparse_dataclass import ArgumentParser 159 | >>> @dataclass 160 | ... class Options: 161 | ... logging: bool = field(metadata=dict(required=True)) 162 | ... 163 | >>> parser = ArgumentParser(Options) 164 | >>> print(parser.parse_args(["--logging"])) 165 | Options(logging=True) 166 | >>> print(parser.parse_args(["--no-logging"])) 167 | Options(logging=False) 168 | 169 | Parsing only the known arguments: 170 | 171 | .. code-block:: pycon 172 | 173 | >>> from dataclasses import dataclass, field 174 | >>> from argparse_dataclass import ArgumentParser 175 | >>> @dataclass 176 | ... class Options: 177 | ... name: str 178 | ... logging: bool = False 179 | ... 180 | >>> parser = ArgumentParser(Options) 181 | >>> print(parser.parse_known_args(["--name", "John", "--other-arg", "foo"])) 182 | (Options(name='John', logging=False), ['--other-arg', 'foo']) 183 | 184 | 185 | Configuring a field with the Optional generic type: 186 | 187 | .. code-block:: pycon 188 | 189 | >>> from dataclasses import dataclass, field 190 | >>> from typing import Optional 191 | >>> from argparse_dataclass import ArgumentParser 192 | >>> @dataclass 193 | ... class Options: 194 | ... name: str 195 | ... id: Optional[int] = None 196 | ... 197 | >>> parser = ArgumentParser(Options) 198 | >>> print(parser.parse_args(["--name", "John"])) 199 | Options(name='John', id=None) 200 | >>> print(parser.parse_args(["--name", "John", "--id", "1234"])) 201 | Options(name='John', id=1234) 202 | 203 | Contributors 204 | ------------ 205 | 206 | * @adsharma 207 | * @asasine 208 | * @frank113 209 | * @jayvdb 210 | * @jcal-15 211 | * @mivade 212 | * @rafi-cohen 213 | 214 | License 215 | ------- 216 | 217 | MIT License 218 | 219 | Copyright (c) 2019-2023 argparse_dataclass contributors 220 | 221 | Permission is hereby granted, free of charge, to any person obtaining a copy 222 | of this software and associated documentation files (the "Software"), to deal 223 | in the Software without restriction, including without limitation the rights 224 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 225 | copies of the Software, and to permit persons to whom the Software is 226 | furnished to do so, subject to the following conditions: 227 | 228 | The above copyright notice and this permission notice shall be included in all 229 | copies or substantial portions of the Software. 230 | 231 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 232 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 233 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 234 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 235 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 236 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 237 | SOFTWARE. 238 | 239 | """ 240 | 241 | import argparse 242 | from argparse import BooleanOptionalAction 243 | from typing import ( 244 | TypeVar, 245 | Optional, 246 | Sequence, 247 | Type, 248 | Tuple, 249 | get_origin, 250 | Literal, 251 | get_args, 252 | Union, 253 | Any, 254 | Generic, 255 | ) 256 | from dataclasses import ( 257 | Field, 258 | is_dataclass, 259 | fields, 260 | MISSING, 261 | dataclass as real_dataclass, 262 | ) 263 | from importlib.metadata import version 264 | 265 | # In Python 3.10, we can use types.NoneType 266 | NoneType = type(None) 267 | 268 | OptionsType = TypeVar("OptionsType") 269 | ArgsType = Optional[Sequence[str]] 270 | 271 | __version__ = version("argparse_dataclass") 272 | 273 | 274 | def parse_args(options_class: Type[OptionsType], args: ArgsType = None) -> OptionsType: 275 | """Parse arguments and return as the dataclass type.""" 276 | parser = argparse.ArgumentParser() 277 | _add_dataclass_options(options_class, parser) 278 | kwargs = _get_kwargs(parser.parse_args(args)) 279 | return options_class(**kwargs) 280 | 281 | 282 | def parse_known_args( 283 | options_class: Type[OptionsType], args: ArgsType = None 284 | ) -> Tuple[OptionsType, list[str]]: 285 | """Parse known arguments and return tuple containing dataclass type 286 | and list of remaining arguments. 287 | """ 288 | parser = argparse.ArgumentParser() 289 | _add_dataclass_options(options_class, parser) 290 | namespace, others = parser.parse_known_args(args=args) 291 | kwargs = _get_kwargs(namespace) 292 | return options_class(**kwargs), others 293 | 294 | 295 | def _add_dataclass_options( 296 | options_class: Type[OptionsType], parser: argparse.ArgumentParser 297 | ) -> None: 298 | if not is_dataclass(options_class): 299 | raise TypeError("cls must be a dataclass") 300 | 301 | for field in fields(options_class): 302 | args = field.metadata.get("args", [f"--{_get_arg_name(field)}"]) 303 | positional = not args[0].startswith("-") 304 | kwargs = { 305 | "type": field.metadata.get("type", field.type), 306 | "help": field.metadata.get("help", None), 307 | } 308 | 309 | if field.metadata.get("args") and not positional: 310 | # We want to ensure that we store the argument based on the 311 | # name of the field and not whatever flag name was provided 312 | kwargs["dest"] = field.name 313 | 314 | if field.metadata.get("choices") is not None: 315 | kwargs["choices"] = field.metadata["choices"] 316 | 317 | # Support Literal types as an alternative means of specifying choices. 318 | if get_origin(field.type) is Literal: 319 | # Prohibit a potential collision with the choices field 320 | if field.metadata.get("choices") is not None: 321 | raise ValueError( 322 | f"Cannot infer type of items in field: {field.name}. " 323 | "Literal type arguments should not be combined with choices in the metadata. " 324 | "Remove the redundant choices field from the metadata." 325 | ) 326 | 327 | # Get the types of the arguments of the Literal 328 | types = [type(arg) for arg in get_args(field.type)] 329 | 330 | # Make sure just a single type has been used 331 | if len(set(types)) > 1: 332 | raise ValueError( 333 | f"Cannot infer type of items in field: {field.name}. " 334 | "Literal type arguments should contain choices of a single type. " 335 | f"Instead, {len(set(types))} types where found: " 336 | + ", ".join([type_.__name__ for type_ in set(types)]) 337 | + "." 338 | ) 339 | 340 | # Overwrite the type kwarg 341 | kwargs["type"] = types[0] 342 | # Use the literal arguments as choices 343 | kwargs["choices"] = get_args(field.type) 344 | 345 | if field.metadata.get("metavar") is not None: 346 | kwargs["metavar"] = field.metadata["metavar"] 347 | 348 | if field.metadata.get("nargs") is not None: 349 | kwargs["nargs"] = field.metadata["nargs"] 350 | if field.metadata.get("type") is None: 351 | # When nargs is specified, field.type should be a list, 352 | # or something equivalent, like typing.List. 353 | # Using it would most likely result in an error, so if the user 354 | # did not specify the type of the elements within the list, we 355 | # try to infer it: 356 | try: 357 | kwargs["type"] = get_args(field.type)[0] # get_args returns a tuple 358 | except IndexError: 359 | # get_args returned an empty tuple, type cannot be inferred 360 | raise ValueError( 361 | f"Cannot infer type of items in field: {field.name}. " 362 | "Try using a parameterized type hint, or " 363 | "specifying the type explicitly using metadata['type']" 364 | ) 365 | 366 | if field.default == field.default_factory == MISSING and not positional: 367 | kwargs["required"] = True 368 | else: 369 | kwargs["default"] = MISSING 370 | 371 | if field.type is bool: 372 | _handle_bool_type(field, args, kwargs) 373 | elif get_origin(field.type) is Union: 374 | if field.metadata.get("type") is None: 375 | # Optional[X] is equivalent to Union[X, None]. 376 | f_args = get_args(field.type) 377 | if len(f_args) == 2 and NoneType in f_args: 378 | arg = next(a for a in f_args if a is not NoneType) 379 | kwargs["type"] = arg 380 | else: 381 | raise TypeError( 382 | "For Union types other than 'Optional', a custom 'type' must be specified using " 383 | "'metadata'." 384 | ) 385 | 386 | if "group" in field.metadata: 387 | _handle_argument_group(parser, field, args, kwargs) 388 | else: 389 | parser.add_argument(*args, **kwargs) 390 | 391 | 392 | def _get_kwargs(namespace: argparse.Namespace) -> dict[str, Any]: 393 | """Converts a Namespace to a dictionary containing the items that 394 | to be used as keyword arguments to the Options class. 395 | """ 396 | return {k: v for k, v in vars(namespace).items() if v != MISSING} 397 | 398 | 399 | def _handle_bool_type(field: Field, args: list, kwargs: dict): 400 | """Handles configuring the parser argument for boolean types. 401 | 402 | Different field configurations: 403 | No default value specified: action='store_true' 404 | Default value set to True : action='store_true' 405 | Default value set to False: action='store_false' 406 | Add a 'no-' prefix to the name if no custom args specified. 407 | if 'required' is specified: action=BooleanOptionalAction 408 | """ 409 | kwargs["action"] = "store_true" 410 | for key in ("type", "required"): 411 | kwargs.pop(key, None) 412 | if "default" in kwargs: 413 | if field.default is True: 414 | kwargs["action"] = "store_false" 415 | if "args" not in field.metadata: 416 | args[0] = f"--no-{_get_arg_name(field)}" 417 | kwargs["dest"] = field.name 418 | elif field.metadata.get("required") is True: 419 | kwargs["action"] = BooleanOptionalAction 420 | kwargs["required"] = True 421 | 422 | 423 | def _handle_argument_group( 424 | parser: argparse.ArgumentParser, field: Field, args: list, kwargs: dict 425 | ) -> None: 426 | """Handles adding the argument to an argument group.""" 427 | groups = {x.title: x for x in parser._action_groups} 428 | group = field.metadata.get("group") 429 | if isinstance(group, str): 430 | title = group 431 | description = None 432 | elif isinstance(group, dict): 433 | title = group.get("title") 434 | description = group.get("description") 435 | elif isinstance(group, Sequence): 436 | len_ = len(group) 437 | title = group[0] if len_ > 0 else None 438 | description = group[1] if len_ > 1 else None 439 | else: 440 | raise TypeError("'group' must be a group title, dictionary, or sequence") 441 | group = groups.get(title) 442 | if title is None or group is None: 443 | group = parser.add_argument_group(title, description) 444 | group.add_argument(*args, **kwargs) 445 | 446 | 447 | def _get_arg_name(field: Field): 448 | if field.metadata.get("keep_underscores", False): 449 | return field.name 450 | return field.name.replace("_", "-") 451 | 452 | 453 | class ArgumentParser(argparse.ArgumentParser, Generic[OptionsType]): 454 | """Command line argument parser that derives its options from a dataclass. 455 | 456 | Parameters 457 | ---------- 458 | options_class 459 | The dataclass that defines the options. 460 | args, kwargs 461 | Passed along to :class:`argparse.ArgumentParser`. 462 | 463 | """ 464 | 465 | def __init__(self, options_class: Type[OptionsType], *args, **kwargs): 466 | super().__init__(*args, **kwargs) 467 | self._options_type: Type[OptionsType] = options_class 468 | _add_dataclass_options(options_class, self) 469 | 470 | def parse_args(self, args: ArgsType = None, namespace=None) -> OptionsType: 471 | """Parse arguments and return as the dataclass type.""" 472 | if namespace is not None: 473 | raise ValueError("supplying a namespace is not allowed") 474 | kwargs = _get_kwargs(super().parse_args(args)) 475 | return self._options_type(**kwargs) 476 | 477 | def parse_known_args( 478 | self, args: ArgsType = None, namespace=None 479 | ) -> Tuple[OptionsType, list[str]]: 480 | """Parse known arguments and return tuple containing dataclass type 481 | and list of remaining arguments. 482 | """ 483 | if namespace is not None: 484 | raise ValueError("supplying a namespace is not allowed") 485 | namespace, others = super().parse_known_args(args=args) 486 | kwargs = _get_kwargs(namespace) 487 | return self._options_type(**kwargs), others 488 | 489 | 490 | def dataclass( 491 | cls=None, 492 | *, 493 | init=True, 494 | repr=True, 495 | eq=True, 496 | order=False, 497 | unsafe_hash=False, 498 | frozen=False, 499 | ): 500 | def wrap(cls): 501 | cls = real_dataclass( 502 | cls, 503 | init=init, 504 | repr=repr, 505 | eq=eq, 506 | order=order, 507 | unsafe_hash=unsafe_hash, 508 | frozen=frozen, 509 | ) 510 | cls.parse_args = staticmethod(ArgumentParser(cls).parse_args) 511 | return cls 512 | 513 | # See if we're being called as @dataclass or @dataclass(). 514 | if cls is None: 515 | # We're called with parens. 516 | return wrap 517 | 518 | # We're called as @dataclass without parens. 519 | return wrap(cls) 520 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "argparse_dataclass" 3 | version = "2.1.0" 4 | requires-python = ">=3.9" 5 | description = "Declarative CLIs with argparse and dataclasses" 6 | license = {file = "LICENSE"} 7 | authors = [ 8 | {name = "Michael V. DePalatis", email = "mike@depalatis.net"} 9 | ] 10 | readme = "README.rst" 11 | classifiers = [ 12 | "License :: OSI Approved :: MIT License", 13 | "Operating System :: OS Independent", 14 | "Programming Language :: Python :: 3", 15 | "Programming Language :: Python :: 3.9", 16 | "Programming Language :: Python :: 3.10", 17 | "Programming Language :: Python :: 3.11", 18 | "Programming Language :: Python :: 3.12", 19 | "Programming Language :: Python :: 3.13", 20 | ] 21 | 22 | [project.urls] 23 | Homepage = "https://github.com/mivade/argparse_dataclass" 24 | 25 | [project.optional-dependencies] 26 | dev = ["black", "flake8", "tox", "pytest", "build"] 27 | 28 | [build-system] 29 | requires = ["setuptools", "wheel"] 30 | build-backend = "setuptools.build_meta" 31 | 32 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | black 2 | flake8 3 | tox 4 | pytest 5 | build 6 | -------------------------------------------------------------------------------- /tests/example.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from argparse_dataclass import dataclass 4 | 5 | 6 | @dataclass 7 | class Opt: 8 | x: int = 42 9 | y: bool = False 10 | 11 | 12 | def main(): 13 | params = Opt.parse_args() 14 | print(params) 15 | 16 | 17 | if __name__ == "__main__": 18 | main() 19 | -------------------------------------------------------------------------------- /tests/example_choices.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from argparse_dataclass import dataclass 3 | from typing import Literal 4 | 5 | 6 | @dataclass 7 | class Opt: 8 | x: int = 42 9 | y: bool = False 10 | z: Literal["a", "b"] = "a" 11 | 12 | 13 | def main(): 14 | params = Opt.parse_args() 15 | print(params) 16 | 17 | 18 | if __name__ == "__main__": 19 | main() 20 | -------------------------------------------------------------------------------- /tests/test_argumentgroups.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from dataclasses import dataclass, field 3 | 4 | import unittest 5 | 6 | from argparse_dataclass import ArgumentParser 7 | 8 | 9 | class ArgumentParserGroupsTests(unittest.TestCase): 10 | def test_basic_str(self): 11 | parser = argparse.ArgumentParser() 12 | group = parser.add_argument_group("title") 13 | group.add_argument("--x", required=True, type=int) 14 | expected = parser.format_help() 15 | 16 | @dataclass 17 | class Opt: 18 | x: int = field(metadata={"group": "title"}) 19 | 20 | parser = ArgumentParser(Opt) 21 | out = parser.format_help() 22 | 23 | self.assertEqual(expected, out) 24 | 25 | def test_basic_dict(self): 26 | title = "title" 27 | 28 | parser = argparse.ArgumentParser() 29 | group = parser.add_argument_group(title) 30 | group.add_argument("--x", required=True, type=int) 31 | expected = parser.format_help() 32 | 33 | @dataclass 34 | class Opt: 35 | x: int = field(metadata={"group": {"title": title}}) 36 | 37 | parser = ArgumentParser(Opt) 38 | out = parser.format_help() 39 | 40 | self.assertEqual(expected, out) 41 | 42 | def test_basic_dict_description(self): 43 | title = "title" 44 | description = "description" 45 | 46 | parser = argparse.ArgumentParser() 47 | group = parser.add_argument_group(title, description) 48 | group.add_argument("--x", required=True, type=int) 49 | expected = parser.format_help() 50 | 51 | @dataclass 52 | class Opt: 53 | x: int = field( 54 | metadata={"group": {"title": title, "description": description}} 55 | ) 56 | 57 | parser = ArgumentParser(Opt) 58 | out = parser.format_help() 59 | 60 | self.assertEqual(expected, out) 61 | 62 | def test_basic_sequence(self): 63 | parser = argparse.ArgumentParser() 64 | group = parser.add_argument_group("group") 65 | group.add_argument("--x", required=True, type=int) 66 | expected = parser.format_help() 67 | 68 | @dataclass 69 | class Opt: 70 | x: int = field(metadata={"group": ("group")}) 71 | 72 | parser = ArgumentParser(Opt) 73 | out = parser.format_help() 74 | 75 | self.assertEqual(expected, out) 76 | 77 | def test_basic_sequence_description(self): 78 | parser = argparse.ArgumentParser() 79 | group = parser.add_argument_group("group", "description") 80 | group.add_argument("--x", required=True, type=int) 81 | expected = parser.format_help() 82 | 83 | @dataclass 84 | class Opt: 85 | x: int = field(metadata={"group": ("group", "description")}) 86 | 87 | parser = ArgumentParser(Opt) 88 | out = parser.format_help() 89 | 90 | self.assertEqual(expected, out) 91 | 92 | def test_basic_empty(self): 93 | parser = argparse.ArgumentParser() 94 | group = parser.add_argument_group() 95 | group.add_argument("--x", required=True, type=int) 96 | expected = parser.format_help() 97 | 98 | @dataclass 99 | class Opt: 100 | x: int = field(metadata={"group": ()}) 101 | 102 | parser = ArgumentParser(Opt) 103 | out = parser.format_help() 104 | 105 | self.assertEqual(expected, out) 106 | 107 | def test_multiple_arguments(self): 108 | title = "title" 109 | 110 | parser = argparse.ArgumentParser() 111 | group = parser.add_argument_group(title) 112 | group.add_argument("--x", required=True, type=int) 113 | group.add_argument("--y", required=True, type=int) 114 | expected = parser.format_help() 115 | 116 | @dataclass 117 | class Opt: 118 | x: int = field(metadata={"group": title}) 119 | y: int = field(metadata={"group": title}) 120 | 121 | parser = ArgumentParser(Opt) 122 | out = parser.format_help() 123 | 124 | self.assertEqual(expected, out) 125 | 126 | def test_multiple_groups_empty(self): 127 | parser = argparse.ArgumentParser() 128 | group_a = parser.add_argument_group() 129 | group_a.add_argument("--x", required=True, type=int) 130 | group_b = parser.add_argument_group() 131 | group_b.add_argument("--y", required=True, type=int) 132 | expected = parser.format_help() 133 | 134 | @dataclass 135 | class Opt: 136 | x: int = field(metadata={"group": ()}) 137 | y: int = field(metadata={"group": ()}) 138 | 139 | parser = ArgumentParser(Opt) 140 | out = parser.format_help() 141 | 142 | self.assertEqual(expected, out) 143 | 144 | def test_argument_groups(self): 145 | title_a = "Group A" 146 | title_b = "Group B" 147 | descr_b = "Description B" 148 | title_c = "Group C" 149 | descr_c = "Description C" 150 | 151 | parser = argparse.ArgumentParser() 152 | group_a = parser.add_argument_group(title_a) 153 | group_a.add_argument("--arga1", required=True, type=int) 154 | group_a.add_argument("--arga2", required=True, type=int) 155 | group_b = parser.add_argument_group(title_b, descr_b) 156 | group_b.add_argument("--argb", required=True, type=int) 157 | group_c = parser.add_argument_group(title_c, descr_c) 158 | group_c.add_argument("--argc", required=True, type=int) 159 | group_d = parser.add_argument_group() 160 | group_d.add_argument("--argd", required=True, type=int) 161 | group_e = parser.add_argument_group() 162 | group_e.add_argument("--arge", required=True, type=int) 163 | group_a.add_argument("--arga3", required=True, type=int) 164 | expected = parser.format_help() 165 | 166 | @dataclass 167 | class Opt: 168 | arga1: int = field(metadata={"group": title_a}) 169 | arga2: int = field(metadata={"group": title_a}) 170 | argb: int = field( 171 | metadata={"group": {"title": title_b, "description": descr_b}} 172 | ) 173 | argc: int = field(metadata={"group": (title_c, descr_c)}) 174 | argd: int = field(metadata={"group": ()}) 175 | arge: int = field(metadata={"group": ()}) 176 | arga3: int = field(metadata={"group": title_a}) 177 | 178 | parser = ArgumentParser(Opt) 179 | out = parser.format_help() 180 | 181 | self.assertEqual(expected, out) 182 | -------------------------------------------------------------------------------- /tests/test_argumentparser.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import unittest 3 | import datetime as dt 4 | from dataclasses import dataclass, field 5 | 6 | from typing import Optional, Union, Literal 7 | from argparse_dataclass import ArgumentParser 8 | 9 | 10 | class NegativeTestHelper: 11 | """Helper to enable testing of negative test cases. 12 | On error parse_args() will call sys.exit(). 13 | this will hijack that call and log the status code. 14 | """ 15 | 16 | def __init__(self): 17 | self._sys_exit = None 18 | self.exit_status: int = None 19 | 20 | def _on_exit(self, status: int): 21 | self.exit_status = status 22 | raise RuntimeError("App Exited") 23 | 24 | def __enter__(self): 25 | self._sys_exit = sys.exit 26 | sys.exit = self._on_exit 27 | return self 28 | 29 | def __exit__(self, exc_type, exc_value, traceback): 30 | sys.exit = self._sys_exit 31 | return exc_type is RuntimeError and str(exc_value) == "App Exited" 32 | 33 | 34 | class ArgumentParserTests(unittest.TestCase): 35 | def test_basic(self): 36 | @dataclass 37 | class Opt: 38 | x: int = 42 39 | y: bool = False 40 | 41 | params = ArgumentParser(Opt).parse_args([]) 42 | self.assertEqual(42, params.x) 43 | self.assertEqual(False, params.y) 44 | params = ArgumentParser(Opt).parse_args(["--x=10", "--y"]) 45 | self.assertEqual(10, params.x) 46 | self.assertEqual(True, params.y) 47 | 48 | def test_basic_negative(self): 49 | class Opt: 50 | x: int = 42 51 | y: bool = False 52 | 53 | self.assertRaises(TypeError, ArgumentParser, Opt) 54 | 55 | def test_no_defaults(self): 56 | @dataclass 57 | class Args: 58 | num_of_foo: int 59 | name: str 60 | 61 | params = ArgumentParser(Args).parse_args(["--num-of-foo=10", "--name", "Sam"]) 62 | self.assertEqual(10, params.num_of_foo) 63 | self.assertEqual("Sam", params.name) 64 | 65 | def test_no_defaults_negative(self): 66 | @dataclass 67 | class Args: 68 | num_of_foo: int 69 | name: str 70 | 71 | with NegativeTestHelper() as helper: 72 | ArgumentParser(Args).parse_args([]) 73 | self.assertIsNotNone(helper.exit_status, "Expected an error while parsing") 74 | 75 | def test_nargs(self): 76 | @dataclass 77 | class Args: 78 | name: str 79 | friends: list[str] = field(metadata=dict(nargs=2)) 80 | 81 | args = ["--name", "Sam", "--friends", "pippin", "Frodo"] 82 | params = ArgumentParser(Args).parse_args(args) 83 | self.assertEqual("Sam", params.name) 84 | self.assertEqual(["pippin", "Frodo"], params.friends) 85 | 86 | def test_nargs_plus(self): 87 | @dataclass 88 | class Args: 89 | name: str 90 | friends: list[str] = field(metadata=dict(nargs="+")) 91 | 92 | args = ["--name", "Sam", "--friends", "pippin", "Frodo"] 93 | params = ArgumentParser(Args).parse_args(args) 94 | self.assertEqual("Sam", params.name) 95 | self.assertEqual(["pippin", "Frodo"], params.friends) 96 | 97 | args += ["Bilbo"] 98 | params = ArgumentParser(Args).parse_args(args) 99 | self.assertEqual("Sam", params.name) 100 | self.assertEqual(["pippin", "Frodo", "Bilbo"], params.friends) 101 | 102 | def test_nargs_negative(self): 103 | @dataclass 104 | class Args: 105 | name: str 106 | friends: list = field(metadata=dict(nargs=2)) 107 | 108 | self.assertRaises(ValueError, ArgumentParser, Args) 109 | 110 | def test_positional(self): 111 | @dataclass 112 | class Options: 113 | x: int = field(metadata=dict(args=["-x", "--long-name"])) 114 | positional: str = field(metadata=dict(args=["positional"])) 115 | 116 | params = ArgumentParser(Options).parse_args(["-x", "0", "POS_VALUE"]) 117 | self.assertEqual(params.x, 0) 118 | self.assertEqual(params.positional, "POS_VALUE") 119 | 120 | def test_choices(self): 121 | @dataclass 122 | class Options: 123 | small_integer: int = field(metadata=dict(choices=[1, 2, 3])) 124 | 125 | params = ArgumentParser(Options).parse_args(["--small-integer", "2"]) 126 | self.assertEqual(params.small_integer, 2) 127 | 128 | def test_choices_negative(self): 129 | @dataclass 130 | class Options: 131 | small_integer: int = field(metadata=dict(choices=[1, 2, 3])) 132 | 133 | with NegativeTestHelper() as helper: 134 | ArgumentParser(Options).parse_args(["--small-integer", "20"]) 135 | self.assertIsNotNone(helper.exit_status, "Expected an error while parsing") 136 | 137 | def test_type(self): 138 | @dataclass 139 | class Options: 140 | name: str = field(metadata=dict(type=str.title)) 141 | 142 | params = ArgumentParser(Options).parse_args(["--name", "john doe"]) 143 | self.assertEqual(params.name, "John Doe") 144 | 145 | def test_default_factory(self): 146 | @dataclass 147 | class Parameters: 148 | cutoff_date: dt.datetime = field( 149 | default_factory=dt.datetime.now, 150 | metadata=dict(type=dt.datetime.fromisoformat), 151 | ) 152 | 153 | s_time = dt.datetime.now() 154 | params = ArgumentParser(Parameters).parse_args([]) 155 | e_time = dt.datetime.now() 156 | self.assertGreaterEqual(params.cutoff_date, s_time) 157 | self.assertLessEqual(params.cutoff_date, e_time) 158 | 159 | s_time = dt.datetime.now() 160 | params = ArgumentParser(Parameters).parse_args([]) 161 | e_time = dt.datetime.now() 162 | self.assertGreaterEqual(params.cutoff_date, s_time) 163 | self.assertLessEqual(params.cutoff_date, e_time) 164 | 165 | date = dt.datetime(2000, 1, 1) 166 | params = ArgumentParser(Parameters).parse_args( 167 | ["--cutoff-date", date.isoformat()] 168 | ) 169 | self.assertEqual(params.cutoff_date, date) 170 | 171 | def test_default_factory_2(self): 172 | factory_calls = 0 173 | 174 | def factory_func(): 175 | nonlocal factory_calls 176 | factory_calls += 1 177 | return f"Default Message: {factory_calls}" 178 | 179 | @dataclass 180 | class Parameters: 181 | message: str = field(default_factory=factory_func) 182 | 183 | params = ArgumentParser(Parameters).parse_args([]) 184 | self.assertEqual(params.message, "Default Message: 1") 185 | self.assertEqual(factory_calls, 1) 186 | 187 | params = ArgumentParser(Parameters).parse_args(["--message", "User message"]) 188 | self.assertEqual(params.message, "User message") 189 | self.assertEqual(factory_calls, 1) 190 | 191 | params = ArgumentParser(Parameters).parse_args([]) 192 | self.assertEqual(params.message, "Default Message: 2") 193 | self.assertEqual(factory_calls, 2) 194 | 195 | def test_optional_args(self): 196 | @dataclass 197 | class Options: 198 | name: str 199 | age: Optional[int] = None 200 | 201 | args = ["--name", "John Doe"] 202 | params = ArgumentParser(Options).parse_args(args) 203 | self.assertEqual(params.name, "John Doe") 204 | self.assertEqual(params.age, None) 205 | 206 | args = ["--name", "John Doe", "--age", "3"] 207 | params = ArgumentParser(Options).parse_args(args) 208 | self.assertEqual(params.name, "John Doe") 209 | self.assertEqual(params.age, 3) 210 | 211 | def test_optional_union_args(self): 212 | @dataclass 213 | class Options: 214 | name: str 215 | age: Union[None, int] = None 216 | 217 | args = ["--name", "John Doe"] 218 | params = ArgumentParser(Options).parse_args(args) 219 | self.assertEqual(params.name, "John Doe") 220 | self.assertEqual(params.age, None) 221 | 222 | args = ["--name", "John Doe", "--age", "3"] 223 | params = ArgumentParser(Options).parse_args(args) 224 | self.assertEqual(params.name, "John Doe") 225 | self.assertEqual(params.age, 3) 226 | 227 | def test_union_args(self): 228 | def parse_int_or_str(value): 229 | try: 230 | return int(value) 231 | except ValueError: 232 | return value 233 | 234 | @dataclass 235 | class Options: 236 | int_or_str: Union[int, str] = field(metadata=dict(type=parse_int_or_str)) 237 | 238 | args = ["--int-or-str", "John Doe"] 239 | params = ArgumentParser(Options).parse_args(args) 240 | self.assertEqual(params.int_or_str, "John Doe") 241 | 242 | args = ["--int-or-str", "42"] 243 | params = ArgumentParser(Options).parse_args(args) 244 | self.assertEqual(params.int_or_str, 42) 245 | 246 | def test_union_args_raises_without_custom_type(self): 247 | @dataclass 248 | class Options: 249 | int_or_str: Union[int, str] 250 | 251 | args = ["--int-or-str", "John Doe"] 252 | with self.assertRaises(TypeError): 253 | ArgumentParser(Options).parse_args(args) 254 | 255 | def test_literal(self): 256 | """Test case for basic usage of a Literal type for providing choices.""" 257 | 258 | @dataclass 259 | class Options: 260 | a_or_b: Literal["a", "b"] 261 | 262 | # Provide a valid argument 263 | args = ["--a-or-b", "a"] 264 | params = ArgumentParser(Options).parse_args(args) 265 | self.assertEqual(params.a_or_b, "a") 266 | 267 | def test_literal_invalid_argument(self): 268 | """Test case for the Literal type: show that incorrect arguments fail on parsing.""" 269 | 270 | @dataclass 271 | class Options: 272 | a_or_b: Literal["a", "b"] 273 | 274 | # Provide an invalid argument 275 | args = ["--a-or-b", "c"] 276 | with NegativeTestHelper() as helper: 277 | ArgumentParser(Options).parse_args(args) 278 | 279 | self.assertIsNotNone(helper.exit_status, "Expected an error while parsing") 280 | 281 | def test_literal_mixed_types(self): 282 | """Test case for the Literal type: show that using a Literal with mixed arguments is rejected with a ValueError.""" 283 | 284 | # Provide a Literal type with mixed argument types 285 | @dataclass 286 | class Options: 287 | a_or_3: Literal["a", 3] 288 | 289 | self.assertRaises(ValueError, lambda: ArgumentParser(Options)) 290 | 291 | def test_literal_choices_collision(self): 292 | """Test case for the Literal type: do not allow choices in the metadata when the field type is Literal.""" 293 | 294 | # Provide a Literal type combined with choices in the meta field 295 | @dataclass 296 | class Options: 297 | a_or_3: Literal["a", "b"] = field(metadata={"choices": ["a", "b"]}) 298 | 299 | self.assertRaises(ValueError, lambda: ArgumentParser(Options)) 300 | 301 | def test_keep_underscores(self): 302 | @dataclass 303 | class Args: 304 | num_of_foo: int = field(metadata={"keep_underscores": True}) 305 | is_fun: bool = field(default=True, metadata={"keep_underscores": True}) 306 | 307 | params = ArgumentParser(Args).parse_args(["--num_of_foo=10", "--no-is_fun"]) 308 | self.assertEqual(10, params.num_of_foo) 309 | self.assertFalse(params.is_fun) 310 | 311 | 312 | if __name__ == "__main__": 313 | unittest.main() 314 | -------------------------------------------------------------------------------- /tests/test_basic.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from argparse_dataclass import dataclass 3 | 4 | 5 | @dataclass 6 | class Opt: 7 | x: int = 42 8 | y: bool = False 9 | 10 | 11 | class ArgParseTests(unittest.TestCase): 12 | def test_basic(self): 13 | params = Opt.parse_args([]) 14 | self.assertEqual(42, params.x) 15 | self.assertEqual(False, params.y) 16 | params = Opt.parse_args(["--x=10", "--y"]) 17 | self.assertEqual(10, params.x) 18 | self.assertEqual(True, params.y) 19 | 20 | 21 | if __name__ == "__main__": 22 | unittest.main() 23 | -------------------------------------------------------------------------------- /tests/test_functional.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import unittest 3 | import datetime as dt 4 | from dataclasses import dataclass, field 5 | 6 | from typing import Optional, Union 7 | 8 | from argparse_dataclass import parse_args, parse_known_args 9 | 10 | 11 | class NegativeTestHelper: 12 | """Helper to enable testing of negative test cases. 13 | On error parse_args() will call sys.exit(). 14 | this will hijack that call and log the status code. 15 | """ 16 | 17 | def __init__(self): 18 | self._sys_exit = None 19 | self.exit_status: int = None 20 | 21 | def _on_exit(self, status: int): 22 | self.exit_status = status 23 | raise RuntimeError("App Exited") 24 | 25 | def __enter__(self): 26 | self._sys_exit = sys.exit 27 | sys.exit = self._on_exit 28 | return self 29 | 30 | def __exit__(self, exc_type, exc_value, traceback): 31 | sys.exit = self._sys_exit 32 | return exc_type is RuntimeError and str(exc_value) == "App Exited" 33 | 34 | 35 | class FunctionalParserTests(unittest.TestCase): 36 | def test_basic(self): 37 | @dataclass 38 | class Opt: 39 | x: int = 42 40 | y: bool = False 41 | 42 | params = parse_args(Opt, []) 43 | self.assertEqual(42, params.x) 44 | self.assertEqual(False, params.y) 45 | params = parse_args(Opt, ["--x=10", "--y"]) 46 | self.assertEqual(10, params.x) 47 | self.assertEqual(True, params.y) 48 | 49 | def test_basic_negative(self): 50 | class Opt: 51 | x: int = 42 52 | y: bool = False 53 | 54 | self.assertRaises(TypeError, parse_args, Opt, []) 55 | 56 | def test_bool_no_default(self): 57 | @dataclass 58 | class Opt: 59 | verbose: bool 60 | logging: bool 61 | 62 | params = parse_args(Opt, []) 63 | self.assertEqual(False, params.verbose) 64 | self.assertEqual(False, params.logging) 65 | params = parse_args(Opt, ["--verbose", "--logging"]) 66 | self.assertEqual(True, params.verbose) 67 | self.assertEqual(True, params.logging) 68 | 69 | def test_bool_default_false(self): 70 | @dataclass 71 | class Opt: 72 | verbose: bool = False 73 | logging: bool = False 74 | 75 | params = parse_args(Opt, ["--verbose"]) 76 | self.assertEqual(True, params.verbose) 77 | self.assertEqual(False, params.logging) 78 | with NegativeTestHelper() as helper: 79 | parse_args(Opt, ["--no-verbose"]) 80 | self.assertIsNotNone(helper.exit_status, "Expected an error while parsing") 81 | 82 | def test_bool_default_true(self): 83 | @dataclass 84 | class Opt: 85 | verbose: bool = True 86 | logging: bool = True 87 | 88 | params = parse_args(Opt, []) 89 | self.assertEqual(True, params.verbose) 90 | self.assertEqual(True, params.logging) 91 | params = parse_args(Opt, ["--no-verbose"]) 92 | self.assertEqual(False, params.verbose) 93 | self.assertEqual(True, params.logging) 94 | 95 | with NegativeTestHelper() as helper: 96 | parse_args(Opt, ["--verbose"]) 97 | self.assertIsNotNone(helper.exit_status, "Expected an error while parsing") 98 | 99 | def test_bool_default_true_with_args(self): 100 | @dataclass 101 | class Opt: 102 | verbose: bool = field(default=True, metadata={"args": ["--silent"]}) 103 | logging: bool = field(default=True, metadata={"args": ["--logging-off"]}) 104 | 105 | params = parse_args(Opt, []) 106 | self.assertEqual(True, params.verbose) 107 | self.assertEqual(True, params.logging) 108 | params = parse_args(Opt, ["--silent"]) 109 | self.assertEqual(False, params.verbose) 110 | self.assertEqual(True, params.logging) 111 | params = parse_args(Opt, ["--logging-off"]) 112 | self.assertEqual(True, params.verbose) 113 | self.assertEqual(False, params.logging) 114 | params = parse_args(Opt, ["--silent", "--logging-off"]) 115 | self.assertEqual(False, params.verbose) 116 | self.assertEqual(False, params.logging) 117 | 118 | def test_bool_required(self): 119 | @dataclass 120 | class Opt: 121 | verbose: bool = field(metadata={"required": True}) 122 | logging: bool = field( 123 | metadata={"args": ["--enable-logging"], "required": True} 124 | ) 125 | 126 | with NegativeTestHelper() as helper: 127 | parse_args(Opt, []) 128 | self.assertIsNotNone(helper.exit_status, "Expected an error while parsing") 129 | params = parse_args(Opt, ["--verbose", "--enable-logging"]) 130 | self.assertEqual(True, params.verbose) 131 | self.assertEqual(True, params.logging) 132 | params = parse_args(Opt, ["--no-verbose", "--enable-logging"]) 133 | self.assertEqual(False, params.verbose) 134 | self.assertEqual(True, params.logging) 135 | params = parse_args(Opt, ["--verbose", "--no-enable-logging"]) 136 | self.assertEqual(True, params.verbose) 137 | self.assertEqual(False, params.logging) 138 | params = parse_args(Opt, ["--no-verbose", "--enable-logging"]) 139 | self.assertEqual(False, params.verbose) 140 | self.assertEqual(True, params.logging) 141 | params = parse_args(Opt, ["--no-verbose", "--no-enable-logging"]) 142 | self.assertEqual(False, params.verbose) 143 | self.assertEqual(False, params.logging) 144 | 145 | def test_no_defaults(self): 146 | @dataclass 147 | class Args: 148 | num_of_foo: int 149 | name: str 150 | 151 | params = parse_args(Args, ["--num-of-foo=10", "--name", "Sam"]) 152 | self.assertEqual(10, params.num_of_foo) 153 | self.assertEqual("Sam", params.name) 154 | 155 | def test_no_defaults_negative(self): 156 | @dataclass 157 | class Args: 158 | num_of_foo: int 159 | name: str 160 | 161 | with NegativeTestHelper() as helper: 162 | parse_args(Args, []) 163 | self.assertIsNotNone(helper.exit_status, "Expected an error while parsing") 164 | 165 | def test_nargs(self): 166 | @dataclass 167 | class Args: 168 | name: str 169 | friends: list[str] = field(metadata=dict(nargs=2)) 170 | 171 | args = ["--name", "Sam", "--friends", "pippin", "Frodo"] 172 | params = parse_args(Args, args) 173 | self.assertEqual("Sam", params.name) 174 | self.assertEqual(["pippin", "Frodo"], params.friends) 175 | 176 | def test_nargs_plus(self): 177 | @dataclass 178 | class Args: 179 | name: str 180 | friends: list[str] = field(metadata=dict(nargs="+")) 181 | 182 | args = ["--name", "Sam", "--friends", "pippin", "Frodo"] 183 | params = parse_args(Args, args) 184 | self.assertEqual("Sam", params.name) 185 | self.assertEqual(["pippin", "Frodo"], params.friends) 186 | 187 | args += ["Bilbo"] 188 | params = parse_args(Args, args) 189 | self.assertEqual("Sam", params.name) 190 | self.assertEqual(["pippin", "Frodo", "Bilbo"], params.friends) 191 | 192 | def test_nargs_negative(self): 193 | @dataclass 194 | class Args: 195 | name: str 196 | friends: list = field(metadata=dict(nargs=2)) 197 | 198 | args = ["--name", "Sam", "--friends", "pippin", "Frodo"] 199 | self.assertRaises(ValueError, parse_args, Args, args) 200 | 201 | def test_positional(self): 202 | @dataclass 203 | class Options: 204 | x: int = field(metadata=dict(args=["-x", "--long-name"])) 205 | positional: str = field(metadata=dict(args=["positional"])) 206 | 207 | params = parse_args(Options, ["-x", "0", "POS_VALUE"]) 208 | self.assertEqual(params.x, 0) 209 | self.assertEqual(params.positional, "POS_VALUE") 210 | 211 | def test_choices(self): 212 | @dataclass 213 | class Options: 214 | small_integer: int = field(metadata=dict(choices=[1, 2, 3])) 215 | 216 | params = parse_args(Options, ["--small-integer", "2"]) 217 | self.assertEqual(params.small_integer, 2) 218 | 219 | def test_choices_negative(self): 220 | @dataclass 221 | class Options: 222 | small_integer: int = field(metadata=dict(choices=[1, 2, 3])) 223 | 224 | with NegativeTestHelper() as helper: 225 | parse_args(Options, ["--small-integer", "20"]) 226 | self.assertIsNotNone(helper.exit_status, "Expected an error while parsing") 227 | 228 | def test_type(self): 229 | @dataclass 230 | class Options: 231 | name: str = field(metadata=dict(type=str.title)) 232 | 233 | params = parse_args(Options, ["--name", "john doe"]) 234 | self.assertEqual(params.name, "John Doe") 235 | 236 | @unittest.skipIf( 237 | sys.version_info[:2] == (3, 6), 238 | "Python 3.6 does not have datetime.fromisoformat()", 239 | ) 240 | def test_default_factory(self): 241 | @dataclass 242 | class Parameters: 243 | cutoff_date: dt.datetime = field( 244 | default_factory=dt.datetime.now, 245 | metadata=dict(type=dt.datetime.fromisoformat), 246 | ) 247 | 248 | s_time = dt.datetime.now() 249 | params = parse_args(Parameters, []) 250 | e_time = dt.datetime.now() 251 | self.assertGreaterEqual(params.cutoff_date, s_time) 252 | self.assertLessEqual(params.cutoff_date, e_time) 253 | 254 | s_time = dt.datetime.now() 255 | params = parse_args(Parameters, []) 256 | e_time = dt.datetime.now() 257 | self.assertGreaterEqual(params.cutoff_date, s_time) 258 | self.assertLessEqual(params.cutoff_date, e_time) 259 | 260 | date = dt.datetime(2000, 1, 1) 261 | params = parse_args(Parameters, ["--cutoff-date", date.isoformat()]) 262 | self.assertEqual(params.cutoff_date, date) 263 | 264 | def test_default_factory_2(self): 265 | factory_calls = 0 266 | 267 | def factory_func(): 268 | nonlocal factory_calls 269 | factory_calls += 1 270 | return f"Default Message: {factory_calls}" 271 | 272 | @dataclass 273 | class Parameters: 274 | message: str = field(default_factory=factory_func) 275 | 276 | params = parse_args(Parameters, []) 277 | self.assertEqual(params.message, "Default Message: 1") 278 | self.assertEqual(factory_calls, 1) 279 | 280 | params = parse_args(Parameters, ["--message", "User message"]) 281 | self.assertEqual(params.message, "User message") 282 | self.assertEqual(factory_calls, 1) 283 | 284 | params = parse_args(Parameters, []) 285 | self.assertEqual(params.message, "Default Message: 2") 286 | self.assertEqual(factory_calls, 2) 287 | 288 | def test_parse_known_args(self): 289 | @dataclass 290 | class Options: 291 | name: str 292 | 293 | with NegativeTestHelper() as helper: 294 | parse_known_args(Options, []) 295 | self.assertIsNotNone(helper.exit_status, "Expected an error while parsing") 296 | 297 | args = ["--name", "John Doe"] 298 | params, others = parse_known_args(Options, args) 299 | self.assertEqual(params.name, "John Doe") 300 | self.assertEqual(others, []) 301 | 302 | args = ["--name", "John Doe", "--cat", "hat"] 303 | params, others = parse_known_args(Options, args) 304 | self.assertEqual(params.name, "John Doe") 305 | self.assertEqual(others, ["--cat", "hat"]) 306 | 307 | def test_optional_args(self): 308 | @dataclass 309 | class Options: 310 | name: str 311 | age: Optional[int] = None 312 | 313 | args = ["--name", "John Doe"] 314 | params = parse_args(Options, args) 315 | self.assertEqual(params.name, "John Doe") 316 | self.assertEqual(params.age, None) 317 | 318 | args = ["--name", "John Doe", "--age", "3"] 319 | params = parse_args(Options, args) 320 | self.assertEqual(params.name, "John Doe") 321 | self.assertEqual(params.age, 3) 322 | 323 | def test_optional_union_args(self): 324 | @dataclass 325 | class Options: 326 | name: str 327 | age: Union[None, int] = None 328 | 329 | args = ["--name", "John Doe"] 330 | params = parse_args(Options, args) 331 | self.assertEqual(params.name, "John Doe") 332 | self.assertEqual(params.age, None) 333 | 334 | args = ["--name", "John Doe", "--age", "3"] 335 | params = parse_args(Options, args) 336 | self.assertEqual(params.name, "John Doe") 337 | self.assertEqual(params.age, 3) 338 | 339 | 340 | if __name__ == "__main__": 341 | unittest.main() 342 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | 3 | envlist = py39, py310, py311, py312, py313 4 | # isolated_build = True 5 | 6 | [testenv] 7 | deps = 8 | -r {toxinidir}/requirements_dev.txt 9 | commands = 10 | python -m doctest argparse_dataclass.py 11 | pytest 12 | commands_post = 13 | black --check argparse_dataclass.py 14 | black --check tests 15 | --------------------------------------------------------------------------------