├── .coveragerc ├── .github └── workflows │ └── tests.yml ├── .gitignore ├── COPYING ├── README.md ├── argclass ├── __init__.py ├── __main__.py └── py.typed ├── poetry.lock ├── pylama.ini ├── pyproject.toml └── tests ├── test_config.py ├── test_secret_string.py ├── test_simple.py ├── test_store.py └── test_subparsers.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | omit = 4 | */env/* 5 | */tests/* 6 | */.*/* 7 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | pylama: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Setup python3.10 15 | uses: actions/setup-python@v2 16 | with: 17 | python-version: "3.11" 18 | - run: python -m pip install poetry 19 | - run: poetry install 20 | - run: poetry run pylama 21 | env: 22 | FORCE_COLOR: 1 23 | mypy: 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v2 27 | - name: Setup python3.10 28 | uses: actions/setup-python@v2 29 | with: 30 | python-version: "3.11" 31 | - run: python -m pip install poetry 32 | - run: poetry install 33 | - run: poetry run mypy 34 | env: 35 | FORCE_COLOR: 1 36 | docs: 37 | runs-on: ubuntu-latest 38 | steps: 39 | - uses: actions/checkout@v2 40 | - name: Setup python3.10 41 | uses: actions/setup-python@v2 42 | with: 43 | python-version: "3.11" 44 | - run: python -m pip install poetry 45 | - run: poetry install 46 | - run: poetry run pytest -svv README.md 47 | env: 48 | FORCE_COLOR: 1 49 | 50 | tests: 51 | runs-on: ubuntu-latest 52 | 53 | strategy: 54 | fail-fast: false 55 | 56 | matrix: 57 | python: 58 | - '3.8' 59 | - '3.9' 60 | - '3.10' 61 | - '3.11' 62 | - '3.12' 63 | steps: 64 | - uses: actions/checkout@v2 65 | - name: Setup python${{ matrix.python }} 66 | uses: actions/setup-python@v2 67 | with: 68 | python-version: "${{ matrix.python }}" 69 | - run: python -m pip install poetry 70 | - run: poetry install 71 | - run: >- 72 | poetry run pytest \ 73 | -vv \ 74 | --cov=argclass \ 75 | --cov-report=term-missing \ 76 | --doctest-modules \ 77 | tests 78 | env: 79 | FORCE_COLOR: 1 80 | - run: poetry run coveralls 81 | env: 82 | COVERALLS_PARALLEL: 'true' 83 | COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} 84 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 85 | 86 | finish: 87 | needs: 88 | - tests 89 | runs-on: ubuntu-latest 90 | steps: 91 | - name: Coveralls Finished 92 | uses: coverallsapp/github-action@master 93 | with: 94 | github-token: ${{ secrets.github_token }} 95 | parallel-finished: true 96 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-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 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | *.bin 131 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2022 Dmitry Orlov 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # argclass 2 | 3 | ![Coverage](https://coveralls.io/repos/github/mosquito/argclass/badge.svg?branch=master) [![Actions](https://github.com/mosquito/argclass/workflows/tests/badge.svg)](https://github.com/mosquito/argclass/actions?query=workflow%3Atests) [![Latest Version](https://img.shields.io/pypi/v/argclass.svg)](https://pypi.python.org/pypi/argclass/) [![Python Versions](https://img.shields.io/pypi/pyversions/argclass.svg)](https://pypi.python.org/pypi/argclass/) [![License](https://img.shields.io/pypi/l/argclass.svg)](https://pypi.python.org/pypi/argclass/) 4 | 5 | A wrapper around the standard `argparse` module that allows you to describe argument parsers declaratively. 6 | 7 | By default, the `argparse` module suggests creating parsers imperatively, which is not convenient for type checking and attribute access. Additionally, IDE autocompletion and type hints are not applicable in this case. 8 | 9 | This module allows you to declare command-line parsers using classes. 10 | 11 | ## Quick Start 12 | 13 | 14 | ```python 15 | import argclass 16 | 17 | class CopyParser(argclass.Parser): 18 | recursive: bool 19 | preserve_attributes: bool 20 | 21 | parser = CopyParser() 22 | parser.parse_args(["--recursive", "--preserve-attributes"]) 23 | assert parser.recursive 24 | assert parser.preserve_attributes 25 | ``` 26 | 27 | As you can see, this example shows basic module usage. When you want to specify argument defaults and other options, you have to use `argclass.Argument`. 28 | 29 | ## Subparsers 30 | 31 | The following example shows how to use subparsers: 32 | 33 | ```python 34 | import argclass 35 | 36 | class SubCommand(argclass.Parser): 37 | comment: str 38 | 39 | def __call__(self) -> int: 40 | endpoint: str = self.__parent__.endpoint 41 | print("Subcommand called", self, "endpoint", endpoint) 42 | return 0 43 | 44 | class Parser(argclass.Parser): 45 | endpoint: str 46 | subcommand = SubCommand() 47 | 48 | if __name__ == '__main__': 49 | parser = Parser() 50 | parser.parse_args() 51 | exit(parser()) 52 | ``` 53 | 54 | The `__call__` method will be called when the subparser is used. Otherwise, help will be printed. 55 | 56 | ## Value Conversion 57 | 58 | If an argument has a generic or composite type, you must explicitly describe it using `argclass.Argument`, specifying a converter function with the `type` or `converter` argument to transform the value after parsing. 59 | 60 | The main differences between `type` and `converter` are: 61 | 62 | * `type` will be directly passed to the `argparse.ArgumentParser.add_argument` method. 63 | * The `converter` function will be called after parsing the argument. 64 | 65 | 66 | ```python 67 | import uuid 68 | import argclass 69 | 70 | def string_uid(value: str) -> uuid.UUID: 71 | return uuid.uuid5(uuid.NAMESPACE_OID, value) 72 | 73 | class Parser(argclass.Parser): 74 | strid1: uuid.UUID = argclass.Argument(converter=string_uid) 75 | strid2: uuid.UUID = argclass.Argument(type=string_uid) 76 | 77 | parser = Parser() 78 | parser.parse_args(["--strid1=hello", "--strid2=world"]) 79 | assert parser.strid1 == uuid.uuid5(uuid.NAMESPACE_OID, 'hello') 80 | assert parser.strid2 == uuid.uuid5(uuid.NAMESPACE_OID, 'world') 81 | ``` 82 | 83 | As you can see, the `string_uid` function is called in both cases, but `converter` is applied after parsing the argument. 84 | 85 | The following example shows how `type` is applied to each item in a list when using `nargs`: 86 | 87 | 88 | ```python 89 | import argclass 90 | 91 | class Parser(argclass.Parser): 92 | numbers = argclass.Argument(nargs=argclass.Nargs.ONE_OR_MORE, type=int) 93 | 94 | parser = Parser() 95 | parser.parse_args(["--numbers", "1", "2", "3"]) 96 | assert parser.numbers == [1, 2, 3] 97 | ``` 98 | 99 | `type` will be applied to each item in the list of arguments. 100 | 101 | If you want to convert a list of strings to a list of integers and then to a `frozenset`, you can use the following example: 102 | 103 | 104 | ```python 105 | import argclass 106 | 107 | class Parser(argclass.Parser): 108 | numbers = argclass.Argument( 109 | nargs=argclass.Nargs.ONE_OR_MORE, type=int, converter=frozenset 110 | ) 111 | 112 | parser = Parser() 113 | parser.parse_args(["--numbers", "1", "2", "3"]) 114 | assert parser.numbers == frozenset([1, 2, 3]) 115 | ``` 116 | 117 | ## Configuration Files 118 | 119 | Parser objects can get default values from environment variables or from specified configuration files. 120 | 121 | 122 | ```python 123 | import logging 124 | from pathlib import Path 125 | from tempfile import TemporaryDirectory 126 | import argclass 127 | 128 | class Parser(argclass.Parser): 129 | log_level: int = argclass.LogLevel 130 | address: str 131 | port: int 132 | 133 | with TemporaryDirectory() as tmpdir: 134 | tmp = Path(tmpdir) 135 | with open(tmp / "config.ini", "w") as fp: 136 | fp.write( 137 | "[DEFAULT]\n" 138 | "log_level=info\n" 139 | "address=localhost\n" 140 | "port=8080\n" 141 | ) 142 | 143 | parser = Parser(config_files=[tmp / "config.ini"]) 144 | parser.parse_args([]) 145 | assert parser.log_level == logging.INFO 146 | assert parser.address == "localhost" 147 | assert parser.port == 8080 148 | ``` 149 | 150 | When using configuration files, argclass uses Python's `ast.literal_eval` for parsing arguments with `nargs` and 151 | complex types. This means that in your INI configuration files, you should write values in a syntax that `literal_eval` 152 | can parse for these specific arguments. 153 | 154 | For regular arguments (simple types like strings, integers, booleans), you can write the values as-is. 155 | 156 | ## Argument Groups 157 | 158 | The following example uses `argclass.Argument` and argument groups: 159 | 160 | 161 | ```python 162 | from typing import FrozenSet 163 | import logging 164 | import argclass 165 | 166 | class AddressPortGroup(argclass.Group): 167 | address: str = argclass.Argument(default="127.0.0.1") 168 | port: int 169 | 170 | class Parser(argclass.Parser): 171 | log_level: int = argclass.LogLevel 172 | http = AddressPortGroup(title="HTTP options", defaults=dict(port=8080)) 173 | rpc = AddressPortGroup(title="RPC options", defaults=dict(port=9090)) 174 | user_id: FrozenSet[int] = argclass.Argument( 175 | nargs="*", type=int, converter=frozenset 176 | ) 177 | 178 | parser = Parser( 179 | config_files=[".example.ini", "~/.example.ini", "/etc/example.ini"], 180 | auto_env_var_prefix="EXAMPLE_" 181 | ) 182 | parser.parse_args([]) 183 | 184 | # Remove all used environment variables from os.environ 185 | parser.sanitize_env() 186 | 187 | logging.basicConfig(level=parser.log_level) 188 | logging.info('Listening http://%s:%d', parser.http.address, parser.http.port) 189 | logging.info('Listening rpc://%s:%d', parser.rpc.address, parser.rpc.port) 190 | 191 | assert parser.http.address == '127.0.0.1' 192 | assert parser.rpc.address == '127.0.0.1' 193 | 194 | assert parser.http.port == 8080 195 | assert parser.rpc.port == 9090 196 | ``` 197 | 198 | Argument groups are sections in the parser configuration. For example, in this case, the configuration file might be: 199 | 200 | ```ini 201 | [DEFAULT] 202 | log_level=info 203 | user_id=[1, 2, 3] 204 | 205 | [http] 206 | port=9001 207 | 208 | [rpc] 209 | port=9002 210 | ``` 211 | 212 | Run this script: 213 | 214 | ```shell 215 | $ python example.py 216 | INFO:root:Listening http://127.0.0.1:8080 217 | INFO:root:Listening rpc://127.0.0.1:9090 218 | ``` 219 | 220 | Example of `--help` output: 221 | 222 | ```shell 223 | $ python example.py --help 224 | usage: example.py [-h] [--log-level {debug,info,warning,error,critical}] 225 | [--http-address HTTP_ADDRESS] [--http-port HTTP_PORT] 226 | [--rpc-address RPC_ADDRESS] [--rpc-port RPC_PORT] 227 | 228 | optional arguments: 229 | -h, --help show this help message and exit 230 | --log-level {debug,info,warning,error,critical} 231 | (default: info) [ENV: EXAMPLE_LOG_LEVEL] 232 | 233 | HTTP options: 234 | --http-address HTTP_ADDRESS 235 | (default: 127.0.0.1) [ENV: EXAMPLE_HTTP_ADDRESS] 236 | --http-port HTTP_PORT 237 | (default: 8080) [ENV: EXAMPLE_HTTP_PORT] 238 | 239 | RPC options: 240 | --rpc-address RPC_ADDRESS 241 | (default: 127.0.0.1) [ENV: EXAMPLE_RPC_ADDRESS] 242 | --rpc-port RPC_PORT (default: 9090) [ENV: EXAMPLE_RPC_PORT] 243 | 244 | Default values will be based on the following configuration files ['example.ini', 245 | '~/.example.ini', '/etc/example.ini']. Now 1 file has been applied 246 | ['example.ini']. The configuration files are INI-formatted files where 247 | configuration groups are INI sections. 248 | See more https://pypi.org/project/argclass/#configs 249 | ``` 250 | 251 | ## Secrets 252 | 253 | Arguments that contain sensitive data, such as tokens, encryption keys, or URLs with passwords, when passed through environment variables or a configuration file, can be printed in the output of `--help`. To hide defaults, add the `secret=True` parameter, or use the special default constructor `argclass.Secret` instead of `argclass.Argument`. 254 | 255 | ```python 256 | import argclass 257 | 258 | class HttpAuthentication(argclass.Group): 259 | username: str = argclass.Argument() 260 | password: str = argclass.Secret() 261 | 262 | class HttpBearerAuthentication(argclass.Group): 263 | token: str = argclass.Argument(secret=True) 264 | 265 | class Parser(argclass.Parser): 266 | http_basic = HttpAuthentication() 267 | http_bearer = HttpBearerAuthentication() 268 | 269 | parser = Parser() 270 | parser.print_help() 271 | ``` 272 | 273 | ### Preventing Secrets from Being Logged 274 | 275 | A secret is not actually a string, but a special class inherited from `str`. All attempts to cast this type to a `str` (using the `__str__` method) will return the original value, unless the `__str__` method is called from the `logging` module. 276 | 277 | ```python 278 | import logging 279 | from argclass import SecretString 280 | 281 | logging.basicConfig(level=logging.INFO) 282 | s = SecretString("my-secret-password") 283 | logging.info(s) # __str__ will be called from logging 284 | logging.info(f"s=%s", s) # __str__ will be called from logging too 285 | logging.info(f"{s!r}") # repr is safe 286 | logging.info(f"{s}") # the password will be compromised 287 | ``` 288 | 289 | Of course, this is not absolute sensitive data protection, but it helps prevent accidental logging of these values. 290 | 291 | The `repr` for this will always give a placeholder, so it is better to always add `!r` to any f-string, for example `f'{value!r}'`. 292 | 293 | ## Enum Argument 294 | 295 | The library provides a special argument type for working with enumerations. For enum arguments, the `choices` parameter will be generated automatically from the enum names. After parsing the argument, the value will be converted to the enum member. 296 | 297 | 298 | ```python 299 | import enum 300 | import logging 301 | import argclass 302 | 303 | class LogLevelEnum(enum.IntEnum): 304 | debug = logging.DEBUG 305 | info = logging.INFO 306 | warning = logging.WARNING 307 | error = logging.ERROR 308 | critical = logging.CRITICAL 309 | 310 | class Parser(argclass.Parser): 311 | """Log level with default""" 312 | log_level = argclass.EnumArgument(LogLevelEnum, default="info") 313 | 314 | class ParserLogLevelIsRequired(argclass.Parser): 315 | log_level: LogLevelEnum 316 | 317 | parser = Parser() 318 | parser.parse_args([]) 319 | assert parser.log_level == logging.INFO 320 | 321 | parser = Parser() 322 | parser.parse_args(["--log-level=error"]) 323 | assert parser.log_level == logging.ERROR 324 | 325 | parser = ParserLogLevelIsRequired() 326 | parser.parse_args(["--log-level=warning"]) 327 | assert parser.log_level == logging.WARNING 328 | ``` 329 | 330 | ## Config Action 331 | 332 | This library provides a base class for writing custom configuration parsers. 333 | 334 | `argclass.Config` is a special argument type for parsing configuration files. The optional parameter `config_class` is used to specify the custom configuration parser. By default, it is an INI parser. 335 | 336 | ### YAML Parser 337 | 338 | To parse YAML files, you need to install the `PyYAML` package. Follow code is an implementation of a YAML config parser. 339 | 340 | ```python 341 | from typing import Mapping, Any 342 | from pathlib import Path 343 | import argclass 344 | import yaml 345 | 346 | class YAMLConfigAction(argclass.ConfigAction): 347 | def parse_file(self, file: Path) -> Mapping[str, Any]: 348 | with file.open("r") as fp: 349 | return yaml.load(fp, Loader=yaml.FullLoader) 350 | 351 | class YAMLConfigArgument(argclass.ConfigArgument): 352 | action = YAMLConfigAction 353 | 354 | class Parser(argclass.Parser): 355 | config = argclass.Config( 356 | required=True, 357 | config_class=YAMLConfigArgument, 358 | ) 359 | ``` 360 | 361 | ### TOML Parser 362 | 363 | To parse TOML files, you need to install the `tomli` package. Follow code is an implementation of a TOML config parser. 364 | 365 | ```python 366 | import tomli 367 | import argclass 368 | from pathlib import Path 369 | from typing import Mapping, Any 370 | 371 | class TOMLConfigAction(argclass.ConfigAction): 372 | def parse_file(self, file: Path) -> Mapping[str, Any]: 373 | with file.open("rb") as fp: 374 | return tomli.load(fp) 375 | 376 | class TOMLConfigArgument(argclass.ConfigArgument): 377 | action = TOMLConfigAction 378 | 379 | class Parser(argclass.Parser): 380 | config = argclass.Config( 381 | required=True, 382 | config_class=TOMLConfigArgument, 383 | ) 384 | ``` 385 | 386 | ## Subparsers Advanced Usage 387 | 388 | There are two ways to work with subparsers: either by calling the parser as a regular function, in which case the 389 | subparser must implement the `__call__` method (otherwise help will be printed and the program will exit with an 390 | error), or by directly inspecting the `.current_subparser` attribute in the parser. The second method can be 391 | simplified using `functools.singledispatch`. 392 | 393 | ### Using `__call__` 394 | 395 | Just implement the `__call__` method for subparsers and call the main parser. 396 | 397 | ```python 398 | from typing import Optional 399 | import argclass 400 | 401 | class AddressPortGroup(argclass.Group): 402 | address: str = "127.0.0.1" 403 | port: int = 8080 404 | 405 | class CommitCommand(argclass.Parser): 406 | comment: str = argclass.Argument() 407 | 408 | def __call__(self) -> int: 409 | endpoint: AddressPortGroup = self.__parent__.endpoint 410 | print( 411 | "Commit command called", self, 412 | "endpoint", endpoint.address, "port", endpoint.port 413 | ) 414 | return 0 415 | 416 | class PushCommand(argclass.Parser): 417 | comment: str = argclass.Argument() 418 | 419 | def __call__(self) -> int: 420 | endpoint: AddressPortGroup = self.__parent__.endpoint 421 | print( 422 | "Push command called", self, 423 | "endpoint", endpoint.address, "port", endpoint.port 424 | ) 425 | return 0 426 | 427 | class Parser(argclass.Parser): 428 | log_level: int = argclass.LogLevel 429 | endpoint = AddressPortGroup(title="Endpoint options") 430 | commit: Optional[CommitCommand] = CommitCommand() 431 | push: Optional[PushCommand] = PushCommand() 432 | 433 | if __name__ == '__main__': 434 | parser = Parser( 435 | config_files=["example.ini", "~/.example.ini", "/etc/example.ini"], 436 | auto_env_var_prefix="EXAMPLE_" 437 | ) 438 | parser.parse_args() 439 | exit(parser()) 440 | ``` 441 | 442 | ### Using `singledispatch` 443 | 444 | You can use the `current_subparser` attribute to get the current subparser and then call it. This does not require implementing the `__call__` method. 445 | 446 | ```python 447 | from functools import singledispatch 448 | from typing import Optional, Any 449 | import argclass 450 | 451 | class AddressPortGroup(argclass.Group): 452 | address: str = argclass.Argument(default="127.0.0.1") 453 | port: int 454 | 455 | class CommitCommand(argclass.Parser): 456 | comment: str = argclass.Argument() 457 | 458 | class PushCommand(argclass.Parser): 459 | comment: str = argclass.Argument() 460 | 461 | class Parser(argclass.Parser): 462 | log_level: int = argclass.LogLevel 463 | endpoint = AddressPortGroup( 464 | title="Endpoint options", 465 | defaults=dict(port=8080) 466 | ) 467 | commit: Optional[CommitCommand] = CommitCommand() 468 | push: Optional[PushCommand] = PushCommand() 469 | 470 | @singledispatch 471 | def handle_subparser(subparser: Any) -> None: 472 | raise NotImplementedError( 473 | f"Unexpected subparser type {subparser.__class__!r}" 474 | ) 475 | 476 | @handle_subparser.register(type(None)) 477 | def handle_none(_: None) -> None: 478 | Parser().print_help() 479 | exit(2) 480 | 481 | @handle_subparser.register(CommitCommand) 482 | def handle_commit(subparser: CommitCommand) -> None: 483 | print("Commit command called", subparser) 484 | 485 | @handle_subparser.register(PushCommand) 486 | def handle_push(subparser: PushCommand) -> None: 487 | print("Push command called", subparser) 488 | 489 | if __name__ == '__main__': 490 | parser = Parser( 491 | config_files=["example.ini", "~/.example.ini", "/etc/example.ini"], 492 | auto_env_var_prefix="EXAMPLE_" 493 | ) 494 | parser.parse_args() 495 | handle_subparser(parser.current_subparser) 496 | ``` 497 | 498 | ## Value Conversion with Optional and Union Types 499 | 500 | If an argument has a generic or composite type, you must explicitly describe it using `argclass.Argument`, specifying 501 | the converter function with `type` or `converter` to transform the value after parsing. The exception to this rule 502 | is `Optional` with a single type. In this case, an argument without a default value will not be required, and 503 | its value can be `None`. 504 | 505 | 506 | ```python 507 | import argclass 508 | from typing import Optional, Union 509 | 510 | def converter(value: str) -> Optional[Union[int, str, bool]]: 511 | if value.lower() == "none": 512 | return None 513 | if value.isdigit(): 514 | return int(value) 515 | if value.lower() in ("yes", "true", "enabled", "enable", "on"): 516 | return True 517 | return False 518 | 519 | class Parser(argclass.Parser): 520 | gizmo: Optional[Union[int, str, bool]] = argclass.Argument( 521 | converter=converter 522 | ) 523 | optional: Optional[int] 524 | 525 | parser = Parser() 526 | 527 | parser.parse_args(["--gizmo=65535"]) 528 | assert parser.gizmo == 65535 529 | 530 | parser.parse_args(["--gizmo=None"]) 531 | assert parser.gizmo is None 532 | 533 | parser.parse_args(["--gizmo=on"]) 534 | assert parser.gizmo is True 535 | assert parser.optional is None 536 | 537 | parser.parse_args(["--gizmo=off", "--optional=10"]) 538 | assert parser.gizmo is False 539 | assert parser.optional == 10 540 | ``` 541 | -------------------------------------------------------------------------------- /argclass/__init__.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import ast 3 | import collections 4 | import configparser 5 | import errno 6 | import json 7 | import logging 8 | import os 9 | import sys 10 | import traceback 11 | from abc import ABCMeta 12 | from argparse import Action, ArgumentParser 13 | from enum import Enum, EnumMeta, IntEnum 14 | from functools import partial 15 | from pathlib import Path 16 | from types import MappingProxyType 17 | from typing import ( 18 | Any, Callable, Dict, Iterable, Iterator, List, Literal, Mapping, 19 | MutableMapping, NamedTuple, Optional, Sequence, Set, Tuple, Type, TypeVar, 20 | Union, 21 | ) 22 | 23 | 24 | ConverterType = Callable[[str], Any] 25 | NoneType = type(None) 26 | UnionClass = Union[None, int].__class__ 27 | EnumType = EnumMeta 28 | 29 | 30 | def read_configs( 31 | *paths: Union[str, Path], **kwargs: Any, 32 | ) -> Tuple[Mapping[str, Any], Tuple[Path, ...]]: 33 | kwargs.setdefault("allow_no_value", True) 34 | kwargs.setdefault("strict", False) 35 | parser = configparser.ConfigParser(**kwargs) 36 | 37 | filenames = list( 38 | map( 39 | lambda p: p.resolve(), 40 | filter( 41 | lambda p: p.is_file(), 42 | map(lambda x: Path(x).expanduser(), paths), 43 | ), 44 | ), 45 | ) 46 | config_paths = parser.read(filenames) 47 | 48 | result: Dict[str, Union[str, Dict[str, str]]] = dict( 49 | parser.items(parser.default_section, raw=True), 50 | ) 51 | 52 | for section in parser.sections(): 53 | config = dict(parser.items(section, raw=True)) 54 | result[section] = config 55 | 56 | return result, tuple(map(Path, config_paths)) 57 | 58 | 59 | class SecretString(str): 60 | """ 61 | The class mimics the string, with one important difference. 62 | Attempting to call __str__ of this instance will result in 63 | the output of placeholer (the default is "******") if the 64 | call stack contains of logging module. In other words, this 65 | is an attempt to keep secrets out of the log. 66 | 67 | However, if you try to do an f-string or str() at the moment 68 | the parameter is passed to the log, the value will be received, 69 | because there is nothing about logging in the stack. 70 | 71 | The repr will always give placeholder, so it is better to always 72 | add ``!r`` for any f-string, for example `f'{value!r}'`. 73 | 74 | Examples: 75 | 76 | >>> import logging 77 | >>> from argclass import SecretString 78 | >>> logging.basicConfig(level=logging.INFO) 79 | >>> s = SecretString("my-secret-password") 80 | >>> logging.info(s) # __str__ will be called from logging 81 | INFO:root:'******' 82 | >>> logging.info(f"s=%s", s) # __str__ will be called from logging too 83 | INFO:root:s='******' 84 | >>> logging.info(f"{s!r}") # repr is safe 85 | INFO:root:'******' 86 | >>> logging.info(f"{s}") # the password will be compromised 87 | INFO:root:my-secret-password 88 | 89 | """ 90 | 91 | PLACEHOLDER = "******" 92 | MODULES_SKIPLIST = ("logging", "log.py") 93 | 94 | def __str__(self) -> str: 95 | for frame in traceback.extract_stack(None): 96 | for skip in self.MODULES_SKIPLIST: 97 | if skip in frame.filename: 98 | return self.PLACEHOLDER 99 | return super().__str__() 100 | 101 | def __repr__(self) -> str: 102 | return repr(self.PLACEHOLDER) 103 | 104 | 105 | class ConfigAction(Action): 106 | def __init__( 107 | self, option_strings: Sequence[str], dest: str, 108 | search_paths: Iterable[Union[str, Path]] = (), 109 | type: MappingProxyType = MappingProxyType({}), 110 | help: str = "", required: bool = False, 111 | default: Any = None, 112 | ): 113 | if not isinstance(type, MappingProxyType): 114 | raise ValueError("type must be MappingProxyType") 115 | 116 | super().__init__( 117 | option_strings, dest, type=Path, help=help, default=default, 118 | required=required, 119 | ) 120 | self.search_paths: List[Path] = list(map(Path, search_paths)) 121 | self._result: Optional[Any] = None 122 | 123 | def parse(self, *files: Path) -> Any: 124 | result = {} 125 | for file in files: 126 | try: 127 | result.update(self.parse_file(file)) 128 | except Exception as e: 129 | logging.warning("Failed to parse config file %s: %s", file, e) 130 | return result 131 | 132 | def parse_file(self, file: Path) -> Any: 133 | raise NotImplementedError() 134 | 135 | def __call__( 136 | self, parser: argparse.ArgumentParser, namespace: argparse.Namespace, 137 | values: Optional[Union[str, Any]], option_string: Optional[str] = None, 138 | ) -> None: 139 | if not self._result: 140 | filenames: Sequence[Path] = list(self.search_paths) 141 | if values: 142 | filenames = [Path(values)] + list(filenames) 143 | filenames = list(filter(lambda x: x.exists(), filenames)) 144 | 145 | if self.required and not filenames: 146 | raise argparse.ArgumentError( 147 | argument=self, 148 | message="is required but no one config loaded", 149 | ) 150 | if filenames: 151 | self._result = self.parse(*filenames) 152 | setattr(namespace, self.dest, MappingProxyType(self._result or {})) 153 | 154 | 155 | class INIConfigAction(ConfigAction): 156 | def parse(self, *files: Path) -> Mapping[str, Any]: 157 | result, filenames = read_configs(*files) 158 | return result 159 | 160 | 161 | class JSONConfigAction(ConfigAction): 162 | def parse_file(self, file: Path) -> Any: 163 | with file.open("r") as fp: 164 | return json.load(fp) 165 | 166 | 167 | class Actions(str, Enum): 168 | APPEND = "append" 169 | APPEND_CONST = "append_const" 170 | COUNT = "count" 171 | HELP = "help" 172 | PARSERS = "parsers" 173 | STORE = "store" 174 | STORE_CONST = "store_const" 175 | STORE_FALSE = "store_false" 176 | STORE_TRUE = "store_true" 177 | VERSION = "version" 178 | 179 | if sys.version_info >= (3, 8): 180 | EXTEND = "extend" 181 | 182 | @classmethod 183 | def default(cls) -> "Actions": 184 | return cls.STORE 185 | 186 | 187 | class Nargs(Enum): 188 | ONE_OR_MORE = "+" 189 | OPTIONAL = "?" 190 | ZERO_OR_MORE = "*" 191 | 192 | 193 | def deep_getattr(name: str, attrs: Dict[str, Any], *bases: Type) -> Any: 194 | if name in attrs: 195 | return attrs[name] 196 | for base in bases: 197 | if hasattr(base, name): 198 | return getattr(base, name) 199 | raise KeyError(f"Key {name} was not declared") 200 | 201 | 202 | def merge_annotations( 203 | annotations: Dict[str, Any], *bases: Type, 204 | ) -> Dict[str, Any]: 205 | result: Dict[str, Any] = {} 206 | 207 | for base in bases: 208 | result.update(getattr(base, "__annotations__", {})) 209 | result.update(annotations) 210 | return result 211 | 212 | 213 | class StoreMeta(type): 214 | def __new__( 215 | mcs, name: str, bases: Tuple[Type["StoreMeta"], ...], 216 | attrs: Dict[str, Any], 217 | ) -> "StoreMeta": 218 | annotations = merge_annotations( 219 | attrs.get("__annotations__", {}), *bases, 220 | ) 221 | attrs["__annotations__"] = annotations 222 | attrs["_fields"] = tuple( 223 | filter( 224 | lambda x: not x.startswith("_"), 225 | annotations.keys(), 226 | ), 227 | ) 228 | return super().__new__(mcs, name, bases, attrs) 229 | 230 | 231 | class Store(metaclass=StoreMeta): 232 | _default_value = object() 233 | _fields: Tuple[str, ...] 234 | 235 | def __new__(cls, **kwargs: Any) -> "Store": 236 | obj = super().__new__(cls) 237 | 238 | type_map: Dict[str, Tuple[Type, Any]] = {} 239 | for key, value in obj.__annotations__.items(): 240 | if key.startswith("_"): 241 | continue 242 | type_map[key] = (value, getattr(obj, key, cls._default_value)) 243 | 244 | for key, (value_type, default) in type_map.items(): 245 | if default is cls._default_value and key not in kwargs: 246 | raise TypeError(f"required argument {key!r} must be passed") 247 | value = kwargs.get(key, default) 248 | setattr(obj, key, value) 249 | return obj 250 | 251 | def copy(self, **overrides: Any) -> Any: 252 | kwargs = self.as_dict() 253 | for key, value in overrides.items(): 254 | kwargs[key] = value 255 | return self.__class__(**kwargs) 256 | 257 | def as_dict(self) -> Dict[str, Any]: 258 | # noinspection PyProtectedMember 259 | return { 260 | field: getattr(self, field) for field in self._fields 261 | } 262 | 263 | def __repr__(self) -> str: 264 | values = ", ".join([ 265 | f"{k!s}={v!r}" for k, v in sorted(self.as_dict().items()) 266 | ]) 267 | return f"<{self.__class__.__name__}: {values}>" 268 | 269 | 270 | class ArgumentBase(Store): 271 | def __init__(self, **kwargs: Any): 272 | self._values = collections.OrderedDict() 273 | 274 | # noinspection PyUnresolvedReferences 275 | for key in self._fields: 276 | self._values[key] = kwargs.get(key, getattr(self.__class__, key)) 277 | 278 | def __getattr__(self, item: str) -> Any: 279 | try: 280 | return self._values[item] 281 | except KeyError as e: 282 | raise AttributeError from e 283 | 284 | @property 285 | def is_positional(self) -> bool: 286 | for alias in self.aliases: 287 | if alias.startswith("-"): 288 | return False 289 | return True 290 | 291 | def get_kwargs(self) -> Dict[str, Any]: 292 | nargs = self.nargs 293 | if isinstance(nargs, Nargs): 294 | nargs = nargs.value 295 | 296 | action = self.action 297 | kwargs = self.as_dict() 298 | 299 | if action in (Actions.STORE_TRUE, Actions.STORE_FALSE, Actions.COUNT): 300 | kwargs.pop("type", None) 301 | 302 | if isinstance(action, Actions): 303 | action = action.value 304 | 305 | kwargs.pop("aliases", None) 306 | kwargs.pop("converter", None) 307 | kwargs.pop("env_var", None) 308 | kwargs.pop("secret", None) 309 | kwargs.update(action=action, nargs=nargs) 310 | 311 | return {k: v for k, v in kwargs.items() if v is not None} 312 | 313 | 314 | class TypedArgument(ArgumentBase): 315 | action: Union[Actions, Type[Action]] = Actions.default() 316 | aliases: Iterable[str] = frozenset() 317 | choices: Optional[Iterable[str]] = None 318 | const: Optional[Any] = None 319 | converter: Optional[ConverterType] = None 320 | default: Optional[Any] = None 321 | secret: bool = False 322 | env_var: Optional[str] = None 323 | help: Optional[str] = None 324 | metavar: Optional[str] = None 325 | nargs: Optional[Union[int, Nargs]] = None 326 | required: Optional[bool] = None 327 | type: Any = None 328 | 329 | @property 330 | def is_nargs(self) -> bool: 331 | if self.nargs is None: 332 | return False 333 | if isinstance(self.nargs, int): 334 | return self.nargs > 1 335 | return True 336 | 337 | 338 | class ConfigArgument(TypedArgument): 339 | search_paths: Optional[Iterable[Union[Path, str]]] = None 340 | action: Type[ConfigAction] 341 | 342 | 343 | class INIConfig(ConfigArgument): 344 | """ Parse INI file and set results as a value """ 345 | action: Type[ConfigAction] = INIConfigAction 346 | 347 | 348 | class JSONConfig(ConfigArgument): 349 | """ Parse INI file and set results as a value """ 350 | action: Type[ConfigAction] = JSONConfigAction 351 | 352 | 353 | class AbstractGroup: 354 | pass 355 | 356 | 357 | class AbstractParser: 358 | __parent__: Union["Parser", None] = None 359 | 360 | def _get_chain(self) -> Iterator["AbstractParser"]: 361 | yield self 362 | if self.__parent__ is None: 363 | return 364 | yield from self.__parent__._get_chain() 365 | 366 | def __call__(self) -> Any: 367 | raise NotImplementedError() 368 | 369 | 370 | TEXT_TRUE_VALUES = frozenset(( 371 | "y", "yes", "true", "t", "enable", "enabled", "1", "on", 372 | )) 373 | 374 | 375 | def parse_bool(value: str) -> bool: 376 | return value.lower() in TEXT_TRUE_VALUES 377 | 378 | 379 | def unwrap_optional(typespec: Any) -> Optional[Any]: 380 | if typespec.__class__ != UnionClass: 381 | return None 382 | 383 | union_args = [a for a in typespec.__args__ if a is not NoneType] 384 | 385 | if len(union_args) != 1: 386 | raise TypeError( 387 | "Complex types mustn't be used in short form. You have to " 388 | "specify argclass.Argument with converter or type function.", 389 | ) 390 | 391 | return union_args[0] 392 | 393 | 394 | def _make_action_true_argument( 395 | kind: Type, default: Any = None, 396 | ) -> TypedArgument: 397 | kw: Dict[str, Any] = {"type": kind} 398 | if kind is bool: 399 | if default is False: 400 | kw["action"] = Actions.STORE_TRUE 401 | elif default is True: 402 | kw["action"] = Actions.STORE_FALSE 403 | else: 404 | raise TypeError(f"Can not set default {default!r} for bool") 405 | elif kind == Optional[bool]: 406 | kw["action"] = Actions.STORE 407 | kw["type"] = parse_bool 408 | kw["default"] = None 409 | return TypedArgument(**kw) 410 | 411 | 412 | def _type_is_bool(kind: Type) -> bool: 413 | return kind is bool or kind == Optional[bool] 414 | 415 | 416 | class Meta(ABCMeta): 417 | def __new__( 418 | mcs, name: str, bases: Tuple[Type["Meta"], ...], 419 | attrs: Dict[str, Any], 420 | ) -> "Meta": 421 | annotations = merge_annotations( 422 | attrs.get("__annotations__", {}), *bases, 423 | ) 424 | 425 | arguments = {} 426 | argument_groups = {} 427 | subparsers = {} 428 | for key, kind in annotations.items(): 429 | if key.startswith("_"): 430 | continue 431 | 432 | try: 433 | argument = deep_getattr(key, attrs, *bases) 434 | except KeyError: 435 | argument = None 436 | if kind is bool: 437 | argument = False 438 | 439 | if not isinstance( 440 | argument, (TypedArgument, AbstractGroup, AbstractParser), 441 | ): 442 | attrs[key] = ... 443 | 444 | is_required = argument is None or argument is Ellipsis 445 | 446 | if _type_is_bool(kind): 447 | argument = _make_action_true_argument(kind, argument) 448 | else: 449 | optional_type = unwrap_optional(kind) 450 | if optional_type is not None: 451 | is_required = False 452 | kind = optional_type 453 | 454 | argument = TypedArgument( 455 | type=kind, default=argument, required=is_required, 456 | ) 457 | 458 | if isinstance(argument, TypedArgument): 459 | if argument.type is None and argument.converter is None: 460 | if kind.__class__.__module__ == "typing": 461 | kind = unwrap_optional(kind) 462 | argument.default = None 463 | argument.type = kind 464 | arguments[key] = argument 465 | elif isinstance(argument, AbstractGroup): 466 | argument_groups[key] = argument 467 | 468 | if isinstance(kind, EnumMeta): 469 | arguments[key] = EnumArgument(kind) 470 | 471 | for key, value in attrs.items(): 472 | if key.startswith("_"): 473 | continue 474 | 475 | if isinstance(value, TypedArgument): 476 | arguments[key] = value 477 | elif isinstance(value, AbstractGroup): 478 | argument_groups[key] = value 479 | elif isinstance(value, AbstractParser): 480 | subparsers[key] = value 481 | 482 | attrs["__arguments__"] = MappingProxyType(arguments) 483 | attrs["__argument_groups__"] = MappingProxyType(argument_groups) 484 | attrs["__subparsers__"] = MappingProxyType(subparsers) 485 | cls = super().__new__(mcs, name, bases, attrs) 486 | return cls 487 | 488 | 489 | class Base(metaclass=Meta): 490 | __arguments__: Mapping[str, TypedArgument] 491 | __argument_groups__: Mapping[str, "Group"] 492 | __subparsers__: Mapping[str, "Parser"] 493 | 494 | def __getattribute__(self, item: str) -> Any: 495 | value = super().__getattribute__(item) 496 | if item.startswith("_"): 497 | return value 498 | 499 | if item in self.__arguments__: 500 | class_value = getattr(self.__class__, item, None) 501 | if value is class_value: 502 | raise AttributeError(f"Attribute {item!r} was not parsed") 503 | return value 504 | 505 | def __repr__(self) -> str: 506 | return ( 507 | f"<{self.__class__.__name__}: " 508 | f"{len(self.__arguments__)} arguments, " 509 | f"{len(self.__argument_groups__)} groups, " 510 | f"{len(self.__subparsers__)} subparsers>" 511 | ) 512 | 513 | 514 | class Destination(NamedTuple): 515 | target: Base 516 | attribute: str 517 | argument: Optional[TypedArgument] 518 | action: Optional[Action] 519 | 520 | 521 | DestinationsType = MutableMapping[str, Set[Destination]] 522 | 523 | 524 | class Group(AbstractGroup, Base): 525 | def __init__( 526 | self, title: Optional[str] = None, description: Optional[str] = None, 527 | prefix: Optional[str] = None, 528 | defaults: Optional[Dict[str, Any]] = None, 529 | ): 530 | self._prefix: Optional[str] = prefix 531 | self._title: Optional[str] = title 532 | self._description: Optional[str] = description 533 | self._defaults: Mapping[str, Any] = MappingProxyType(defaults or {}) 534 | 535 | 536 | ParserType = TypeVar("ParserType", bound="Parser") 537 | 538 | 539 | # noinspection PyProtectedMember 540 | class Parser(AbstractParser, Base): 541 | HELP_APPENDIX_PREAMBLE = ( 542 | " Default values will based on following " 543 | "configuration files {configs}. " 544 | ) 545 | HELP_APPENDIX_CURRENT = ( 546 | "Now {num_existent} files has been applied {existent}. " 547 | ) 548 | HELP_APPENDIX_END = ( 549 | "The configuration files is INI-formatted files " 550 | "where configuration groups is INI sections. " 551 | "See more https://pypi.org/project/argclass/#configs" 552 | ) 553 | 554 | def _add_argument( 555 | self, parser: Any, argument: TypedArgument, dest: str, *aliases: str, 556 | ) -> Tuple[str, Action]: 557 | kwargs = argument.get_kwargs() 558 | 559 | if not argument.is_positional: 560 | kwargs["dest"] = dest 561 | 562 | if argument.default is not None and not argument.secret: 563 | kwargs["help"] = ( 564 | f"{kwargs.get('help', '')} (default: {argument.default})" 565 | ).strip() 566 | 567 | if argument.env_var is not None: 568 | default = kwargs.get("default") 569 | kwargs["default"] = os.getenv(argument.env_var, default) 570 | 571 | if kwargs["default"] and argument.is_nargs: 572 | kwargs["default"] = list( 573 | map( 574 | argument.type or str, 575 | ast.literal_eval(kwargs["default"]), 576 | ), 577 | ) 578 | 579 | kwargs["help"] = ( 580 | f"{kwargs.get('help', '')} [ENV: {argument.env_var}]" 581 | ).strip() 582 | 583 | if argument.env_var in os.environ: 584 | self._used_env_vars.add(argument.env_var) 585 | 586 | if kwargs.get("default"): 587 | kwargs["required"] = False 588 | 589 | return dest, parser.add_argument(*aliases, **kwargs) 590 | 591 | @staticmethod 592 | def get_cli_name(name: str) -> str: 593 | return name.replace("_", "-") 594 | 595 | def get_env_var(self, name: str, argument: TypedArgument) -> Optional[str]: 596 | if argument.env_var is not None: 597 | return argument.env_var 598 | if self._auto_env_var_prefix is not None: 599 | return f"{self._auto_env_var_prefix}{name}".upper() 600 | return None 601 | 602 | def __init__( 603 | self, config_files: Iterable[Union[str, Path]] = (), 604 | auto_env_var_prefix: Optional[str] = None, 605 | **kwargs: Any, 606 | ): 607 | super().__init__() 608 | self.current_subparsers = () 609 | self._config_files = config_files 610 | self._config, filenames = read_configs(*config_files) 611 | 612 | self._epilog = kwargs.pop("epilog", "") 613 | 614 | if config_files: 615 | # If not config files, we don't need to add any to the epilog 616 | self._epilog += self.HELP_APPENDIX_PREAMBLE.format( 617 | configs=repr(config_files), 618 | ) 619 | 620 | if filenames: 621 | self._epilog += self.HELP_APPENDIX_CURRENT.format( 622 | num_existent=len(filenames), 623 | existent=repr(list(map(str, filenames))), 624 | ) 625 | self._epilog += self.HELP_APPENDIX_END 626 | 627 | self._auto_env_var_prefix = auto_env_var_prefix 628 | self._parser_kwargs = kwargs 629 | self._used_env_vars: Set[str] = set() 630 | 631 | @property 632 | def current_subparser(self) -> Optional["AbstractParser"]: 633 | if not self.current_subparsers: 634 | return None 635 | return self.current_subparsers[0] 636 | 637 | def _make_parser( 638 | self, parser: Optional[ArgumentParser] = None, 639 | ) -> Tuple[ArgumentParser, DestinationsType]: 640 | if parser is None: 641 | parser = ArgumentParser( 642 | epilog=self._epilog, **self._parser_kwargs, 643 | ) 644 | 645 | destinations: DestinationsType = collections.defaultdict(set) 646 | 647 | self._fill_arguments(destinations, parser) 648 | self._fill_groups(destinations, parser) 649 | if self.__subparsers__: 650 | self._fill_subparsers(destinations, parser) 651 | 652 | return parser, destinations 653 | 654 | def _fill_arguments( 655 | self, destinations: DestinationsType, parser: ArgumentParser, 656 | ) -> None: 657 | for name, argument in self.__arguments__.items(): 658 | aliases = set(argument.aliases) 659 | 660 | # Add default alias 661 | if not aliases: 662 | aliases.add(f"--{self.get_cli_name(name)}") 663 | 664 | default = self._config.get(name, argument.default) 665 | argument = argument.copy( 666 | aliases=aliases, 667 | env_var=self.get_env_var(name, argument), 668 | default=default, 669 | ) 670 | 671 | if default and argument.required: 672 | argument = argument.copy(required=False) 673 | 674 | dest, action = self._add_argument(parser, argument, name, *aliases) 675 | destinations[dest].add( 676 | Destination( 677 | target=self, 678 | attribute=name, 679 | argument=argument, 680 | action=action, 681 | ), 682 | ) 683 | 684 | def _fill_groups( 685 | self, destinations: DestinationsType, parser: ArgumentParser, 686 | ) -> None: 687 | for group_name, group in self.__argument_groups__.items(): 688 | group_parser = parser.add_argument_group( 689 | title=group._title, 690 | description=group._description, 691 | ) 692 | config = self._config.get(group_name, {}) 693 | 694 | for name, argument in group.__arguments__.items(): 695 | aliases = set(argument.aliases) 696 | dest = "_".join((group._prefix or group_name, name)) 697 | 698 | if not aliases: 699 | aliases.add(f"--{self.get_cli_name(dest)}") 700 | 701 | default = config.get( 702 | name, group._defaults.get(name, argument.default), 703 | ) 704 | argument = argument.copy( 705 | default=default, 706 | env_var=self.get_env_var(dest, argument), 707 | ) 708 | dest, action = self._add_argument( 709 | group_parser, argument, dest, *aliases, 710 | ) 711 | destinations[dest].add( 712 | Destination( 713 | target=group, 714 | attribute=name, 715 | argument=argument, 716 | action=action, 717 | ), 718 | ) 719 | 720 | def _fill_subparsers( 721 | self, destinations: DestinationsType, parser: ArgumentParser, 722 | ) -> None: 723 | subparsers = parser.add_subparsers() 724 | subparser: AbstractParser 725 | destinations["current_subparsers"].add( 726 | Destination( 727 | target=self, 728 | attribute="current_subparsers", 729 | argument=None, 730 | action=None, 731 | ), 732 | ) 733 | 734 | for subparser_name, subparser in self.__subparsers__.items(): 735 | current_parser, subparser_dests = ( 736 | subparser._make_parser( 737 | subparsers.add_parser( 738 | subparser_name, **subparser._parser_kwargs, 739 | ), 740 | ) 741 | ) 742 | subparser.__parent__ = self 743 | current_parser.set_defaults( 744 | current_subparsers=tuple(subparser._get_chain()), 745 | ) 746 | current_target: Base 747 | for dest, values in subparser_dests.items(): 748 | for target, name, argument, action in values: 749 | for target_destination in subparser_dests.get(dest, [None]): 750 | current_target = subparser 751 | 752 | if target_destination is not None: 753 | current_target = target_destination.target 754 | 755 | destinations[dest].add( 756 | Destination( 757 | target=current_target, 758 | attribute=name, 759 | argument=argument, 760 | action=action, 761 | ), 762 | ) 763 | 764 | def parse_args( 765 | self: ParserType, args: Optional[Sequence[str]] = None, 766 | ) -> ParserType: 767 | self._used_env_vars.clear() 768 | parser, destinations = self._make_parser() 769 | parsed_ns = parser.parse_args(args) 770 | 771 | parsed_value: Any 772 | current_subparsers = getattr(parsed_ns, "current_subparsers", ()) 773 | 774 | for key, values in destinations.items(): 775 | parsed_value = getattr(parsed_ns, key, None) 776 | for target, name, argument, action in values: 777 | if ( 778 | target is not self and 779 | isinstance(target, Parser) and 780 | target not in current_subparsers 781 | ): 782 | continue 783 | 784 | if isinstance(action, ConfigAction): 785 | action(parser, parsed_ns, parsed_value, None) 786 | parsed_value = getattr(parsed_ns, key) 787 | 788 | if argument is not None: 789 | if argument.secret: 790 | parsed_value = SecretString(parsed_value) 791 | if argument.converter is not None: 792 | if argument.nargs and parsed_value is None: 793 | parsed_value = [] 794 | parsed_value = argument.converter(parsed_value) 795 | setattr(target, name, parsed_value) 796 | 797 | return self 798 | 799 | def print_help(self) -> None: 800 | parser, _ = self._make_parser() 801 | return parser.print_help() 802 | 803 | def sanitize_env(self) -> None: 804 | for name in self._used_env_vars: 805 | os.environ.pop(name, None) 806 | self._used_env_vars.clear() 807 | 808 | def __call__(self) -> Any: 809 | """ 810 | Override this function if you want to equip your parser with an action. 811 | It will be like replacing the main function in a classical case. 812 | 813 | >>> import argclass 814 | >>> class MyParser(argclass.Parser): 815 | ... dry_run: bool = False 816 | ... def __call__(self): 817 | ... print("Dry run mode is:", self.dry_run) 818 | ... 819 | >>> parser = MyParser() 820 | >>> parser.parse_args([]) 821 | >>> parser() 822 | Dry run mode is: False 823 | >>> parser.parse_args(['--dry-run']) 824 | >>> parser() 825 | Dry run mode is: True 826 | """ 827 | if self.current_subparser is not None: 828 | return self.current_subparser() 829 | self.print_help() 830 | exit(errno.EINVAL) 831 | 832 | 833 | NargsType = Union[Nargs, Literal["*", "+", "?"], int, None] 834 | 835 | 836 | # noinspection PyPep8Naming 837 | def Argument( 838 | *aliases: str, 839 | action: Union[Actions, Type[Action]] = Actions.default(), 840 | choices: Optional[Iterable[str]] = None, 841 | const: Optional[Any] = None, 842 | converter: Optional[ConverterType] = None, 843 | default: Optional[Any] = None, 844 | secret: bool = False, 845 | env_var: Optional[str] = None, 846 | help: Optional[str] = None, 847 | metavar: Optional[str] = None, 848 | nargs: NargsType = None, 849 | required: Optional[bool] = None, 850 | type: Optional[Callable[[str], Any]] = None, 851 | ) -> Any: 852 | return TypedArgument( 853 | action=action, 854 | aliases=aliases, 855 | choices=choices, 856 | const=const, 857 | converter=converter, 858 | default=default, 859 | secret=secret, 860 | env_var=env_var, 861 | help=help, 862 | metavar=metavar, 863 | nargs=nargs, 864 | required=required, 865 | type=type, 866 | ) # type: ignore 867 | 868 | 869 | # noinspection PyPep8Naming 870 | def EnumArgument( 871 | enum: EnumMeta, 872 | *aliases: str, 873 | action: Union[Actions, Type[Action]] = Actions.default(), 874 | const: Optional[Any] = None, 875 | default: Optional[Any] = None, 876 | secret: bool = False, 877 | env_var: Optional[str] = None, 878 | help: Optional[str] = None, 879 | metavar: Optional[str] = None, 880 | nargs: NargsType = None, 881 | required: Optional[bool] = None, 882 | ) -> Any: 883 | 884 | def converter(value: Any) -> EnumMeta: 885 | if isinstance(value, Enum): 886 | return value # type: ignore 887 | return enum[value] 888 | 889 | return TypedArgument( # type: ignore 890 | aliases=aliases, 891 | action=action, 892 | choices=sorted(enum.__members__), 893 | const=const, 894 | converter=converter, 895 | default=default, 896 | secret=secret, 897 | env_var=env_var, 898 | help=help, 899 | metavar=metavar, 900 | nargs=nargs, 901 | required=required, 902 | ) 903 | 904 | 905 | Secret = partial(Argument, secret=True) 906 | 907 | 908 | # noinspection PyPep8Naming 909 | def Config( 910 | *aliases: str, 911 | search_paths: Optional[Iterable[Union[Path, str]]] = None, 912 | choices: Optional[Iterable[str]] = None, 913 | converter: Optional[ConverterType] = None, 914 | const: Optional[Any] = None, 915 | default: Optional[Any] = None, 916 | env_var: Optional[str] = None, 917 | help: Optional[str] = None, 918 | metavar: Optional[str] = None, 919 | nargs: NargsType = None, 920 | required: Optional[bool] = None, 921 | config_class: Type[ConfigArgument] = INIConfig, 922 | ) -> Any: 923 | return config_class( 924 | search_paths=search_paths, 925 | aliases=aliases, 926 | choices=choices, 927 | const=const, 928 | converter=converter, 929 | default=default, 930 | env_var=env_var, 931 | help=help, 932 | metavar=metavar, 933 | nargs=nargs, 934 | required=required, 935 | ) # type: ignore 936 | 937 | 938 | class LogLevelEnum(IntEnum): 939 | debug = logging.DEBUG 940 | info = logging.INFO 941 | warning = logging.WARNING 942 | error = logging.ERROR 943 | critical = logging.CRITICAL 944 | 945 | 946 | LogLevel: LogLevelEnum = EnumArgument(LogLevelEnum, default="info") 947 | 948 | 949 | __all__ = ( 950 | "Actions", 951 | "Argument", 952 | "ConfigArgument", 953 | "EnumArgument", 954 | "Group", 955 | "INIConfig", 956 | "JSONConfig", 957 | "LogLevel", 958 | "LogLevelEnum", 959 | "Nargs", 960 | "Parser", 961 | "SecretString", 962 | "TypedArgument", 963 | ) 964 | -------------------------------------------------------------------------------- /argclass/__main__.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import sys 3 | from pathlib import Path 4 | 5 | import argclass 6 | 7 | 8 | class GreetCommand(argclass.Parser): 9 | user: str = argclass.Argument("user", help="User to greet") 10 | 11 | def __call__(self) -> int: 12 | print(f"Hello, {self.user}!") 13 | return 0 14 | 15 | 16 | class Parser(argclass.Parser): 17 | verbose: bool = False 18 | secret_key: str = argclass.Secret(help="Secret API key") 19 | greet = GreetCommand() 20 | 21 | 22 | def main() -> None: 23 | parser = Parser( 24 | prog=f"{Path(sys.executable).name} -m argclass", 25 | formatter_class=argparse.RawDescriptionHelpFormatter, 26 | description=( 27 | "This code produces this help:\n\n```" 28 | f"python\n{open(__file__).read().strip()}\n```" 29 | ), 30 | ) 31 | parser.parse_args() 32 | parser.sanitize_env() 33 | exit(parser()) 34 | 35 | 36 | if __name__ == "__main__": 37 | main() 38 | -------------------------------------------------------------------------------- /argclass/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mosquito/argclass/09c63a59124ef865773f4ee45097f8904dcac34f/argclass/py.typed -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "attrs" 5 | version = "24.2.0" 6 | description = "Classes Without Boilerplate" 7 | optional = false 8 | python-versions = ">=3.7" 9 | files = [ 10 | {file = "attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"}, 11 | {file = "attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346"}, 12 | ] 13 | 14 | [package.extras] 15 | benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] 16 | cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] 17 | dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] 18 | docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] 19 | tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] 20 | tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] 21 | 22 | [[package]] 23 | name = "certifi" 24 | version = "2024.8.30" 25 | description = "Python package for providing Mozilla's CA Bundle." 26 | optional = false 27 | python-versions = ">=3.6" 28 | files = [ 29 | {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"}, 30 | {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, 31 | ] 32 | 33 | [[package]] 34 | name = "charset-normalizer" 35 | version = "3.4.0" 36 | description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 37 | optional = false 38 | python-versions = ">=3.7.0" 39 | files = [ 40 | {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6"}, 41 | {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b"}, 42 | {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99"}, 43 | {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca"}, 44 | {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d"}, 45 | {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7"}, 46 | {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3"}, 47 | {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907"}, 48 | {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b"}, 49 | {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912"}, 50 | {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95"}, 51 | {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e"}, 52 | {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe"}, 53 | {file = "charset_normalizer-3.4.0-cp310-cp310-win32.whl", hash = "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc"}, 54 | {file = "charset_normalizer-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749"}, 55 | {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c"}, 56 | {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944"}, 57 | {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee"}, 58 | {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c"}, 59 | {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6"}, 60 | {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea"}, 61 | {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc"}, 62 | {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5"}, 63 | {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594"}, 64 | {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c"}, 65 | {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365"}, 66 | {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129"}, 67 | {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236"}, 68 | {file = "charset_normalizer-3.4.0-cp311-cp311-win32.whl", hash = "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99"}, 69 | {file = "charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27"}, 70 | {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6"}, 71 | {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf"}, 72 | {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db"}, 73 | {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1"}, 74 | {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03"}, 75 | {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284"}, 76 | {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15"}, 77 | {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8"}, 78 | {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2"}, 79 | {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719"}, 80 | {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631"}, 81 | {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b"}, 82 | {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565"}, 83 | {file = "charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7"}, 84 | {file = "charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9"}, 85 | {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114"}, 86 | {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed"}, 87 | {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250"}, 88 | {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920"}, 89 | {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64"}, 90 | {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23"}, 91 | {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc"}, 92 | {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d"}, 93 | {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88"}, 94 | {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90"}, 95 | {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b"}, 96 | {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d"}, 97 | {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482"}, 98 | {file = "charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67"}, 99 | {file = "charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b"}, 100 | {file = "charset_normalizer-3.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:dbe03226baf438ac4fda9e2d0715022fd579cb641c4cf639fa40d53b2fe6f3e2"}, 101 | {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd9a8bd8900e65504a305bf8ae6fa9fbc66de94178c420791d0293702fce2df7"}, 102 | {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8831399554b92b72af5932cdbbd4ddc55c55f631bb13ff8fe4e6536a06c5c51"}, 103 | {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a14969b8691f7998e74663b77b4c36c0337cb1df552da83d5c9004a93afdb574"}, 104 | {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcaf7c1524c0542ee2fc82cc8ec337f7a9f7edee2532421ab200d2b920fc97cf"}, 105 | {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425c5f215d0eecee9a56cdb703203dda90423247421bf0d67125add85d0c4455"}, 106 | {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:d5b054862739d276e09928de37c79ddeec42a6e1bfc55863be96a36ba22926f6"}, 107 | {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:f3e73a4255342d4eb26ef6df01e3962e73aa29baa3124a8e824c5d3364a65748"}, 108 | {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:2f6c34da58ea9c1a9515621f4d9ac379871a8f21168ba1b5e09d74250de5ad62"}, 109 | {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:f09cb5a7bbe1ecae6e87901a2eb23e0256bb524a79ccc53eb0b7629fbe7677c4"}, 110 | {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:0099d79bdfcf5c1f0c2c72f91516702ebf8b0b8ddd8905f97a8aecf49712c621"}, 111 | {file = "charset_normalizer-3.4.0-cp37-cp37m-win32.whl", hash = "sha256:9c98230f5042f4945f957d006edccc2af1e03ed5e37ce7c373f00a5a4daa6149"}, 112 | {file = "charset_normalizer-3.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:62f60aebecfc7f4b82e3f639a7d1433a20ec32824db2199a11ad4f5e146ef5ee"}, 113 | {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:af73657b7a68211996527dbfeffbb0864e043d270580c5aef06dc4b659a4b578"}, 114 | {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cab5d0b79d987c67f3b9e9c53f54a61360422a5a0bc075f43cab5621d530c3b6"}, 115 | {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9289fd5dddcf57bab41d044f1756550f9e7cf0c8e373b8cdf0ce8773dc4bd417"}, 116 | {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b493a043635eb376e50eedf7818f2f322eabbaa974e948bd8bdd29eb7ef2a51"}, 117 | {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fa2566ca27d67c86569e8c85297aaf413ffab85a8960500f12ea34ff98e4c41"}, 118 | {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8e538f46104c815be19c975572d74afb53f29650ea2025bbfaef359d2de2f7f"}, 119 | {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fd30dc99682dc2c603c2b315bded2799019cea829f8bf57dc6b61efde6611c8"}, 120 | {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2006769bd1640bdf4d5641c69a3d63b71b81445473cac5ded39740a226fa88ab"}, 121 | {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:dc15e99b2d8a656f8e666854404f1ba54765871104e50c8e9813af8a7db07f12"}, 122 | {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ab2e5bef076f5a235c3774b4f4028a680432cded7cad37bba0fd90d64b187d19"}, 123 | {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:4ec9dd88a5b71abfc74e9df5ebe7921c35cbb3b641181a531ca65cdb5e8e4dea"}, 124 | {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:43193c5cda5d612f247172016c4bb71251c784d7a4d9314677186a838ad34858"}, 125 | {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:aa693779a8b50cd97570e5a0f343538a8dbd3e496fa5dcb87e29406ad0299654"}, 126 | {file = "charset_normalizer-3.4.0-cp38-cp38-win32.whl", hash = "sha256:7706f5850360ac01d80c89bcef1640683cc12ed87f42579dab6c5d3ed6888613"}, 127 | {file = "charset_normalizer-3.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:c3e446d253bd88f6377260d07c895816ebf33ffffd56c1c792b13bff9c3e1ade"}, 128 | {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa"}, 129 | {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a"}, 130 | {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0"}, 131 | {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a"}, 132 | {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242"}, 133 | {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b"}, 134 | {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62"}, 135 | {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0"}, 136 | {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd"}, 137 | {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be"}, 138 | {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d"}, 139 | {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3"}, 140 | {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742"}, 141 | {file = "charset_normalizer-3.4.0-cp39-cp39-win32.whl", hash = "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2"}, 142 | {file = "charset_normalizer-3.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca"}, 143 | {file = "charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079"}, 144 | {file = "charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e"}, 145 | ] 146 | 147 | [[package]] 148 | name = "colorama" 149 | version = "0.4.6" 150 | description = "Cross-platform colored terminal text." 151 | optional = false 152 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 153 | files = [ 154 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 155 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 156 | ] 157 | 158 | [[package]] 159 | name = "coverage" 160 | version = "6.5.0" 161 | description = "Code coverage measurement for Python" 162 | optional = false 163 | python-versions = ">=3.7" 164 | files = [ 165 | {file = "coverage-6.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef8674b0ee8cc11e2d574e3e2998aea5df5ab242e012286824ea3c6970580e53"}, 166 | {file = "coverage-6.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:784f53ebc9f3fd0e2a3f6a78b2be1bd1f5575d7863e10c6e12504f240fd06660"}, 167 | {file = "coverage-6.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4a5be1748d538a710f87542f22c2cad22f80545a847ad91ce45e77417293eb4"}, 168 | {file = "coverage-6.5.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:83516205e254a0cb77d2d7bb3632ee019d93d9f4005de31dca0a8c3667d5bc04"}, 169 | {file = "coverage-6.5.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af4fffaffc4067232253715065e30c5a7ec6faac36f8fc8d6f64263b15f74db0"}, 170 | {file = "coverage-6.5.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:97117225cdd992a9c2a5515db1f66b59db634f59d0679ca1fa3fe8da32749cae"}, 171 | {file = "coverage-6.5.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a1170fa54185845505fbfa672f1c1ab175446c887cce8212c44149581cf2d466"}, 172 | {file = "coverage-6.5.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:11b990d520ea75e7ee8dcab5bc908072aaada194a794db9f6d7d5cfd19661e5a"}, 173 | {file = "coverage-6.5.0-cp310-cp310-win32.whl", hash = "sha256:5dbec3b9095749390c09ab7c89d314727f18800060d8d24e87f01fb9cfb40b32"}, 174 | {file = "coverage-6.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:59f53f1dc5b656cafb1badd0feb428c1e7bc19b867479ff72f7a9dd9b479f10e"}, 175 | {file = "coverage-6.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4a5375e28c5191ac38cca59b38edd33ef4cc914732c916f2929029b4bfb50795"}, 176 | {file = "coverage-6.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4ed2820d919351f4167e52425e096af41bfabacb1857186c1ea32ff9983ed75"}, 177 | {file = "coverage-6.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:33a7da4376d5977fbf0a8ed91c4dffaaa8dbf0ddbf4c8eea500a2486d8bc4d7b"}, 178 | {file = "coverage-6.5.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8fb6cf131ac4070c9c5a3e21de0f7dc5a0fbe8bc77c9456ced896c12fcdad91"}, 179 | {file = "coverage-6.5.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a6b7d95969b8845250586f269e81e5dfdd8ff828ddeb8567a4a2eaa7313460c4"}, 180 | {file = "coverage-6.5.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:1ef221513e6f68b69ee9e159506d583d31aa3567e0ae84eaad9d6ec1107dddaa"}, 181 | {file = "coverage-6.5.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cca4435eebea7962a52bdb216dec27215d0df64cf27fc1dd538415f5d2b9da6b"}, 182 | {file = "coverage-6.5.0-cp311-cp311-win32.whl", hash = "sha256:98e8a10b7a314f454d9eff4216a9a94d143a7ee65018dd12442e898ee2310578"}, 183 | {file = "coverage-6.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:bc8ef5e043a2af066fa8cbfc6e708d58017024dc4345a1f9757b329a249f041b"}, 184 | {file = "coverage-6.5.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4433b90fae13f86fafff0b326453dd42fc9a639a0d9e4eec4d366436d1a41b6d"}, 185 | {file = "coverage-6.5.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4f05d88d9a80ad3cac6244d36dd89a3c00abc16371769f1340101d3cb899fc3"}, 186 | {file = "coverage-6.5.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:94e2565443291bd778421856bc975d351738963071e9b8839ca1fc08b42d4bef"}, 187 | {file = "coverage-6.5.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:027018943386e7b942fa832372ebc120155fd970837489896099f5cfa2890f79"}, 188 | {file = "coverage-6.5.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:255758a1e3b61db372ec2736c8e2a1fdfaf563977eedbdf131de003ca5779b7d"}, 189 | {file = "coverage-6.5.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:851cf4ff24062c6aec510a454b2584f6e998cada52d4cb58c5e233d07172e50c"}, 190 | {file = "coverage-6.5.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:12adf310e4aafddc58afdb04d686795f33f4d7a6fa67a7a9d4ce7d6ae24d949f"}, 191 | {file = "coverage-6.5.0-cp37-cp37m-win32.whl", hash = "sha256:b5604380f3415ba69de87a289a2b56687faa4fe04dbee0754bfcae433489316b"}, 192 | {file = "coverage-6.5.0-cp37-cp37m-win_amd64.whl", hash = "sha256:4a8dbc1f0fbb2ae3de73eb0bdbb914180c7abfbf258e90b311dcd4f585d44bd2"}, 193 | {file = "coverage-6.5.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d900bb429fdfd7f511f868cedd03a6bbb142f3f9118c09b99ef8dc9bf9643c3c"}, 194 | {file = "coverage-6.5.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2198ea6fc548de52adc826f62cb18554caedfb1d26548c1b7c88d8f7faa8f6ba"}, 195 | {file = "coverage-6.5.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c4459b3de97b75e3bd6b7d4b7f0db13f17f504f3d13e2a7c623786289dd670e"}, 196 | {file = "coverage-6.5.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:20c8ac5386253717e5ccc827caad43ed66fea0efe255727b1053a8154d952398"}, 197 | {file = "coverage-6.5.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b07130585d54fe8dff3d97b93b0e20290de974dc8177c320aeaf23459219c0b"}, 198 | {file = "coverage-6.5.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:dbdb91cd8c048c2b09eb17713b0c12a54fbd587d79adcebad543bc0cd9a3410b"}, 199 | {file = "coverage-6.5.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:de3001a203182842a4630e7b8d1a2c7c07ec1b45d3084a83d5d227a3806f530f"}, 200 | {file = "coverage-6.5.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e07f4a4a9b41583d6eabec04f8b68076ab3cd44c20bd29332c6572dda36f372e"}, 201 | {file = "coverage-6.5.0-cp38-cp38-win32.whl", hash = "sha256:6d4817234349a80dbf03640cec6109cd90cba068330703fa65ddf56b60223a6d"}, 202 | {file = "coverage-6.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:7ccf362abd726b0410bf8911c31fbf97f09f8f1061f8c1cf03dfc4b6372848f6"}, 203 | {file = "coverage-6.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:633713d70ad6bfc49b34ead4060531658dc6dfc9b3eb7d8a716d5873377ab745"}, 204 | {file = "coverage-6.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:95203854f974e07af96358c0b261f1048d8e1083f2de9b1c565e1be4a3a48cfc"}, 205 | {file = "coverage-6.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9023e237f4c02ff739581ef35969c3739445fb059b060ca51771e69101efffe"}, 206 | {file = "coverage-6.5.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:265de0fa6778d07de30bcf4d9dc471c3dc4314a23a3c6603d356a3c9abc2dfcf"}, 207 | {file = "coverage-6.5.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f830ed581b45b82451a40faabb89c84e1a998124ee4212d440e9c6cf70083e5"}, 208 | {file = "coverage-6.5.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7b6be138d61e458e18d8e6ddcddd36dd96215edfe5f1168de0b1b32635839b62"}, 209 | {file = "coverage-6.5.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:42eafe6778551cf006a7c43153af1211c3aaab658d4d66fa5fcc021613d02518"}, 210 | {file = "coverage-6.5.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:723e8130d4ecc8f56e9a611e73b31219595baa3bb252d539206f7bbbab6ffc1f"}, 211 | {file = "coverage-6.5.0-cp39-cp39-win32.whl", hash = "sha256:d9ecf0829c6a62b9b573c7bb6d4dcd6ba8b6f80be9ba4fc7ed50bf4ac9aecd72"}, 212 | {file = "coverage-6.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:fc2af30ed0d5ae0b1abdb4ebdce598eafd5b35397d4d75deb341a614d333d987"}, 213 | {file = "coverage-6.5.0-pp36.pp37.pp38-none-any.whl", hash = "sha256:1431986dac3923c5945271f169f59c45b8802a114c8f548d611f2015133df77a"}, 214 | {file = "coverage-6.5.0.tar.gz", hash = "sha256:f642e90754ee3e06b0e7e51bce3379590e76b7f76b708e1a71ff043f87025c84"}, 215 | ] 216 | 217 | [package.dependencies] 218 | tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} 219 | 220 | [package.extras] 221 | toml = ["tomli"] 222 | 223 | [[package]] 224 | name = "coveralls" 225 | version = "3.3.1" 226 | description = "Show coverage stats online via coveralls.io" 227 | optional = false 228 | python-versions = ">= 3.5" 229 | files = [ 230 | {file = "coveralls-3.3.1-py2.py3-none-any.whl", hash = "sha256:f42015f31d386b351d4226389b387ae173207058832fbf5c8ec4b40e27b16026"}, 231 | {file = "coveralls-3.3.1.tar.gz", hash = "sha256:b32a8bb5d2df585207c119d6c01567b81fba690c9c10a753bfe27a335bfc43ea"}, 232 | ] 233 | 234 | [package.dependencies] 235 | coverage = ">=4.1,<6.0.dev0 || >6.1,<6.1.1 || >6.1.1,<7.0" 236 | docopt = ">=0.6.1" 237 | requests = ">=1.0.0" 238 | 239 | [package.extras] 240 | yaml = ["PyYAML (>=3.10)"] 241 | 242 | [[package]] 243 | name = "docopt" 244 | version = "0.6.2" 245 | description = "Pythonic argument parser, that will make you smile" 246 | optional = false 247 | python-versions = "*" 248 | files = [ 249 | {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, 250 | ] 251 | 252 | [[package]] 253 | name = "exceptiongroup" 254 | version = "1.2.2" 255 | description = "Backport of PEP 654 (exception groups)" 256 | optional = false 257 | python-versions = ">=3.7" 258 | files = [ 259 | {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, 260 | {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, 261 | ] 262 | 263 | [package.extras] 264 | test = ["pytest (>=6)"] 265 | 266 | [[package]] 267 | name = "idna" 268 | version = "3.10" 269 | description = "Internationalized Domain Names in Applications (IDNA)" 270 | optional = false 271 | python-versions = ">=3.6" 272 | files = [ 273 | {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, 274 | {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, 275 | ] 276 | 277 | [package.extras] 278 | all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] 279 | 280 | [[package]] 281 | name = "iniconfig" 282 | version = "2.0.0" 283 | description = "brain-dead simple config-ini parsing" 284 | optional = false 285 | python-versions = ">=3.7" 286 | files = [ 287 | {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, 288 | {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, 289 | ] 290 | 291 | [[package]] 292 | name = "markdown-pytest" 293 | version = "0.3.2" 294 | description = "Pytest plugin for runs tests directly from Markdown files" 295 | optional = false 296 | python-versions = "<4.0,>=3.8" 297 | files = [ 298 | {file = "markdown_pytest-0.3.2-py3-none-any.whl", hash = "sha256:2faabe7bfba232b2b2075d6b552580d370733c5ff77934f41bcfcb960b06d09f"}, 299 | {file = "markdown_pytest-0.3.2.tar.gz", hash = "sha256:ddb1b746e18fbdaab97bc2058b90c0b3d71063c8265a88a8c755f433f94a5e12"}, 300 | ] 301 | 302 | [package.dependencies] 303 | pytest-subtests = ">=0.12.0" 304 | 305 | [[package]] 306 | name = "mccabe" 307 | version = "0.7.0" 308 | description = "McCabe checker, plugin for flake8" 309 | optional = false 310 | python-versions = ">=3.6" 311 | files = [ 312 | {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, 313 | {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, 314 | ] 315 | 316 | [[package]] 317 | name = "mypy" 318 | version = "0.991" 319 | description = "Optional static typing for Python" 320 | optional = false 321 | python-versions = ">=3.7" 322 | files = [ 323 | {file = "mypy-0.991-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7d17e0a9707d0772f4a7b878f04b4fd11f6f5bcb9b3813975a9b13c9332153ab"}, 324 | {file = "mypy-0.991-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0714258640194d75677e86c786e80ccf294972cc76885d3ebbb560f11db0003d"}, 325 | {file = "mypy-0.991-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0c8f3be99e8a8bd403caa8c03be619544bc2c77a7093685dcf308c6b109426c6"}, 326 | {file = "mypy-0.991-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc9ec663ed6c8f15f4ae9d3c04c989b744436c16d26580eaa760ae9dd5d662eb"}, 327 | {file = "mypy-0.991-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4307270436fd7694b41f913eb09210faff27ea4979ecbcd849e57d2da2f65305"}, 328 | {file = "mypy-0.991-cp310-cp310-win_amd64.whl", hash = "sha256:901c2c269c616e6cb0998b33d4adbb4a6af0ac4ce5cd078afd7bc95830e62c1c"}, 329 | {file = "mypy-0.991-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d13674f3fb73805ba0c45eb6c0c3053d218aa1f7abead6e446d474529aafc372"}, 330 | {file = "mypy-0.991-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c8cd4fb70e8584ca1ed5805cbc7c017a3d1a29fb450621089ffed3e99d1857f"}, 331 | {file = "mypy-0.991-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:209ee89fbb0deed518605edddd234af80506aec932ad28d73c08f1400ef80a33"}, 332 | {file = "mypy-0.991-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:37bd02ebf9d10e05b00d71302d2c2e6ca333e6c2a8584a98c00e038db8121f05"}, 333 | {file = "mypy-0.991-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:26efb2fcc6b67e4d5a55561f39176821d2adf88f2745ddc72751b7890f3194ad"}, 334 | {file = "mypy-0.991-cp311-cp311-win_amd64.whl", hash = "sha256:3a700330b567114b673cf8ee7388e949f843b356a73b5ab22dd7cff4742a5297"}, 335 | {file = "mypy-0.991-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:1f7d1a520373e2272b10796c3ff721ea1a0712288cafaa95931e66aa15798813"}, 336 | {file = "mypy-0.991-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:641411733b127c3e0dab94c45af15fea99e4468f99ac88b39efb1ad677da5711"}, 337 | {file = "mypy-0.991-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:3d80e36b7d7a9259b740be6d8d906221789b0d836201af4234093cae89ced0cd"}, 338 | {file = "mypy-0.991-cp37-cp37m-win_amd64.whl", hash = "sha256:e62ebaad93be3ad1a828a11e90f0e76f15449371ffeecca4a0a0b9adc99abcef"}, 339 | {file = "mypy-0.991-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:b86ce2c1866a748c0f6faca5232059f881cda6dda2a893b9a8373353cfe3715a"}, 340 | {file = "mypy-0.991-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ac6e503823143464538efda0e8e356d871557ef60ccd38f8824a4257acc18d93"}, 341 | {file = "mypy-0.991-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:0cca5adf694af539aeaa6ac633a7afe9bbd760df9d31be55ab780b77ab5ae8bf"}, 342 | {file = "mypy-0.991-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a12c56bf73cdab116df96e4ff39610b92a348cc99a1307e1da3c3768bbb5b135"}, 343 | {file = "mypy-0.991-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:652b651d42f155033a1967739788c436491b577b6a44e4c39fb340d0ee7f0d70"}, 344 | {file = "mypy-0.991-cp38-cp38-win_amd64.whl", hash = "sha256:4175593dc25d9da12f7de8de873a33f9b2b8bdb4e827a7cae952e5b1a342e243"}, 345 | {file = "mypy-0.991-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:98e781cd35c0acf33eb0295e8b9c55cdbef64fcb35f6d3aa2186f289bed6e80d"}, 346 | {file = "mypy-0.991-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6d7464bac72a85cb3491c7e92b5b62f3dcccb8af26826257760a552a5e244aa5"}, 347 | {file = "mypy-0.991-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c9166b3f81a10cdf9b49f2d594b21b31adadb3d5e9db9b834866c3258b695be3"}, 348 | {file = "mypy-0.991-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8472f736a5bfb159a5e36740847808f6f5b659960115ff29c7cecec1741c648"}, 349 | {file = "mypy-0.991-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5e80e758243b97b618cdf22004beb09e8a2de1af481382e4d84bc52152d1c476"}, 350 | {file = "mypy-0.991-cp39-cp39-win_amd64.whl", hash = "sha256:74e259b5c19f70d35fcc1ad3d56499065c601dfe94ff67ae48b85596b9ec1461"}, 351 | {file = "mypy-0.991-py3-none-any.whl", hash = "sha256:de32edc9b0a7e67c2775e574cb061a537660e51210fbf6006b0b36ea695ae9bb"}, 352 | {file = "mypy-0.991.tar.gz", hash = "sha256:3c0165ba8f354a6d9881809ef29f1a9318a236a6d81c690094c5df32107bde06"}, 353 | ] 354 | 355 | [package.dependencies] 356 | mypy-extensions = ">=0.4.3" 357 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 358 | typing-extensions = ">=3.10" 359 | 360 | [package.extras] 361 | dmypy = ["psutil (>=4.0)"] 362 | install-types = ["pip"] 363 | python2 = ["typed-ast (>=1.4.0,<2)"] 364 | reports = ["lxml"] 365 | 366 | [[package]] 367 | name = "mypy-extensions" 368 | version = "1.0.0" 369 | description = "Type system extensions for programs checked with the mypy type checker." 370 | optional = false 371 | python-versions = ">=3.5" 372 | files = [ 373 | {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, 374 | {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, 375 | ] 376 | 377 | [[package]] 378 | name = "packaging" 379 | version = "24.1" 380 | description = "Core utilities for Python packages" 381 | optional = false 382 | python-versions = ">=3.8" 383 | files = [ 384 | {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, 385 | {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, 386 | ] 387 | 388 | [[package]] 389 | name = "pluggy" 390 | version = "1.5.0" 391 | description = "plugin and hook calling mechanisms for python" 392 | optional = false 393 | python-versions = ">=3.8" 394 | files = [ 395 | {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, 396 | {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, 397 | ] 398 | 399 | [package.extras] 400 | dev = ["pre-commit", "tox"] 401 | testing = ["pytest", "pytest-benchmark"] 402 | 403 | [[package]] 404 | name = "pycodestyle" 405 | version = "2.12.1" 406 | description = "Python style guide checker" 407 | optional = false 408 | python-versions = ">=3.8" 409 | files = [ 410 | {file = "pycodestyle-2.12.1-py2.py3-none-any.whl", hash = "sha256:46f0fb92069a7c28ab7bb558f05bfc0110dac69a0cd23c61ea0040283a9d78b3"}, 411 | {file = "pycodestyle-2.12.1.tar.gz", hash = "sha256:6838eae08bbce4f6accd5d5572075c63626a15ee3e6f842df996bf62f6d73521"}, 412 | ] 413 | 414 | [[package]] 415 | name = "pydocstyle" 416 | version = "6.3.0" 417 | description = "Python docstring style checker" 418 | optional = false 419 | python-versions = ">=3.6" 420 | files = [ 421 | {file = "pydocstyle-6.3.0-py3-none-any.whl", hash = "sha256:118762d452a49d6b05e194ef344a55822987a462831ade91ec5c06fd2169d019"}, 422 | {file = "pydocstyle-6.3.0.tar.gz", hash = "sha256:7ce43f0c0ac87b07494eb9c0b462c0b73e6ff276807f204d6b53edc72b7e44e1"}, 423 | ] 424 | 425 | [package.dependencies] 426 | snowballstemmer = ">=2.2.0" 427 | 428 | [package.extras] 429 | toml = ["tomli (>=1.2.3)"] 430 | 431 | [[package]] 432 | name = "pyflakes" 433 | version = "3.2.0" 434 | description = "passive checker of Python programs" 435 | optional = false 436 | python-versions = ">=3.8" 437 | files = [ 438 | {file = "pyflakes-3.2.0-py2.py3-none-any.whl", hash = "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a"}, 439 | {file = "pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f"}, 440 | ] 441 | 442 | [[package]] 443 | name = "pylama" 444 | version = "8.4.1" 445 | description = "Code audit tool for python" 446 | optional = false 447 | python-versions = ">=3.7" 448 | files = [ 449 | {file = "pylama-8.4.1-py3-none-any.whl", hash = "sha256:5bbdbf5b620aba7206d688ed9fc917ecd3d73e15ec1a89647037a09fa3a86e60"}, 450 | {file = "pylama-8.4.1.tar.gz", hash = "sha256:2d4f7aecfb5b7466216d48610c7d6bad1c3990c29cdd392ad08259b161e486f6"}, 451 | ] 452 | 453 | [package.dependencies] 454 | mccabe = ">=0.7.0" 455 | pycodestyle = ">=2.9.1" 456 | pydocstyle = ">=6.1.1" 457 | pyflakes = ">=2.5.0" 458 | 459 | [package.extras] 460 | all = ["eradicate", "mypy", "pylint", "radon", "vulture"] 461 | eradicate = ["eradicate"] 462 | mypy = ["mypy"] 463 | pylint = ["pylint"] 464 | radon = ["radon"] 465 | tests = ["eradicate (>=2.0.0)", "mypy", "pylama-quotes", "pylint (>=2.11.1)", "pytest (>=7.1.2)", "pytest-mypy", "radon (>=5.1.0)", "toml", "types-setuptools", "types-toml", "vulture"] 466 | toml = ["toml (>=0.10.2)"] 467 | vulture = ["vulture"] 468 | 469 | [[package]] 470 | name = "pytest" 471 | version = "8.3.3" 472 | description = "pytest: simple powerful testing with Python" 473 | optional = false 474 | python-versions = ">=3.8" 475 | files = [ 476 | {file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"}, 477 | {file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"}, 478 | ] 479 | 480 | [package.dependencies] 481 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 482 | exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} 483 | iniconfig = "*" 484 | packaging = "*" 485 | pluggy = ">=1.5,<2" 486 | tomli = {version = ">=1", markers = "python_version < \"3.11\""} 487 | 488 | [package.extras] 489 | dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] 490 | 491 | [[package]] 492 | name = "pytest-cov" 493 | version = "5.0.0" 494 | description = "Pytest plugin for measuring coverage." 495 | optional = false 496 | python-versions = ">=3.8" 497 | files = [ 498 | {file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"}, 499 | {file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"}, 500 | ] 501 | 502 | [package.dependencies] 503 | coverage = {version = ">=5.2.1", extras = ["toml"]} 504 | pytest = ">=4.6" 505 | 506 | [package.extras] 507 | testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] 508 | 509 | [[package]] 510 | name = "pytest-subtests" 511 | version = "0.13.1" 512 | description = "unittest subTest() support and subtests fixture" 513 | optional = false 514 | python-versions = ">=3.7" 515 | files = [ 516 | {file = "pytest_subtests-0.13.1-py3-none-any.whl", hash = "sha256:ab616a22f64cd17c1aee65f18af94dbc30c444f8683de2b30895c3778265e3bd"}, 517 | {file = "pytest_subtests-0.13.1.tar.gz", hash = "sha256:989e38f0f1c01bc7c6b2e04db7d9fd859db35d77c2c1a430c831a70cbf3fde2d"}, 518 | ] 519 | 520 | [package.dependencies] 521 | attrs = ">=19.2.0" 522 | pytest = ">=7.0" 523 | 524 | [[package]] 525 | name = "pyyaml" 526 | version = "6.0.2" 527 | description = "YAML parser and emitter for Python" 528 | optional = false 529 | python-versions = ">=3.8" 530 | files = [ 531 | {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, 532 | {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, 533 | {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, 534 | {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, 535 | {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, 536 | {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, 537 | {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, 538 | {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, 539 | {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, 540 | {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, 541 | {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, 542 | {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, 543 | {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, 544 | {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, 545 | {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, 546 | {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, 547 | {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, 548 | {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, 549 | {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, 550 | {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, 551 | {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, 552 | {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, 553 | {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, 554 | {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, 555 | {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, 556 | {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, 557 | {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, 558 | {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, 559 | {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, 560 | {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, 561 | {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, 562 | {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, 563 | {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, 564 | {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, 565 | {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, 566 | {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, 567 | {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, 568 | {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, 569 | {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, 570 | {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, 571 | {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, 572 | {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, 573 | {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, 574 | {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, 575 | {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, 576 | {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, 577 | {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, 578 | {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, 579 | {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, 580 | {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, 581 | {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, 582 | {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, 583 | {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, 584 | ] 585 | 586 | [[package]] 587 | name = "requests" 588 | version = "2.32.3" 589 | description = "Python HTTP for Humans." 590 | optional = false 591 | python-versions = ">=3.8" 592 | files = [ 593 | {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, 594 | {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, 595 | ] 596 | 597 | [package.dependencies] 598 | certifi = ">=2017.4.17" 599 | charset-normalizer = ">=2,<4" 600 | idna = ">=2.5,<4" 601 | urllib3 = ">=1.21.1,<3" 602 | 603 | [package.extras] 604 | socks = ["PySocks (>=1.5.6,!=1.5.7)"] 605 | use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] 606 | 607 | [[package]] 608 | name = "setuptools" 609 | version = "75.2.0" 610 | description = "Easily download, build, install, upgrade, and uninstall Python packages" 611 | optional = false 612 | python-versions = ">=3.8" 613 | files = [ 614 | {file = "setuptools-75.2.0-py3-none-any.whl", hash = "sha256:a7fcb66f68b4d9e8e66b42f9876150a3371558f98fa32222ffaa5bced76406f8"}, 615 | {file = "setuptools-75.2.0.tar.gz", hash = "sha256:753bb6ebf1f465a1912e19ed1d41f403a79173a9acf66a42e7e6aec45c3c16ec"}, 616 | ] 617 | 618 | [package.extras] 619 | check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.5.2)"] 620 | core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.collections", "jaraco.functools", "jaraco.text (>=3.7)", "more-itertools", "more-itertools (>=8.8)", "packaging", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] 621 | cover = ["pytest-cov"] 622 | doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] 623 | enabler = ["pytest-enabler (>=2.2)"] 624 | test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] 625 | type = ["importlib-metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.11.*)", "pytest-mypy"] 626 | 627 | [[package]] 628 | name = "snowballstemmer" 629 | version = "2.2.0" 630 | description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." 631 | optional = false 632 | python-versions = "*" 633 | files = [ 634 | {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, 635 | {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, 636 | ] 637 | 638 | [[package]] 639 | name = "tomli" 640 | version = "2.0.2" 641 | description = "A lil' TOML parser" 642 | optional = false 643 | python-versions = ">=3.8" 644 | files = [ 645 | {file = "tomli-2.0.2-py3-none-any.whl", hash = "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38"}, 646 | {file = "tomli-2.0.2.tar.gz", hash = "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed"}, 647 | ] 648 | 649 | [[package]] 650 | name = "typing-extensions" 651 | version = "4.12.2" 652 | description = "Backported and Experimental Type Hints for Python 3.8+" 653 | optional = false 654 | python-versions = ">=3.8" 655 | files = [ 656 | {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, 657 | {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, 658 | ] 659 | 660 | [[package]] 661 | name = "urllib3" 662 | version = "2.2.3" 663 | description = "HTTP library with thread-safe connection pooling, file post, and more." 664 | optional = false 665 | python-versions = ">=3.8" 666 | files = [ 667 | {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"}, 668 | {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"}, 669 | ] 670 | 671 | [package.extras] 672 | brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] 673 | h2 = ["h2 (>=4,<5)"] 674 | socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] 675 | zstd = ["zstandard (>=0.18.0)"] 676 | 677 | [metadata] 678 | lock-version = "2.0" 679 | python-versions = "^3.8" 680 | content-hash = "d677be7b6612a654b3d585829634e613ab9b9e9c7c2abc17d342adf5cdca9fb3" 681 | -------------------------------------------------------------------------------- /pylama.ini: -------------------------------------------------------------------------------- 1 | [pylama] 2 | linters = mccabe,pycodestyle,pyflakes 3 | skip = *env*,.tox*,*build*,.*,env/*,.venv/* 4 | ignore = C901 5 | 6 | [pylama:pycodestyle] 7 | max_line_length = 80 8 | show-pep8 = True 9 | show-source = True 10 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "argclass" 3 | version = "1.1.0" 4 | description = "A wrapper around the standard argparse module that allows you to describe argument parsers declaratively" 5 | authors = ["Dmitry Orlov "] 6 | readme = "README.md" 7 | license = "Apache 2" 8 | homepage = "https://github.com/mosquito/argclass" 9 | classifiers = [ 10 | "Development Status :: 5 - Production/Stable", 11 | "Environment :: Console", 12 | "Intended Audience :: Developers", 13 | "Intended Audience :: System Administrators", 14 | "License :: OSI Approved :: Apache Software License", 15 | "Operating System :: MacOS :: MacOS X", 16 | "Operating System :: Microsoft :: Windows", 17 | "Operating System :: POSIX :: Linux", 18 | "Operating System :: POSIX", 19 | "Programming Language :: Python :: 3 :: Only", 20 | "Programming Language :: Python :: 3", 21 | "Programming Language :: Python :: 3.10", 22 | "Programming Language :: Python :: 3.11", 23 | "Programming Language :: Python :: 3.12", 24 | "Programming Language :: Python :: 3.8", 25 | "Programming Language :: Python :: 3.9", 26 | "Programming Language :: Python", 27 | "Typing :: Typed", 28 | ] 29 | packages = [ 30 | { include = "argclass" }, 31 | ] 32 | 33 | [tool.poetry.urls] 34 | "Source" = "https://github.com/mosquito/argclass" 35 | "Tracker" = "https://github.com/mosquito/argclass/issues" 36 | "Documentation" = "https://github.com/mosquito/argclass/blob/master/README.md" 37 | 38 | [tool.poetry.dependencies] 39 | python = "^3.8" 40 | 41 | [tool.poetry.group.dev.dependencies] 42 | coveralls = ">=3.3.1" 43 | pytest = ">=7.2" 44 | pytest-cov = ">=4.0.0" 45 | pylama = ">=8.4.1" 46 | setuptools = ">=69.0.2" 47 | markdown-pytest = ">=0.3.1" 48 | pyyaml = "^6.0.2" 49 | 50 | [tool.poetry.group.mypy.dependencies] 51 | mypy = "0.991" 52 | 53 | [build-system] 54 | requires = ["poetry-core"] 55 | build-backend = "poetry.core.masonry.api" 56 | 57 | [tool.mypy] 58 | check_untyped_defs = true 59 | disallow_any_generics = false 60 | disallow_incomplete_defs = true 61 | disallow_subclassing_any = true 62 | disallow_untyped_calls = true 63 | disallow_untyped_decorators = true 64 | disallow_untyped_defs = true 65 | follow_imports = "silent" 66 | no_implicit_reexport = true 67 | strict_optional = true 68 | warn_redundant_casts = true 69 | warn_unused_configs = true 70 | warn_unused_ignores = false 71 | files = "argclass" 72 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | from configparser import ConfigParser 2 | from pathlib import Path 3 | 4 | import pytest 5 | 6 | import argclass 7 | 8 | 9 | class TestBasics: 10 | class Parser(argclass.Parser): 11 | config = argclass.Config(search_paths=["test.conf"]) 12 | foo: str = argclass.Argument(help="foo") 13 | 14 | def test_simple(self): 15 | parser = self.Parser() 16 | parser.parse_args([]) 17 | 18 | def test_example_config(self, tmp_path): 19 | config = ConfigParser() 20 | config[config.default_section]["foo"] = "bar" 21 | config.add_section("test_section") 22 | config["test_section"]["spam"] = "egg" 23 | 24 | config_file = tmp_path / "config.ini" 25 | with open(config_file, "w") as fp: 26 | config.write(fp) 27 | 28 | parser = self.Parser() 29 | parser.parse_args(["--config", str(config_file)]) 30 | 31 | assert parser.config["foo"] == "bar" 32 | 33 | 34 | def test_config_type_not_exists(): 35 | class Parser(argclass.Parser): 36 | config = argclass.Config() 37 | 38 | parser = Parser() 39 | parser.parse_args(["--config=test.ini"]) 40 | assert parser.config == {} 41 | 42 | 43 | def test_config_required(): 44 | class Parser(argclass.Parser): 45 | config = argclass.Config(required=True) 46 | 47 | parser = Parser() 48 | with pytest.raises(SystemExit): 49 | parser.parse_args(["--config=test.ini"]) 50 | 51 | 52 | def test_config_defaults(tmp_path: Path): 53 | config = ConfigParser() 54 | config[config.default_section]["foo"] = "bar" 55 | 56 | config_file = tmp_path / "config.ini" 57 | with open(config_file, "w") as fp: 58 | config.write(fp) 59 | 60 | class Parser(argclass.Parser): 61 | foo = argclass.Argument(default="spam") 62 | 63 | parser = Parser(config_files=[config_file]) 64 | parser.parse_args([]) 65 | assert parser.foo == "bar" 66 | -------------------------------------------------------------------------------- /tests/test_secret_string.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from argclass import SecretString 4 | 5 | 6 | def test_secret_string(): 7 | s = SecretString("password") 8 | assert repr(s) != str(s) 9 | assert repr(s) == repr(SecretString.PLACEHOLDER) 10 | assert f"{s!r}" == repr(SecretString.PLACEHOLDER) 11 | 12 | 13 | def test_secret_log(caplog): 14 | s = SecretString("password") 15 | caplog.set_level(logging.INFO) 16 | 17 | with caplog.at_level(logging.INFO): 18 | logging.info(s) 19 | 20 | assert caplog.records[0].message == SecretString.PLACEHOLDER 21 | caplog.records.clear() 22 | 23 | with caplog.at_level(logging.INFO): 24 | logging.info("%s", s) 25 | 26 | assert caplog.records[0].message == SecretString.PLACEHOLDER 27 | caplog.records.clear() 28 | -------------------------------------------------------------------------------- /tests/test_simple.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | import re 5 | import uuid 6 | from enum import IntEnum 7 | from typing import FrozenSet, List, Optional 8 | from unittest.mock import patch 9 | 10 | import pytest 11 | 12 | import argclass 13 | 14 | 15 | class TestBasics: 16 | class Parser(argclass.Parser): 17 | integers: List[int] = argclass.Argument( 18 | "integers", type=int, 19 | nargs=argclass.Nargs.ONE_OR_MORE, metavar="N", 20 | help="an integer for the accumulator", 21 | ) 22 | accumulate = argclass.Argument( 23 | "--sum", action=argclass.Actions.STORE_CONST, const=sum, 24 | default=max, help="sum the integers (default: find the max)", 25 | ) 26 | secret = argclass.Secret() 27 | 28 | def test_simple(self): 29 | parser = self.Parser() 30 | parser.parse_args(["1", "2", "3"]) 31 | 32 | assert parser.integers 33 | assert parser.integers == [1, 2, 3] 34 | 35 | 36 | class HostPortGroup(argclass.Group): 37 | host: str 38 | port: int 39 | 40 | 41 | class TestFoo: 42 | class Parser(argclass.Parser): 43 | foo: str = argclass.Argument(help="foo") 44 | http: HostPortGroup = HostPortGroup( 45 | title="HTTP host and port", prefix="api", defaults={ 46 | "port": 80, "host": "0.0.0.0", 47 | }, 48 | ) 49 | grpc: HostPortGroup = HostPortGroup( 50 | title="GRPC host and port", 51 | defaults={"port": 6000, "host": "::"}, 52 | ) 53 | 54 | def test_simple(self): 55 | parser = self.Parser() 56 | parser.parse_args(["--foo", "bar"]) 57 | assert parser.foo == "bar" 58 | 59 | parser.parse_args(["--foo=bar"]) 60 | assert parser.foo == "bar" 61 | 62 | def test_group(self): 63 | parser = self.Parser() 64 | parser.parse_args(["--foo", "bar"]) 65 | assert parser.foo == "bar" 66 | 67 | parser.parse_args([ 68 | "--foo=bar", 69 | "--api-host=127.0.0.1", 70 | "--api-port=8080", 71 | "--grpc-host=127.0.0.2", 72 | "--grpc-port=9000", 73 | ]) 74 | assert parser.foo == "bar" 75 | assert parser.http.host == "127.0.0.1" 76 | assert parser.http.port == 8080 77 | assert parser.grpc.host == "127.0.0.2" 78 | assert parser.grpc.port == 9000 79 | 80 | def test_group_defaults(self): 81 | parser = self.Parser() 82 | parser.parse_args(["--foo=bar"]) 83 | assert parser.foo == "bar" 84 | assert parser.http.host == "0.0.0.0" 85 | assert parser.http.port == 80 86 | assert parser.grpc.host == "::" 87 | assert parser.grpc.port == 6000 88 | 89 | def test_parser_repr(self): 90 | parser = self.Parser() 91 | r = repr(parser) 92 | assert r == "" 93 | 94 | def test_access_to_not_parsed_attrs(self): 95 | parser = self.Parser() 96 | with pytest.raises(AttributeError): 97 | _ = parser.foo 98 | 99 | def test_environment(self, request: pytest.FixtureRequest): 100 | prefix = re.sub(r"\d+", "", uuid.uuid4().hex + uuid.uuid4().hex).upper() 101 | expected = uuid.uuid4().hex 102 | os.environ[f"{prefix}_FOO"] = expected 103 | request.addfinalizer(lambda: os.environ.pop(f"{prefix}_FOO")) 104 | 105 | parser = self.Parser(auto_env_var_prefix=f"{prefix}_") 106 | parser.parse_args([]) 107 | assert parser.foo == expected 108 | 109 | 110 | def test_env_var(request: pytest.FixtureRequest): 111 | env_var = re.sub(r"\d+", "", uuid.uuid4().hex + uuid.uuid4().hex).upper() 112 | 113 | class Parser(argclass.Parser): 114 | foo: str = argclass.Argument(env_var=env_var) 115 | 116 | expected = uuid.uuid4().hex 117 | os.environ[env_var] = expected 118 | request.addfinalizer(lambda: os.environ.pop(env_var)) 119 | 120 | parser = Parser() 121 | parser.parse_args([]) 122 | assert parser.foo == expected 123 | 124 | 125 | def test_nargs(): 126 | class Parser(argclass.Parser): 127 | foo: List[int] = argclass.Argument( 128 | nargs=argclass.Nargs.ZERO_OR_MORE, type=int, 129 | ) 130 | bar: int = argclass.Argument(nargs="*") 131 | spam: int = argclass.Argument(nargs=1) 132 | 133 | parser = Parser() 134 | parser.parse_args(["--foo", "1", "2", "--bar=3", "--spam=4"]) 135 | assert parser.foo == [1, 2] 136 | assert parser.bar == [3] 137 | assert parser.spam == [4] 138 | 139 | 140 | def test_group_aliases(): 141 | class Group(argclass.Group): 142 | foo: str = argclass.Argument("-F") 143 | 144 | class Parser(argclass.Parser): 145 | group = Group() 146 | 147 | parser = Parser() 148 | parser.parse_args(["-F", "egg"]) 149 | assert parser.group.foo == "egg" 150 | 151 | 152 | def test_short_parser_definition(): 153 | class Parser(argclass.Parser): 154 | foo: str 155 | bar: int 156 | 157 | parser = Parser() 158 | parser.parse_args(["--foo=spam", "--bar=1"]) 159 | assert parser.foo == "spam" 160 | assert parser.bar == 1 161 | 162 | 163 | def test_print_help(capsys: pytest.CaptureFixture): 164 | class Parser(argclass.Parser): 165 | foo: str 166 | bar: int = 0 167 | 168 | parser = Parser() 169 | parser.print_help() 170 | captured = capsys.readouterr() 171 | assert "--foo" in captured.out 172 | assert "--bar" in captured.out 173 | assert "--help" in captured.out 174 | assert "--foo FOO" in captured.out 175 | assert "[--bar BAR]" in captured.out 176 | 177 | 178 | def test_print_log_level(capsys: pytest.CaptureFixture): 179 | class Parser(argclass.Parser): 180 | log_level: int = argclass.LogLevel 181 | 182 | parser = Parser() 183 | parser.parse_args(["--log-level", "info"]) 184 | assert parser.log_level == logging.INFO 185 | 186 | parser.parse_args(["--log-level=warning"]) 187 | assert parser.log_level == logging.WARNING 188 | 189 | 190 | def test_optional_type(): 191 | class Parser(argclass.Parser): 192 | flag: bool 193 | optional: Optional[bool] 194 | 195 | parser = Parser() 196 | parser.parse_args([]) 197 | assert parser.optional is None 198 | assert not parser.flag 199 | 200 | parser.parse_args(["--flag"]) 201 | assert parser.flag 202 | 203 | for variant in ("yes", "Y", "yeS", "enable", "ENABLED", "1"): 204 | parser.parse_args([f"--optional={variant}"]) 205 | assert parser.optional is True 206 | 207 | for variant in ("no", "crap", "false", "disabled", "MY_HANDS_TYPING_WORDS"): 208 | parser.parse_args([f"--optional={variant}"]) 209 | assert parser.optional is False 210 | 211 | 212 | def test_argument_defaults(): 213 | class Parser(argclass.Parser): 214 | debug: bool = False 215 | confused_default: bool = True 216 | pool_size: int = 4 217 | forks: int = 2 218 | 219 | parser = Parser() 220 | 221 | parser.parse_args([]) 222 | assert parser.debug is False 223 | assert parser.confused_default is True 224 | assert parser.pool_size == 4 225 | assert parser.forks == 2 226 | 227 | parser.parse_args([ 228 | "--debug", "--forks=8", "--pool-size=2", "--confused-default", 229 | ]) 230 | assert parser.debug is True 231 | assert parser.confused_default is False 232 | assert parser.pool_size == 2 233 | assert parser.forks == 8 234 | 235 | 236 | def test_inheritance(): 237 | class AddressPort(argclass.Group): 238 | address: str 239 | port: int 240 | 241 | class Parser(argclass.Parser, AddressPort): 242 | pass 243 | 244 | parser = Parser() 245 | parser.parse_args(["--address=0.0.0.0", "--port=9876"]) 246 | assert parser.address == "0.0.0.0" 247 | assert parser.port == 9876 248 | 249 | 250 | def test_config_for_required(tmp_path): 251 | class Parser(argclass.Parser): 252 | required: int = argclass.Argument(required=True) 253 | 254 | config_path = tmp_path / "config.ini" 255 | 256 | with open(config_path, "w") as fp: 257 | fp.write("[DEFAULT]\n") 258 | fp.write("required = 10\n") 259 | fp.write("\n") 260 | 261 | parser = Parser(config_files=[config_path]) 262 | parser.parse_args([]) 263 | 264 | assert parser.required == 10 265 | 266 | parser = Parser(config_files=[]) 267 | 268 | with pytest.raises(SystemExit): 269 | parser.parse_args([]) 270 | 271 | 272 | def test_minimal_optional(tmp_path): 273 | class Parser(argclass.Parser): 274 | optional: Optional[int] 275 | 276 | parser = Parser() 277 | parser.parse_args([]) 278 | 279 | assert parser.optional is None 280 | 281 | parser.parse_args(["--optional=10"]) 282 | 283 | assert parser.optional == 10 284 | 285 | 286 | def test_optional_is_not_required(tmp_path): 287 | class Parser(argclass.Parser): 288 | optional: Optional[int] = argclass.Argument(required=False) 289 | 290 | parser = Parser() 291 | 292 | parser.parse_args([]) 293 | assert parser.optional is None 294 | 295 | parser.parse_args(["--optional=20"]) 296 | assert parser.optional == 20 297 | 298 | 299 | def test_minimal_required(tmp_path): 300 | class Parser(argclass.Parser): 301 | required: int 302 | 303 | parser = Parser() 304 | 305 | with pytest.raises(SystemExit): 306 | parser.parse_args([]) 307 | 308 | parser.parse_args(["--required=20"]) 309 | 310 | assert parser.required == 20 311 | 312 | 313 | def test_log_group(): 314 | class LogGroup(argclass.Group): 315 | level: int = argclass.LogLevel 316 | format = argclass.Argument( 317 | choices=("json", "stream"), default="stream", 318 | ) 319 | 320 | class Parser(argclass.Parser): 321 | log = LogGroup() 322 | 323 | parser = Parser() 324 | parser.parse_args([]) 325 | 326 | assert parser.log.level == logging.INFO 327 | assert parser.log.format == "stream" 328 | 329 | parser.parse_args(["--log-level=debug", "--log-format=json"]) 330 | 331 | assert parser.log.level == logging.DEBUG 332 | assert parser.log.format == "json" 333 | 334 | 335 | def test_log_group_defaults(): 336 | class LogGroup(argclass.Group): 337 | level: int = argclass.LogLevel 338 | format: str = argclass.Argument( 339 | choices=("json", "stream"), 340 | ) 341 | 342 | class Parser(argclass.Parser): 343 | log = LogGroup(defaults=dict(format="json", level="error")) 344 | 345 | parser = Parser() 346 | parser.parse_args([]) 347 | 348 | assert parser.log.level == logging.ERROR 349 | assert parser.log.format == "json" 350 | 351 | 352 | def test_environment_required(): 353 | class Parser(argclass.Parser): 354 | required: int 355 | 356 | parser = Parser(auto_env_var_prefix="TEST_") 357 | 358 | os.environ["TEST_REQUIRED"] = "100" 359 | 360 | parser.parse_args([]) 361 | assert parser.required == 100 362 | 363 | os.environ.pop("TEST_REQUIRED") 364 | 365 | with pytest.raises(SystemExit): 366 | parser.parse_args([]) 367 | 368 | 369 | def test_nargs_and_converter(): 370 | class Parser(argclass.Parser): 371 | args_set: FrozenSet[int] = argclass.Argument( 372 | type=int, nargs="+", converter=frozenset, 373 | ) 374 | 375 | parser = Parser() 376 | parser.parse_args(["--args-set", "1", "2", "3", "4", "5"]) 377 | assert isinstance(parser.args_set, frozenset) 378 | assert parser.args_set == frozenset([1, 2, 3, 4, 5]) 379 | 380 | 381 | def test_nargs_and_converter_not_required(): 382 | class Parser(argclass.Parser): 383 | args_set: FrozenSet[int] = argclass.Argument( 384 | type=int, nargs="*", converter=frozenset, 385 | ) 386 | 387 | parser = Parser() 388 | parser.parse_args([]) 389 | assert isinstance(parser.args_set, frozenset) 390 | assert parser.args_set == frozenset([]) 391 | 392 | parser.parse_args(["--args-set", "1", "2", "3", "4", "5"]) 393 | assert isinstance(parser.args_set, frozenset) 394 | assert parser.args_set == frozenset([1, 2, 3, 4, 5]) 395 | 396 | 397 | def test_nargs_1(): 398 | class Parser(argclass.Parser): 399 | args_set: FrozenSet[int] = argclass.Argument( 400 | type=int, nargs=1, converter=frozenset, 401 | ) 402 | 403 | parser = Parser() 404 | parser.parse_args([]) 405 | assert isinstance(parser.args_set, frozenset) 406 | assert parser.args_set == frozenset([]) 407 | 408 | parser.parse_args(["--args-set", "1"]) 409 | assert isinstance(parser.args_set, frozenset) 410 | assert parser.args_set == frozenset([1]) 411 | 412 | 413 | def test_nargs_env_var(): 414 | class Parser(argclass.Parser): 415 | nargs: FrozenSet[int] = argclass.Argument( 416 | type=int, nargs="*", converter=frozenset, env_var="NARGS", 417 | ) 418 | 419 | os.environ["NARGS"] = "[1, 2, 3]" 420 | try: 421 | parser = Parser() 422 | parser.parse_args([]) 423 | finally: 424 | del os.environ["NARGS"] 425 | 426 | assert parser.nargs == frozenset({1, 2, 3}) 427 | 428 | 429 | def test_nargs_env_var_str(): 430 | class Parser(argclass.Parser): 431 | nargs: FrozenSet[int] = argclass.Argument( 432 | type=str, nargs="*", converter=frozenset, env_var="NARGS", 433 | ) 434 | 435 | os.environ["NARGS"] = '["a", "b", "c"]' 436 | try: 437 | parser = Parser() 438 | parser.parse_args([]) 439 | finally: 440 | del os.environ["NARGS"] 441 | 442 | assert parser.nargs == frozenset({"a", "b", "c"}) 443 | 444 | 445 | def test_nargs_config_list(tmp_path): 446 | class Parser(argclass.Parser): 447 | nargs: FrozenSet[int] = argclass.Argument( 448 | type=int, nargs="*", converter=frozenset, env_var="NARGS", 449 | ) 450 | 451 | conf_file = tmp_path / "config.ini" 452 | 453 | with open(conf_file, "w") as fp: 454 | fp.write("[DEFAULT]\n") 455 | fp.write("nargs = [1, 2, 3, 4]\n") 456 | 457 | parser = Parser(config_files=[conf_file]) 458 | parser.parse_args([]) 459 | 460 | assert parser.nargs == frozenset({1, 2, 3, 4}) 461 | 462 | 463 | def test_nargs_config_set(tmp_path): 464 | class Parser(argclass.Parser): 465 | nargs: FrozenSet[int] = argclass.Argument( 466 | type=int, nargs="*", converter=frozenset, env_var="NARGS", 467 | ) 468 | 469 | conf_file = tmp_path / "config.ini" 470 | 471 | with open(conf_file, "w") as fp: 472 | fp.write("[DEFAULT]\n") 473 | fp.write("nargs = {1, 2, 3, 4}\n") 474 | 475 | parser = Parser(config_files=[conf_file]) 476 | parser.parse_args([]) 477 | 478 | assert parser.nargs == frozenset({1, 2, 3, 4}) 479 | 480 | 481 | def test_secret_argument(tmp_path, capsys): 482 | class Parser(argclass.Parser): 483 | token: str = argclass.Argument(secret=True, default="TOP_SECRET") 484 | pubkey: str = argclass.Argument(secret=False, default="NO_SECRET") 485 | secret: str = argclass.Secret(default="FORBIDDEN") 486 | 487 | parser = Parser() 488 | parser.print_help() 489 | 490 | captured = capsys.readouterr() 491 | assert "TOP_SECRET" not in captured.out 492 | assert "NO_SECRET" in captured.out 493 | assert "FORBIDDEN" not in captured.out 494 | 495 | 496 | def test_sanitize_env(): 497 | class Parser(argclass.Parser): 498 | secret: str = argclass.Secret(default="SECRET") 499 | 500 | parser = Parser(auto_env_var_prefix="TEST_") 501 | 502 | with patch("os.environ", new={}): 503 | os.environ["TEST_SECRET"] = "foo" 504 | 505 | assert os.environ["TEST_SECRET"] == "foo" 506 | 507 | parser.parse_args([]) 508 | parser.sanitize_env() 509 | 510 | assert "TEST_SECRET" not in dict(os.environ) 511 | 512 | 513 | def test_enum(): 514 | class Options(IntEnum): 515 | ONE = 1 516 | TWO = 2 517 | THREE = 3 518 | ZERO = 0 519 | 520 | class Parser(argclass.Parser): 521 | option: Options = argclass.EnumArgument(Options, default=Options.ZERO) 522 | 523 | parser = Parser() 524 | 525 | parser.parse_args([]) 526 | assert parser.option is Options.ZERO 527 | 528 | parser.parse_args(["--option=ONE"]) 529 | assert parser.option is Options.ONE 530 | 531 | parser.parse_args(["--option=TWO"]) 532 | assert parser.option is Options.TWO 533 | 534 | with pytest.raises(SystemExit): 535 | parser.parse_args(["--option=3"]) 536 | 537 | class Parser(argclass.Parser): 538 | option: Options 539 | 540 | parser = Parser() 541 | 542 | parser.parse_args(["--option=ONE"]) 543 | assert parser.option is Options.ONE 544 | 545 | parser.parse_args(["--option=TWO"]) 546 | assert parser.option is Options.TWO 547 | 548 | with pytest.raises(SystemExit): 549 | parser.parse_args(["--option=3"]) 550 | 551 | 552 | def test_group_required_inheritance(): 553 | class BaseGroup(argclass.Group): 554 | bar: str = argclass.Argument(required=True) 555 | 556 | class SubGroup(BaseGroup): 557 | pass 558 | 559 | class Parser(argclass.Parser): 560 | group = SubGroup() 561 | 562 | parser = Parser() 563 | with pytest.raises(SystemExit): 564 | parser.parse_args([]) 565 | 566 | class ImplicitBaseGroup(argclass.Group): 567 | bar: str 568 | foo: int 569 | 570 | class ImplicitSubGroup(ImplicitBaseGroup): 571 | zoo: str 572 | 573 | class Parser2(argclass.Parser): 574 | group = ImplicitSubGroup() 575 | 576 | parser = Parser2() 577 | with pytest.raises(SystemExit): 578 | parser.parse_args([]) 579 | 580 | 581 | def test_json_action(tmp_path): 582 | class Parser(argclass.Parser): 583 | config = argclass.Config( 584 | required=True, 585 | config_class=argclass.JSONConfig, 586 | ) 587 | 588 | with open(tmp_path / "config.json", "w") as fp: 589 | json.dump({"foo": "bar"}, fp) 590 | 591 | parser = Parser() 592 | parser.parse_args(["--config", str(tmp_path / "config.json")]) 593 | 594 | assert parser.config["foo"] == "bar" 595 | -------------------------------------------------------------------------------- /tests/test_store.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import argclass 4 | 5 | 6 | def test_required_argument(): 7 | class RequiredStore(argclass.Store): 8 | foo: str 9 | 10 | with pytest.raises(TypeError): 11 | RequiredStore() 12 | 13 | 14 | def test_store_repr(): 15 | class Store(argclass.Store): 16 | foo: str 17 | bar: int 18 | 19 | store = Store(foo="spam", bar=2) 20 | r = repr(store) 21 | assert r == "" 22 | -------------------------------------------------------------------------------- /tests/test_subparsers.py: -------------------------------------------------------------------------------- 1 | from functools import singledispatch 2 | from typing import Any, Optional 3 | 4 | import pytest 5 | 6 | import argclass 7 | 8 | 9 | def test_subparsers(): 10 | class Subparser(argclass.Parser): 11 | foo: str = argclass.Argument() 12 | 13 | class Parser(argclass.Parser): 14 | subparser = Subparser() 15 | 16 | parser = Parser() 17 | parser.parse_args(["subparser", "--foo=bar"]) 18 | assert parser.subparser.foo == "bar" 19 | 20 | 21 | def test_two_subparsers(): 22 | class Subparser(argclass.Parser): 23 | spam: str = argclass.Argument() 24 | 25 | class Parser(argclass.Parser): 26 | foo = Subparser() 27 | bar = Subparser() 28 | 29 | parser = Parser() 30 | parser.parse_args(["bar", "--spam=egg"]) 31 | assert parser.bar.spam == "egg" 32 | 33 | with pytest.raises(AttributeError): 34 | _ = parser.foo.spam 35 | 36 | 37 | def test_two_simple_subparsers(): 38 | class Subparser(argclass.Parser): 39 | spam: str 40 | 41 | class Parser(argclass.Parser): 42 | foo = Subparser() 43 | bar = Subparser() 44 | 45 | parser = Parser() 46 | parser.parse_args(["foo", "--spam=egg"]) 47 | assert parser.foo.spam == "egg" 48 | 49 | with pytest.raises(AttributeError): 50 | _ = parser.bar.spam 51 | 52 | 53 | def test_current_subparsers(): 54 | class AddressPortGroup(argclass.Group): 55 | address: str = argclass.Argument(default="127.0.0.1") 56 | port: int 57 | 58 | class CommitCommand(argclass.Parser): 59 | comment: str = argclass.Argument() 60 | 61 | class PushCommand(argclass.Parser): 62 | comment: str = argclass.Argument() 63 | 64 | class Parser(argclass.Parser): 65 | log_level: int = argclass.LogLevel 66 | endpoint = AddressPortGroup( 67 | title="Endpoint options", 68 | defaults=dict(port=8080), 69 | ) 70 | commit: Optional[CommitCommand] = CommitCommand() 71 | push: Optional[PushCommand] = PushCommand() 72 | 73 | state = {} 74 | 75 | @singledispatch 76 | def handle_subparser(subparser: Any) -> None: 77 | raise NotImplementedError( 78 | f"Unexpected subparser type {subparser.__class__!r}", 79 | ) 80 | 81 | @handle_subparser.register(type(None)) 82 | def handle_none(_: None) -> None: 83 | Parser().print_help() 84 | exit(12) 85 | 86 | @handle_subparser.register(CommitCommand) 87 | def handle_commit(subparser: CommitCommand) -> None: 88 | state["commit"] = subparser 89 | 90 | @handle_subparser.register(PushCommand) 91 | def handle_push(subparser: PushCommand) -> None: 92 | state["push"] = subparser 93 | 94 | parser = Parser() 95 | parser.parse_args([]) 96 | 97 | with pytest.raises(SystemExit) as e: 98 | handle_subparser(parser.current_subparser) 99 | 100 | assert e.value.code == 12 101 | 102 | parser.parse_args(["commit"]) 103 | handle_subparser(parser.current_subparser) 104 | assert "commit" in state 105 | assert "push" not in state 106 | 107 | parser.parse_args(["push"]) 108 | handle_subparser(parser.current_subparser) 109 | assert "push" in state 110 | 111 | 112 | def test_nested_subparsers() -> None: 113 | class Group(argclass.Group): 114 | value: int = argclass.Argument() 115 | 116 | class SubSubParser(argclass.Parser): 117 | str_value: str = argclass.Argument() 118 | group: Group = Group() 119 | 120 | class SubParser(argclass.Parser): 121 | subsub: Optional[SubSubParser] = SubSubParser() 122 | val: str = argclass.Argument() 123 | 124 | class Parser(argclass.Parser): 125 | sub: Optional[SubParser] = SubParser() 126 | sub2: Optional[SubParser] = SubParser() 127 | 128 | parser = Parser() 129 | args = parser.parse_args([ 130 | "sub", 131 | "--val=lol", 132 | "subsub", 133 | "--group-value=2", 134 | "--str-value=kek", 135 | ]) 136 | 137 | assert args.sub.val == "lol" 138 | assert args.sub.subsub.str_value == "kek" 139 | assert args.sub.subsub.group.value == 2 140 | with pytest.raises(AttributeError): 141 | print(args.sub2.val) 142 | 143 | 144 | def test_call() -> None: 145 | class SubParser(argclass.Parser): 146 | _flag = False 147 | 148 | def __call__(self): 149 | self.__class__._flag = not self.__class__._flag 150 | 151 | class Parser(argclass.Parser): 152 | subparser1 = SubParser() 153 | subparser2 = SubParser() 154 | flag: bool = True 155 | 156 | parser = Parser() 157 | 158 | parser.parse_args(["subparser1"]) 159 | parser() 160 | assert parser.subparser1._flag 161 | 162 | parser.parse_args(["subparser2"]) 163 | parser() 164 | assert not parser.subparser2._flag 165 | --------------------------------------------------------------------------------