├── examples ├── train_config.yaml ├── train_config.toml ├── train_config.json ├── simple_cli.py └── nested_cli.py ├── .pre-commit-config.yaml ├── tests ├── ui_testing.py ├── saw_error_message.sh ├── test_to_pydantic.py ├── test_parse.py └── test_config_load.py ├── src └── pydantic_config │ ├── __init__.py │ ├── errors.py │ └── parse.py ├── pyproject.toml ├── .gitignore ├── README.md ├── LICENCE └── uv.lock /examples/train_config.yaml: -------------------------------------------------------------------------------- 1 | lr: 3e-4 2 | batch_size: 32 3 | 4 | -------------------------------------------------------------------------------- /examples/train_config.toml: -------------------------------------------------------------------------------- 1 | lr = 3e-4 2 | batch_size = 32 3 | 4 | -------------------------------------------------------------------------------- /examples/train_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "lr": 3e-4, 3 | "batch_size": 32 4 | } 5 | 6 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | # Ruff version. 4 | rev: v0.4.4 5 | hooks: 6 | # Run the linter. 7 | - id: ruff 8 | args: [ --fix ] 9 | # Run the formatter. 10 | - id: ruff-format 11 | -------------------------------------------------------------------------------- /examples/simple_cli.py: -------------------------------------------------------------------------------- 1 | from pydantic_config import parse_argv 2 | from pydantic import validate_call 3 | 4 | 5 | @validate_call 6 | def main(hello: str, foo: int): 7 | print(f"hello: {hello}, foo: {foo}") 8 | 9 | 10 | if __name__ == "__main__": 11 | main(**parse_argv()) 12 | -------------------------------------------------------------------------------- /tests/ui_testing.py: -------------------------------------------------------------------------------- 1 | """ 2 | this test is not a pytest test but a visual one. 3 | 4 | Just call `python tests/ui_testing.py --foo bar` to see the ui and error rendering. 5 | """ 6 | 7 | from pydantic_config import parse_argv 8 | 9 | if __name__ == "__main__": 10 | print(parse_argv()) 11 | -------------------------------------------------------------------------------- /src/pydantic_config/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.0.1" 2 | 3 | from pydantic_config.parse import parse_argv 4 | from pydantic import BaseModel as PydanticBaseModel 5 | from pydantic import ConfigDict 6 | 7 | 8 | class BaseConfig(PydanticBaseModel): 9 | model_config = ConfigDict(extra="forbid") 10 | 11 | 12 | __all__ = ["parse_argv", "BaseConfig"] 13 | -------------------------------------------------------------------------------- /tests/saw_error_message.sh: -------------------------------------------------------------------------------- 1 | # use this file from the root folder. Use it to visualize all error messages 2 | 3 | echo "### new line" 4 | uv run python tests/ui_testing.py --no-hello l --a 5 | echo "### new line" 6 | uv run python tests/ui_testing.py hello 7 | echo "### new line" 8 | uv run python tests/ui_testing.py --no-hello world 9 | echo "### new line" 10 | uv run python tests/ui_testing.py --hello @wrong_file.json 11 | echo "### new line" 12 | uv run python tests/ui_testing.py --hello @uv.lock 13 | -------------------------------------------------------------------------------- /examples/nested_cli.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from pydantic_config import parse_argv, BaseConfig 3 | from pydantic import validate_call 4 | 5 | 6 | class TrainingConfig(BaseConfig): 7 | lr: float = 3e-4 8 | batch_size: int 9 | 10 | 11 | class DataConfig(BaseConfig): 12 | path: Path 13 | 14 | 15 | def prepare_data(conf: DataConfig): 16 | print(conf) 17 | 18 | 19 | def train_model(conf: TrainingConfig): 20 | print(conf) 21 | 22 | 23 | @validate_call 24 | def main(train: TrainingConfig, data: DataConfig): 25 | prepare_data(data) 26 | train_model(train) 27 | 28 | 29 | if __name__ == "__main__": 30 | main(**parse_argv()) 31 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "pydantic_config" 3 | version = "0.2.0" 4 | description = "cli and config for ml using pydantic" 5 | authors = [ 6 | { name = "Sami Jaghouar", email = "sami.jaghouar@gmail.com" } 7 | ] 8 | dependencies = [ 9 | "pydantic>=2.0.0", 10 | "rich", 11 | ] 12 | readme = "README.md" 13 | requires-python = ">= 3.10" 14 | 15 | 16 | [build-system] 17 | requires = ["hatchling"] 18 | build-backend = "hatchling.build" 19 | 20 | 21 | [project.optional-dependencies] 22 | yaml = ["pyyaml"] 23 | toml = ["tomli"] 24 | all = ["pyyaml", "tomli"] 25 | 26 | [tool.ruff] 27 | line-length = 120 28 | 29 | [tool.uv] 30 | dev-dependencies = ["ruff==0.5.0", "pre-commit>=3.0.0","pytest>=7.0.0"] 31 | 32 | -------------------------------------------------------------------------------- /tests/test_to_pydantic.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pydantic_config.parse import parse_args 3 | from pydantic_config import BaseConfig 4 | from pydantic import validate_call 5 | 6 | 7 | def test_cli_to_pydantic(): 8 | class Foo(BaseConfig): 9 | hello: str 10 | world: int 11 | 12 | argv = ["--hello", "world", "--world", "1"] 13 | 14 | arg_parsed = parse_args(argv) 15 | assert arg_parsed == {"hello": "world", "world": "1"} 16 | 17 | arg_validated = Foo(**arg_parsed) 18 | assert arg_validated.hello == "world" 19 | assert arg_validated.world == 1 20 | 21 | 22 | def test_complex_pydantic(): 23 | class NestedNestedModel(BaseConfig): 24 | hello: str = "world" 25 | world: int 26 | 27 | class NestedModel(BaseConfig): 28 | nested: NestedNestedModel 29 | foo: str 30 | 31 | class MainModel(BaseConfig): 32 | nested: NestedModel 33 | bar: str 34 | 35 | argv = [ 36 | "--nested.nested.hello", 37 | "world", 38 | "--nested.nested.world", 39 | "1", 40 | "--nested.foo", 41 | "hello", 42 | "--bar", 43 | "hello", 44 | ] 45 | arg_parsed = parse_args(argv) 46 | 47 | arg_validated = MainModel(**arg_parsed) 48 | 49 | assert arg_validated.nested.nested.hello == "world" 50 | assert arg_validated.nested.nested.world == 1 51 | assert arg_validated.nested.foo == "hello" 52 | assert arg_validated.bar == "hello" 53 | 54 | 55 | def test_validate_function(): 56 | class Config(BaseConfig): 57 | hello: str 58 | world: int 59 | 60 | @validate_call 61 | def foo(a: str, config: Config): 62 | assert config.hello == "hello" 63 | assert config.world == 1 64 | assert a == "b" 65 | 66 | arg_parsed = parse_args(["--a", "b", "--config.hello", "hello", "--config.world", "1"]) 67 | foo(**arg_parsed) 68 | 69 | with pytest.raises(AssertionError): 70 | arg_parsed = parse_args(["--a", "b", "--config.hello", "nooo", "--config.world", "1"]) 71 | foo(**arg_parsed) 72 | -------------------------------------------------------------------------------- /src/pydantic_config/errors.py: -------------------------------------------------------------------------------- 1 | import copy 2 | from importlib.util import find_spec 3 | 4 | 5 | class PydanticConfigError(BaseException): ... 6 | 7 | 8 | class CliError(PydanticConfigError): 9 | def __init__(self, args: list[str], wrong_index: list[int], error_msg: str, suggestion: list[str] | None = None): 10 | super().__init__() 11 | self.args = copy.deepcopy(args) 12 | self.wrong_index = wrong_index 13 | self.suggestion = suggestion 14 | self.error_msg = error_msg 15 | self._program_name = None 16 | 17 | def get_input_and_suggestion(self): 18 | input_ = [] 19 | suggestion = [] 20 | for i, arg in enumerate(self.args): 21 | if i in self.wrong_index: 22 | input_.append(f"[red][bold]{arg}[/bold][/red]") 23 | if self.suggestion: 24 | suggestion.append(f"[green][bold]{self.suggestion[i]}[/bold][/green]") 25 | else: 26 | input_.append(arg) 27 | if self.suggestion: 28 | suggestion.append(self.suggestion[i]) 29 | 30 | input_ = self.program_name + " " + " ".join(input_) 31 | if self.suggestion: 32 | suggestion = self.program_name + " " + " ".join(suggestion) 33 | return input_, suggestion 34 | 35 | def _render_with_rich(self): 36 | # inspired from cyclopts https://github.com/BrianPugh/cyclopts/blob/a6489e6f6e7e1b555c614f2fa93a13191718d44b/cyclopts/exceptions.py#L318 37 | from rich.console import Console 38 | 39 | console = Console() 40 | 41 | console.print("\nERROR: Invalid argument: ", style="bold red") 42 | console.print("\n" + self.error_msg, style="red") 43 | console.print("-" * console.width + "\n", style="red") 44 | 45 | input_, suggestion = self.get_input_and_suggestion() 46 | console.print("[red]Input:[/red] \n" + input_) 47 | if self.suggestion: 48 | console.print(" \n[green]Suggestion:[/green] \n" + suggestion) 49 | 50 | console.print("\n" + "-" * console.width, style="red") 51 | 52 | console.print("Please check your input and try again.\n", style="yellow") 53 | 54 | def render(self): 55 | if find_spec("rich"): 56 | return self._render_with_rich() 57 | else: 58 | return print(self.error_msg) 59 | 60 | @property 61 | def program_name(self) -> str: 62 | return self._program_name or "" 63 | 64 | @program_name.setter 65 | def program_name(self, program_name: str): 66 | self._program_name = program_name 67 | 68 | 69 | class MergedConflictError(PydanticConfigError): ... 70 | 71 | 72 | class InvalidConfigFileError(PydanticConfigError): 73 | def __init__(self, original_error: Exception): 74 | super().__init__() 75 | self.original_error = original_error 76 | -------------------------------------------------------------------------------- /tests/test_parse.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pydantic_config.errors import CliError 3 | from pydantic_config.parse import parse_args 4 | 5 | 6 | def test_no_starting_with_correct_prefix(): 7 | argv = ["world"] 8 | with pytest.raises(CliError): 9 | parse_args(argv) 10 | 11 | 12 | ## bool 13 | 14 | 15 | def test_bool_simple(): 16 | argv = ["--hello", "--a", "b"] 17 | assert parse_args(argv) == {"hello": True, "a": "b"} 18 | 19 | 20 | def test_bool_simple_2(): 21 | argv = ["--no-hello", "--a", "b"] 22 | assert parse_args(argv) == {"hello": False, "a": "b"} 23 | 24 | 25 | def test_bool(): 26 | argv = ["--hello", "--no-foo", "--no-bar"] 27 | assert parse_args(argv) == {"hello": True, "foo": False, "bar": False} 28 | 29 | 30 | def test_bool_not_follow_value(): 31 | argv = ["--no-hello", "world"] 32 | with pytest.raises(CliError): 33 | parse_args(argv) 34 | 35 | 36 | def test_bool_conflict(): 37 | argv = ["--hello", "world", "--hello"] 38 | with pytest.raises(CliError): 39 | parse_args(argv) 40 | 41 | 42 | ## list 43 | 44 | 45 | def test_list(): 46 | argv = ["--hello", "world", "--foo", "bar", "--hello", "universe"] 47 | assert parse_args(argv) == {"hello": ["world", "universe"], "foo": "bar"} 48 | 49 | 50 | ## nested 51 | 52 | 53 | def test_nested_list(): 54 | argv = ["--hello.world", "world", "--foo", "bar", "--hello.world", "universe"] 55 | assert parse_args(argv) == {"hello": {"world": ["world", "universe"]}, "foo": "bar"} 56 | 57 | 58 | def test_nested_list_2(): 59 | argv = ["--hello.world.a.b.c.d", "world", "--foo", "bar", "--hello.world.a.b.c.d", "universe"] 60 | assert parse_args(argv) == {"hello": {"world": {"a": {"b": {"c": {"d": ["world", "universe"]}}}}}, "foo": "bar"} 61 | 62 | 63 | ## old test for legacy 64 | 65 | 66 | def test_nested_conflict(): 67 | with pytest.raises(CliError): 68 | argv = ["--hello.world", "world", "--hello", "galaxy"] 69 | parse_args(argv) 70 | 71 | 72 | @pytest.mark.parametrize("arg", ["hello", "-hello"]) 73 | def test_no_underscor_arg_failed(arg): 74 | argv = [arg] 75 | 76 | with pytest.raises(CliError): 77 | parse_args(argv) 78 | 79 | 80 | def test_correct_arg_passed(): 81 | argv = ["--hello", "world", "--foo", "bar"] 82 | assert parse_args(argv) == {"hello": "world", "foo": "bar"} 83 | 84 | 85 | def test_python_underscor_replace(): 86 | argv = ["--hello-world", "hye", "--foo_bar", "bar"] 87 | assert parse_args(argv) == {"hello_world": "hye", "foo_bar": "bar"} 88 | 89 | 90 | def test_use_equal(): 91 | argv = ["--hello-world=hye", "--foo_bar=bar"] 92 | assert parse_args(argv) == {"hello_world": "hye", "foo_bar": "bar"} 93 | 94 | 95 | def test_use_equal_nested(): 96 | argv = ["--hello-world.a=hye", "--foo_bar=bar", "--hey", "go"] 97 | assert parse_args(argv) == {"hello_world": {"a": "hye"}, "foo_bar": "bar", "hey": "go"} 98 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.vscode 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 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 | cover/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | .pybuilder/ 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | # For a library or package, you might want to ignore these files since the code is 88 | # intended to run in multiple environments; otherwise, check them in: 89 | # .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # poetry 99 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 100 | # This is especially recommended for binary packages to ensure reproducibility, and is more 101 | # commonly ignored for libraries. 102 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 103 | #poetry.lock 104 | 105 | # pdm 106 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 107 | #pdm.lock 108 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 109 | # in version control. 110 | # https://pdm.fming.dev/#use-with-ide 111 | .pdm.toml 112 | 113 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 114 | __pypackages__/ 115 | 116 | # Celery stuff 117 | celerybeat-schedule 118 | celerybeat.pid 119 | 120 | # SageMath parsed files 121 | *.sage.py 122 | 123 | # Environments 124 | .env 125 | .venv 126 | env/ 127 | venv/ 128 | ENV/ 129 | env.bak/ 130 | venv.bak/ 131 | 132 | # Spyder project settings 133 | .spyderproject 134 | .spyproject 135 | 136 | # Rope project settings 137 | .ropeproject 138 | 139 | # mkdocs documentation 140 | /site 141 | 142 | # mypy 143 | .mypy_cache/ 144 | .dmypy.json 145 | dmypy.json 146 | 147 | # Pyre type checker 148 | .pyre/ 149 | 150 | # pytype static type analyzer 151 | .pytype/ 152 | 153 | # Cython debug symbols 154 | cython_debug/ 155 | 156 | # PyCharm 157 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 158 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 159 | # and can be added to the global gitignore or merged into this file. For a more nuclear 160 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 161 | #.idea/ 162 | -------------------------------------------------------------------------------- /tests/test_config_load.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pytest 3 | from pydantic_config.errors import CliError 4 | from pydantic_config.parse import parse_args 5 | 6 | 7 | def string_to_file(tmp_path, content): 8 | with open(tmp_path, "w") as f: 9 | f.write(content) 10 | 11 | 12 | @pytest.fixture() 13 | def tmp_file(tmp_path): 14 | return os.path.join(tmp_path, "dummy_config.json") 15 | 16 | 17 | @pytest.fixture() 18 | def tmp_yaml_file(tmp_path): 19 | return os.path.join(tmp_path, "dummy_config.yaml") 20 | 21 | 22 | @pytest.fixture() 23 | def tmp_toml_file(tmp_path): 24 | return os.path.join(tmp_path, "dummy_config.toml") 25 | 26 | 27 | def test_config_file(tmp_file): 28 | config_dot_json = """ 29 | { 30 | "foo": "bar" 31 | } 32 | """ 33 | string_to_file(tmp_file, config_dot_json) 34 | 35 | argv = ["--hey", f"@{tmp_file}", "--hello", "world"] 36 | assert parse_args(argv) == {"hey": {"foo": "bar"}, "hello": "world"} 37 | 38 | 39 | def test_config_file_root_level(tmp_file): 40 | config_dot_json = """ 41 | { 42 | "foo": "bar" 43 | } 44 | """ 45 | string_to_file(tmp_file, config_dot_json) 46 | 47 | argv = [f"@{tmp_file}"] 48 | assert parse_args(argv) == {"foo": "bar"} 49 | 50 | 51 | def test_config_file_root_level_override(tmp_file): 52 | config_dot_json = """ 53 | { 54 | "foo": "bar" 55 | } 56 | """ 57 | string_to_file(tmp_file, config_dot_json) 58 | 59 | argv = [f"@{tmp_file}", "--foo", "world", "--hey", "oh"] 60 | assert parse_args(argv) == {"foo": "world", "hey": "oh"} 61 | 62 | 63 | def test_override_config_file_pre(tmp_file): 64 | config_dot_json = """ 65 | { 66 | "foo": "bar", 67 | "abc": "xyz" 68 | } 69 | """ 70 | string_to_file(tmp_file, config_dot_json) 71 | 72 | argv = ["--hey", f"@{tmp_file}", "--hey.foo", "world"] 73 | assert parse_args(argv) == {"hey": {"foo": "world", "abc": "xyz"}} 74 | 75 | 76 | def test_load_nested_config(tmp_file): 77 | config_dot_json = """ 78 | { 79 | "foo": "bar", 80 | "abc": "xyz" 81 | } 82 | """ 83 | string_to_file(tmp_file, config_dot_json) 84 | 85 | argv = ["--hey.config", f"@{tmp_file}"] 86 | assert parse_args(argv) == {"hey": {"config": {"foo": "bar", "abc": "xyz"}}} 87 | 88 | 89 | def test_override_config_file_post(tmp_file): 90 | """ 91 | testing the same as the pre test, but with the config file specified last 92 | """ 93 | config_dot_json = """ 94 | { 95 | "foo": "bar", 96 | "abc": "xyz" 97 | } 98 | """ 99 | string_to_file(tmp_file, config_dot_json) 100 | 101 | argv = ["--hey.foo", "world", "--hey", f"@{tmp_file}"] 102 | assert parse_args(argv) == {"hey": {"foo": "world", "abc": "xyz"}} 103 | 104 | 105 | def test_sub_config_file(tmp_file): 106 | config_dot_json = """ 107 | { 108 | "foo": "bar" 109 | } 110 | """ 111 | string_to_file(tmp_file, config_dot_json) 112 | 113 | argv = ["--abc.xyz", "world", "--abc.ijk", f"@{tmp_file}"] 114 | 115 | assert parse_args(argv) == {"abc": {"xyz": "world", "ijk": {"foo": "bar"}}} 116 | 117 | 118 | def test_sub_config_file_override(tmp_file): 119 | config_dot_json = """ 120 | { 121 | "foo": "bar" 122 | } 123 | """ 124 | string_to_file(tmp_file, config_dot_json) 125 | 126 | argv = ["--abc.ijk", f"@{tmp_file}", "--abc.ijk.foo", "world"] 127 | assert parse_args(argv) == {"abc": {"ijk": {"foo": "world"}}} 128 | 129 | 130 | def test_wrong_config_file(tmp_file): 131 | config_dot_json = """ 132 | { 133 | foo": "bar" 134 | } 135 | """ 136 | string_to_file(tmp_file, config_dot_json) 137 | argv = ["--hey", f"@{tmp_file}", "--hello", "world"] 138 | 139 | with pytest.raises(CliError): 140 | parse_args(argv) 141 | 142 | 143 | def test_yaml_config_file(tmp_yaml_file): 144 | config_dot_yaml = """ 145 | foo: bar 146 | """ 147 | string_to_file(tmp_yaml_file, config_dot_yaml) 148 | 149 | argv = ["--hey", f"@{tmp_yaml_file}", "--hello", "world"] 150 | assert parse_args(argv) == {"hey": {"foo": "bar"}, "hello": "world"} 151 | 152 | 153 | def test_toml_config_file(tmp_toml_file): 154 | config_dot_toml = """ 155 | foo = "bar" 156 | """ 157 | string_to_file(tmp_toml_file, config_dot_toml) 158 | 159 | argv = ["--hey", f"@{tmp_toml_file}", "--hello", "world"] 160 | assert parse_args(argv) == {"hey": {"foo": "bar"}, "hello": "world"} 161 | 162 | 163 | def test_config_file_root_level_override_toml(tmp_toml_file): 164 | config_dot_toml = """ 165 | [yo] 166 | foo = 10 167 | """ 168 | string_to_file(tmp_toml_file, config_dot_toml) 169 | 170 | argv = [f"@{tmp_toml_file}", "--yo.foo", "100", "--hey", "oh"] 171 | assert parse_args(argv) == {"yo": {"foo": "100"}, "hey": "oh"} 172 | 173 | 174 | def test_white_space_config_file(tmp_file): 175 | config_dot_json = """ 176 | { 177 | "foo": "bar" 178 | } 179 | """ 180 | string_to_file(tmp_file, config_dot_json) 181 | 182 | argv = ["--hey", "@", f"{tmp_file}", "--hello", "world"] 183 | assert parse_args(argv) == {"hey": {"foo": "bar"}, "hello": "world"} 184 | 185 | 186 | def test_config_file_root_level_override_toml_white_space(tmp_toml_file): 187 | config_dot_toml = """ 188 | [yo] 189 | foo = 10 190 | """ 191 | string_to_file(tmp_toml_file, config_dot_toml) 192 | 193 | argv = ["@", f"{tmp_toml_file}", "--yo.foo", "100", "--hey", "oh"] 194 | assert parse_args(argv) == {"yo": {"foo": "100"}, "hey": "oh"} 195 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pydantic config 2 | 3 | Pydantic is a dead simple config manager that built on top of pydantic. 4 | 5 | It can parse some configuration either from cli or from a yaml/json/toml file and validate it against a pydantic model. 6 | 7 | ## Install 8 | 9 | ```bash 10 | pip install git+https://github.com/samsja/pydantic_config 11 | ``` 12 | 13 | ## Example 14 | 15 | This is the code to define the cli (in a file name `simple_cli.py`) 16 | 17 | ```python 18 | from pydantic_config import parse_argv 19 | from pydantic import validate_call 20 | 21 | @validate_call 22 | def main(hello: str, foo: int): 23 | print(f"hello: {hello}, foo: {foo}") 24 | 25 | 26 | if __name__ == "__main__": 27 | main(**parse_argv()) 28 | ``` 29 | 30 | you can call it like this 31 | 32 | ```bash 33 | 34 | python simple_cli.py --hello world --foo bar 35 | >>> 'hello': 'world', 'foo': 1 36 | ``` 37 | 38 | Under the hood, the cli argument are converted to a (nested) dictionary and passed to the function. Pydantic is used to validate 39 | the argument, eventually coercing the type if needed. 40 | 41 | 42 | 43 | ## Nested Config 44 | 45 | Pydantic Config allow to represent nested config using pydantic [BaseModel](https://docs.pydantic.dev/latest/api/base_model/). 46 | 47 | The vision is that most ml code is a suite of nested funciton call, training loop calling model init, calling sub module init etcc. 48 | 49 | Allowing to represent the config as a nested model is the most natural way to represent ML code (IMO). It allows as well to locally define argument, tested them independently from other but still having a global config that can be validate ahead of time, allowing to fail early if necessary. 50 | 51 | 52 | ```python 53 | from pathlib import Path 54 | from pydantic_config import parse_argv, BaseConfig 55 | from pydantic import validate_call 56 | 57 | 58 | class TrainingConfig(BaseConfig): 59 | lr: float = 3e-4 60 | batch_size: int 61 | 62 | 63 | class DataConfig(BaseConfig): 64 | path: Path 65 | 66 | def prepare_data(conf: DataConfig): 67 | print(conf) 68 | 69 | def train_model(conf: TrainingConfig): 70 | print(conf) 71 | 72 | @validate_call 73 | def main(train: TrainingConfig, data: DataConfig): 74 | prepare_data(data) 75 | train_model(train) 76 | 77 | if __name__ == "__main__": 78 | main(**parse_argv()) 79 | 80 | ``` 81 | 82 | You can use it like this 83 | 84 | ```bash 85 | python examples/nested_cli.py --train.batch_size 32 --data.path ~/datasets 86 | 87 | >>> path=PosixPath('/home/sami/datasets') 88 | >>> lr=0.0003 batch_size=32 89 | ``` 90 | 91 | You can as well load config from a json file: 92 | 93 | ```bash 94 | python examples/nested_cli.py --train @examples/train_config.json --data.path ~/datasets 95 | 96 | >>> path=PosixPath('/home/sami/datasets') 97 | >>> lr=0.0003 batch_size=32 98 | ``` 99 | 100 | ## Yet another cli parser / config manager in python ? 101 | 102 | Yes sorry, but this one will stay as simple as possible. Arg to dict to pydantic. 103 | 104 | ### Why ? 105 | 106 | Because I have been tired of the different cli tool and config manager in the python ecosystem. I want to let [Pydantic](https://docs.pydantic.dev/latest/) handle all of the validation and coercion logic (because it is doing it great), I just need a simple tool that can 107 | generate a dict from the cli arguments and/or a json file and pass it pydantic. 108 | 109 | Pydantic_config is what most of the cli/config tool would have been if pydantic would have been released earlier. 110 | 111 | Honorable mention to the tool that I used in the past: 112 | 113 | * [Typer](https://typer.tiangolo.com/) 114 | * [cyclopts](https://github.com/BrianPugh/cyclopts) 115 | * [click](https://click.palletsprojects.com/en/8.0.x/cli/) 116 | * [fire](https://github.com/google/python-fire) 117 | * [jsonargparse](https://github.com/omni-us/jsonargparse) 118 | 119 | 120 | 121 | ## CLI syntax 122 | 123 | Pydantic config accept argument with two leading minus `-`. 124 | 125 | ```bash 126 | python main.py --arg value --arg2 value2 --arg3=value3 127 | ``` 128 | 129 | You can pass both using white space or using the `=` syntax. 130 | 131 | ### Python varaible, `-` and `_` 132 | 133 | Any other `-` will be converted to an underscoed `_`. As in python variable name use underscode but cli args are usaully using 134 | minus as seperator. 135 | 136 | This two are therefore equivalent 137 | ```bash 138 | python main.py --my-arg value 139 | python main.py --my_arg value 140 | ``` 141 | 142 | ### Nested argument 143 | 144 | Pydantic config support nested argument using the `.` delimiter 145 | 146 | ```bash 147 | python main.py --hello.foo bar --xyz value 148 | python main.py --hello.foo.a abc --hello.foo.b bar 149 | ``` 150 | 151 | this hierarchy will be translated into nested python dictionaries 152 | 153 | ### Boolean handling 154 | 155 | If you pass an argument without a value, pydantic_config will assume it is a boolean and set the value to `True`. 156 | 157 | ```bash 158 | python main.py --my-arg 159 | ``` 160 | 161 | Unless you pass `--no-my-arg`, which will set the value to `False`. 162 | 163 | ```bash 164 | python main.py --no-my-arg 165 | ``` 166 | 167 | ### List handling 168 | 169 | To pass as list, just a repeat the argument 170 | 171 | ```bash 172 | python main.py --my-list value1 --my-list value2 173 | >>> {"my_list": ["value1", "value2"]} 174 | ``` 175 | 176 | 177 | ### Loading config from file 178 | 179 | You can as well load config from a json file using the `@` in front of a value. Pydantic config will naivly load the config file and pass it as a python dict to pydantic to be validated. 180 | 181 | **Command line argument will have precedence over config file** 182 | 183 | example: 184 | 185 | ```bash 186 | python main.py --train @ train_config.json 187 | ``` 188 | 189 | 190 | You can as well load yaml file by using the `.yaml` or `.yml` extension or toml file by using the `.toml` extension 191 | 192 | ```bash 193 | python main.py --train @ train_config.yaml 194 | ``` 195 | 196 | both `@config.toml` and `@ config.toml` are valid and load the same way. 197 | 198 | **Note:pydantic_config will look at the file extension to determine the file type.** 199 | 200 | If you want to use `toml` or `yaml` file you need to install using 201 | ``` 202 | pip install .[toml] 203 | ``` 204 | or 205 | 206 | ``` 207 | pip install .[yaml] 208 | ``` 209 | 210 | # Development 211 | 212 | This project use [uv](https://github.com/astral-sh/uv) to manage python. 213 | 214 | update your env with the right dev env 215 | 216 | ```bash 217 | uv venv 218 | uv sync --extra all 219 | ``` 220 | 221 | Run test with 222 | 223 | ```bash 224 | uv run pytest -vv 225 | ``` 226 | 227 | to work on error messaging do: 228 | 229 | ``bash 230 | uv run python tests/ui_testing.py --foo bar 231 | ``` 232 | 233 | You can see all the error message by doing 234 | 235 | ```bash 236 | ./tests/saw_error_message.sh 237 | ``` 238 | 239 | 240 | 241 | ## todo list 242 | 243 | - [ ] rename since pydantic_config is already used on pypi 244 | - [x] add decorator to wrap function 245 | - [x] add rich for ui 246 | - [x] add no prefix to negate boolean 247 | - [x] nice error message 248 | 249 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | Apache Licnse 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 [yyyy] [name of copyright owner] 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.e 202 | -------------------------------------------------------------------------------- /src/pydantic_config/parse.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import copy 3 | import json 4 | from typing import TypeAlias 5 | import sys 6 | import importlib 7 | 8 | from pydantic_config.errors import CliError, InvalidConfigFileError 9 | 10 | RawValue: TypeAlias = str | bool 11 | 12 | 13 | class Value: 14 | """ 15 | Hold a value as well as a priority info 16 | """ 17 | 18 | def __init__(self, value: RawValue, priority: int): 19 | self.value = value 20 | self.priority = priority 21 | 22 | def __repr__(self) -> str: 23 | return f"{self.value} ({self.priority})" 24 | 25 | 26 | NestedArgs: TypeAlias = dict[str, "NestedArgs"] # dict[str, "NestedArgs" | Value] 27 | 28 | 29 | CONFIG_FILE_SIGN = "@" 30 | 31 | 32 | def parse_nested_args(arg_name: str, value: RawValue) -> NestedArgs: 33 | """ 34 | Take an arg_name and a value and return a nested dictionary. 35 | 36 | Mainly look for nested dot notation in the arg_name and unest it if needed. 37 | 38 | Example: 39 | 40 | >>> parse_nested_args("a.b.c.d", "value") 41 | {"a": {"b": {"c": {"d": "value"}}}} 42 | """ 43 | if "." not in arg_name: 44 | return {arg_name: value} 45 | else: 46 | left_name, *rest = arg_name.split(".") 47 | rest = ".".join(rest) 48 | return {left_name: parse_nested_args(rest, value)} 49 | 50 | 51 | def normalize_arg_name(arg_name: str) -> str: 52 | """remove prefix are replaced - with _""" 53 | arg_name = copy.deepcopy(arg_name) 54 | if arg_name.startswith("--no-"): 55 | arg_name = arg_name.removeprefix("--no-") 56 | else: 57 | arg_name = arg_name.removeprefix("--") 58 | 59 | arg_name = arg_name.replace("-", "_") 60 | return arg_name 61 | 62 | 63 | def unwrap_value(args: NestedArgs) -> NestedArgs: 64 | """ 65 | Look for value as leaf in a nested args and cast to its content 66 | """ 67 | for key, value in args.items(): 68 | if isinstance(value, Value): 69 | args[key] = value.value 70 | elif isinstance(value, dict): 71 | unwrap_value(value) 72 | else: 73 | raise ValueError(f"Invalid value type {type(value)}") 74 | return args 75 | 76 | 77 | def load_config_file(path: str, priority: int) -> NestedArgs: 78 | """ 79 | Load a config file and return a nested dictionary. 80 | """ 81 | 82 | content = None 83 | try: 84 | with open(path, "rb") as f: 85 | if path.endswith(".json"): 86 | try: 87 | content = json.load(f) 88 | except json.JSONDecodeError as e: 89 | raise InvalidConfigFileError(e) 90 | elif importlib.util.find_spec("yaml") is not None and path.endswith(".yaml") or path.endswith(".yml"): 91 | import yaml 92 | 93 | try: 94 | content = yaml.load(f, Loader=yaml.FullLoader) 95 | except yaml.YAMLError as e: 96 | raise InvalidConfigFileError(e) 97 | elif importlib.util.find_spec("tomli") is not None and path.endswith(".toml"): 98 | import tomli 99 | 100 | try: 101 | content = tomli.load(f) 102 | except tomli.TOMLDecodeError as e: 103 | raise InvalidConfigFileError(e) 104 | else: 105 | raise InvalidConfigFileError(f"Unsupported file type: {path}") 106 | except FileNotFoundError: 107 | raise InvalidConfigFileError(f"File {path} not found") 108 | 109 | def wrap_value(nested_dict): 110 | if isinstance(nested_dict, dict): 111 | for key, value in nested_dict.items(): 112 | nested_dict[key] = wrap_value(value) 113 | return nested_dict 114 | else: 115 | return Value(nested_dict, priority) 116 | 117 | return wrap_value(content) 118 | 119 | 120 | def parse_args(args: list[str]) -> NestedArgs: 121 | """ 122 | Parse and validated a list of raw arguments. 123 | 124 | Example 125 | >>> parse_args(["--hello", "world", "--foo.bar", "galaxy"]) 126 | {"hello": "world", "foo": {"bar": "galaxy"}} 127 | """ 128 | 129 | args_original = args 130 | args = copy.deepcopy(args) 131 | suggestion_args = copy.deepcopy(args) 132 | 133 | merged_args = {} 134 | 135 | i = 0 136 | 137 | while i < len(args): 138 | potential_arg_name = args[i] 139 | 140 | if i == 0 and args[i].startswith(CONFIG_FILE_SIGN): 141 | ## if we start with a config value we don't need a key name 142 | ## example python train.py @llama_7b.json 143 | 144 | if args[i] == "@": 145 | config_name = args[i + 1] 146 | i += 2 147 | else: 148 | config_name = args[i].removeprefix(CONFIG_FILE_SIGN) 149 | i += 1 150 | try: 151 | value = load_config_file(config_name, priority=0) 152 | except InvalidConfigFileError as e: 153 | raise CliError( 154 | args_original, 155 | [0], 156 | f"Invalid config file [bold]{config_name}[/bold]. Original error: {e.original_error}", 157 | [], 158 | ) 159 | 160 | merged_args.update(value) 161 | 162 | if not potential_arg_name.startswith("--") and not potential_arg_name.startswith(CONFIG_FILE_SIGN): 163 | ## arg_name should start with "--" Example "--hello a" 164 | error_msg = "the first argument should start with '--'" 165 | suggestion_args[i] = "--" + potential_arg_name 166 | raise CliError(args_original, [i], error_msg, suggestion_args) 167 | 168 | if not potential_arg_name.startswith(CONFIG_FILE_SIGN): 169 | # once we have the arg name we look for the arg value 170 | arg_name = potential_arg_name 171 | need_to_load_config_file = False 172 | 173 | # if we are at the end of the list, we assume the last value is a boolean 174 | if "=" in potential_arg_name: 175 | splits = potential_arg_name.split("=") 176 | if len(splits) != 2: 177 | raise CliError( 178 | args_original, [i], "Invalid argument format. Expected --arg=value, found two or mote =", [] 179 | ) 180 | arg_name = splits[0] 181 | value = splits[1] 182 | increment = 1 183 | elif i == len(args) - 1: 184 | value = None 185 | increment = 1 186 | elif "=" in potential_arg_name: 187 | splits = potential_arg_name.split("=") 188 | if len(splits) != 2: 189 | raise CliError( 190 | args_original, [i], "Invalid argument format. Expected --arg=value, found two or mote =", [] 191 | ) 192 | arg_name = splits[0] 193 | value = splits[1] 194 | increment = 1 195 | else: 196 | arg_value = args[i + 1] 197 | 198 | if arg_value.startswith("--"): 199 | ## Example "--hello --foo a". Hello is a bool here 200 | value = None 201 | increment = 1 # we want to analyse --foo next 202 | elif arg_value == CONFIG_FILE_SIGN: # example " --hello @ config.json --foo" 203 | if i == len(args) - 1: 204 | raise CliError(args_original, [i], "Cannot end with @", []) 205 | else: 206 | value = args[i + 2] 207 | increment = 3 # we want to analyse --foo next 208 | need_to_load_config_file = True 209 | else: 210 | ## example "--hello a --foo b" 211 | value = arg_value 212 | increment = 2 # we want to analyse --foo next 213 | 214 | if value is not None and arg_name.startswith("--no-"): 215 | error_msg = "Boolean flag starting with '--no-' cannot be follow by a argument value" 216 | suggestion_args[i] = "--" + arg_name.removeprefix("--no-") 217 | raise CliError(args_original, [i, i + 1], error_msg, suggestion_args) 218 | 219 | if value is None: 220 | # if it start with --no then value is False else True 221 | value = not (arg_name.startswith("--no-")) 222 | 223 | arg_name = normalize_arg_name(arg_name) 224 | if isinstance(value, str) and value.startswith(CONFIG_FILE_SIGN): 225 | value = value.removeprefix(CONFIG_FILE_SIGN) 226 | need_to_load_config_file = True 227 | 228 | if need_to_load_config_file: 229 | try: 230 | value = load_config_file(value, priority=0) 231 | except InvalidConfigFileError as e: 232 | raise CliError( 233 | args_original, 234 | [i, i + 1], 235 | f"Invalid config file [bold]{value.removeprefix(CONFIG_FILE_SIGN)}[/bold]. Original error: {e.original_error}", 236 | [], 237 | ) 238 | else: 239 | value = Value(value, priority=1) # command line are priority over config file 240 | 241 | parsed_arg = parse_nested_args(arg_name, value) 242 | 243 | def merge_dict(name, left, right): 244 | if name not in left.keys(): 245 | left[name] = right[name] 246 | else: 247 | arg = left[name] 248 | new_arg = right[name] 249 | if isinstance(arg, Value): 250 | if not isinstance(new_arg, Value): 251 | raise CliError(args_original, [i], f"Conflicting value for {name}", []) 252 | 253 | if not isinstance(new_arg.value, dict): 254 | if isinstance(arg.value, dict): 255 | raise CliError(args_original, [i], f"Conflicting value for {name}", []) 256 | if new_arg.priority > arg.priority: 257 | left[name] = new_arg 258 | elif new_arg.priority < arg.priority: 259 | ... 260 | else: 261 | # if we get mutiple non bool arg we put them into a list 262 | if isinstance(arg.value, bool) or isinstance(new_arg.value, bool): 263 | raise CliError(args_original, [i], f"Conflicting boolean flag for {name}", []) 264 | else: 265 | arg.value = [arg.value] 266 | 267 | arg.value.append(new_arg.value) 268 | 269 | elif isinstance(arg, dict): 270 | if not isinstance(new_arg, dict): 271 | raise CliError(args_original, [i], f"Conflicting boolean flag for {name}", []) 272 | 273 | for nested_arg_name in right[name].keys(): 274 | merge_dict(nested_arg_name, left[name], right[name]) 275 | else: 276 | # should never arrive here 277 | raise ValueError() 278 | 279 | for name in parsed_arg.keys(): 280 | merge_dict(name, merged_args, parsed_arg) 281 | 282 | i += increment 283 | 284 | return unwrap_value(merged_args) 285 | 286 | 287 | def parse_argv() -> NestedArgs: 288 | """ 289 | Parse argument from argv and return a nested python dictionary. 290 | """ 291 | try: 292 | program_name = sys.argv[0] 293 | args = list(sys.argv)[1:] 294 | return parse_args(args) 295 | except CliError as e: 296 | e.program_name = program_name 297 | e.render() 298 | sys.exit(1) 299 | -------------------------------------------------------------------------------- /uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | requires-python = ">=3.10" 3 | resolution-markers = [ 4 | "python_full_version < '3.13'", 5 | "python_full_version >= '3.13'", 6 | ] 7 | 8 | [[package]] 9 | name = "annotated-types" 10 | version = "0.7.0" 11 | source = { registry = "https://pypi.org/simple" } 12 | sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } 13 | wheels = [ 14 | { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, 15 | ] 16 | 17 | [[package]] 18 | name = "cfgv" 19 | version = "3.4.0" 20 | source = { registry = "https://pypi.org/simple" } 21 | sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114 } 22 | wheels = [ 23 | { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249 }, 24 | ] 25 | 26 | [[package]] 27 | name = "colorama" 28 | version = "0.4.6" 29 | source = { registry = "https://pypi.org/simple" } 30 | sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } 31 | wheels = [ 32 | { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, 33 | ] 34 | 35 | [[package]] 36 | name = "distlib" 37 | version = "0.3.8" 38 | source = { registry = "https://pypi.org/simple" } 39 | sdist = { url = "https://files.pythonhosted.org/packages/c4/91/e2df406fb4efacdf46871c25cde65d3c6ee5e173b7e5a4547a47bae91920/distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64", size = 609931 } 40 | wheels = [ 41 | { url = "https://files.pythonhosted.org/packages/8e/41/9307e4f5f9976bc8b7fea0b66367734e8faf3ec84bc0d412d8cfabbb66cd/distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784", size = 468850 }, 42 | ] 43 | 44 | [[package]] 45 | name = "exceptiongroup" 46 | version = "1.2.2" 47 | source = { registry = "https://pypi.org/simple" } 48 | sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 } 49 | wheels = [ 50 | { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, 51 | ] 52 | 53 | [[package]] 54 | name = "filelock" 55 | version = "3.15.4" 56 | source = { registry = "https://pypi.org/simple" } 57 | sdist = { url = "https://files.pythonhosted.org/packages/08/dd/49e06f09b6645156550fb9aee9cc1e59aba7efbc972d665a1bd6ae0435d4/filelock-3.15.4.tar.gz", hash = "sha256:2207938cbc1844345cb01a5a95524dae30f0ce089eba5b00378295a17e3e90cb", size = 18007 } 58 | wheels = [ 59 | { url = "https://files.pythonhosted.org/packages/ae/f0/48285f0262fe47103a4a45972ed2f9b93e4c80b8fd609fa98da78b2a5706/filelock-3.15.4-py3-none-any.whl", hash = "sha256:6ca1fffae96225dab4c6eaf1c4f4f28cd2568d3ec2a44e15a08520504de468e7", size = 16159 }, 60 | ] 61 | 62 | [[package]] 63 | name = "identify" 64 | version = "2.6.0" 65 | source = { registry = "https://pypi.org/simple" } 66 | sdist = { url = "https://files.pythonhosted.org/packages/32/f4/8e8f7db397a7ce20fbdeac5f25adaf567fc362472432938d25556008e03a/identify-2.6.0.tar.gz", hash = "sha256:cb171c685bdc31bcc4c1734698736a7d5b6c8bf2e0c15117f4d469c8640ae5cf", size = 99116 } 67 | wheels = [ 68 | { url = "https://files.pythonhosted.org/packages/24/6c/a4f39abe7f19600b74528d0c717b52fff0b300bb0161081510d39c53cb00/identify-2.6.0-py2.py3-none-any.whl", hash = "sha256:e79ae4406387a9d300332b5fd366d8994f1525e8414984e1a59e058b2eda2dd0", size = 98962 }, 69 | ] 70 | 71 | [[package]] 72 | name = "iniconfig" 73 | version = "2.0.0" 74 | source = { registry = "https://pypi.org/simple" } 75 | sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } 76 | wheels = [ 77 | { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, 78 | ] 79 | 80 | [[package]] 81 | name = "markdown-it-py" 82 | version = "3.0.0" 83 | source = { registry = "https://pypi.org/simple" } 84 | dependencies = [ 85 | { name = "mdurl" }, 86 | ] 87 | sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } 88 | wheels = [ 89 | { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, 90 | ] 91 | 92 | [[package]] 93 | name = "mdurl" 94 | version = "0.1.2" 95 | source = { registry = "https://pypi.org/simple" } 96 | sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } 97 | wheels = [ 98 | { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, 99 | ] 100 | 101 | [[package]] 102 | name = "nodeenv" 103 | version = "1.9.1" 104 | source = { registry = "https://pypi.org/simple" } 105 | sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437 } 106 | wheels = [ 107 | { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, 108 | ] 109 | 110 | [[package]] 111 | name = "packaging" 112 | version = "24.1" 113 | source = { registry = "https://pypi.org/simple" } 114 | sdist = { url = "https://files.pythonhosted.org/packages/51/65/50db4dda066951078f0a96cf12f4b9ada6e4b811516bf0262c0f4f7064d4/packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002", size = 148788 } 115 | wheels = [ 116 | { url = "https://files.pythonhosted.org/packages/08/aa/cc0199a5f0ad350994d660967a8efb233fe0416e4639146c089643407ce6/packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124", size = 53985 }, 117 | ] 118 | 119 | [[package]] 120 | name = "platformdirs" 121 | version = "4.2.2" 122 | source = { registry = "https://pypi.org/simple" } 123 | sdist = { url = "https://files.pythonhosted.org/packages/f5/52/0763d1d976d5c262df53ddda8d8d4719eedf9594d046f117c25a27261a19/platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3", size = 20916 } 124 | wheels = [ 125 | { url = "https://files.pythonhosted.org/packages/68/13/2aa1f0e1364feb2c9ef45302f387ac0bd81484e9c9a4c5688a322fbdfd08/platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee", size = 18146 }, 126 | ] 127 | 128 | [[package]] 129 | name = "pluggy" 130 | version = "1.5.0" 131 | source = { registry = "https://pypi.org/simple" } 132 | sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } 133 | wheels = [ 134 | { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, 135 | ] 136 | 137 | [[package]] 138 | name = "pre-commit" 139 | version = "3.8.0" 140 | source = { registry = "https://pypi.org/simple" } 141 | dependencies = [ 142 | { name = "cfgv" }, 143 | { name = "identify" }, 144 | { name = "nodeenv" }, 145 | { name = "pyyaml" }, 146 | { name = "virtualenv" }, 147 | ] 148 | sdist = { url = "https://files.pythonhosted.org/packages/64/10/97ee2fa54dff1e9da9badbc5e35d0bbaef0776271ea5907eccf64140f72f/pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af", size = 177815 } 149 | wheels = [ 150 | { url = "https://files.pythonhosted.org/packages/07/92/caae8c86e94681b42c246f0bca35c059a2f0529e5b92619f6aba4cf7e7b6/pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f", size = 204643 }, 151 | ] 152 | 153 | [[package]] 154 | name = "pydantic" 155 | version = "2.8.2" 156 | source = { registry = "https://pypi.org/simple" } 157 | dependencies = [ 158 | { name = "annotated-types" }, 159 | { name = "pydantic-core" }, 160 | { name = "typing-extensions" }, 161 | ] 162 | sdist = { url = "https://files.pythonhosted.org/packages/8c/99/d0a5dca411e0a017762258013ba9905cd6e7baa9a3fd1fe8b6529472902e/pydantic-2.8.2.tar.gz", hash = "sha256:6f62c13d067b0755ad1c21a34bdd06c0c12625a22b0fc09c6b149816604f7c2a", size = 739834 } 163 | wheels = [ 164 | { url = "https://files.pythonhosted.org/packages/1f/fa/b7f815b8c9ad021c07f88875b601222ef5e70619391ade4a49234d12d278/pydantic-2.8.2-py3-none-any.whl", hash = "sha256:73ee9fddd406dc318b885c7a2eab8a6472b68b8fb5ba8150949fc3db939f23c8", size = 423875 }, 165 | ] 166 | 167 | [[package]] 168 | name = "pydantic-config" 169 | version = "0.2.0" 170 | source = { editable = "." } 171 | dependencies = [ 172 | { name = "pydantic" }, 173 | { name = "rich" }, 174 | ] 175 | 176 | [package.optional-dependencies] 177 | all = [ 178 | { name = "pyyaml" }, 179 | { name = "tomli" }, 180 | ] 181 | toml = [ 182 | { name = "tomli" }, 183 | ] 184 | yaml = [ 185 | { name = "pyyaml" }, 186 | ] 187 | 188 | [package.dev-dependencies] 189 | dev = [ 190 | { name = "pre-commit" }, 191 | { name = "pytest" }, 192 | { name = "ruff" }, 193 | ] 194 | 195 | [package.metadata] 196 | requires-dist = [ 197 | { name = "pydantic", specifier = ">=2.0.0" }, 198 | { name = "pyyaml", marker = "extra == 'all'" }, 199 | { name = "pyyaml", marker = "extra == 'yaml'" }, 200 | { name = "rich" }, 201 | { name = "tomli", marker = "extra == 'all'" }, 202 | { name = "tomli", marker = "extra == 'toml'" }, 203 | ] 204 | 205 | [package.metadata.requires-dev] 206 | dev = [ 207 | { name = "pre-commit", specifier = ">=3.0.0" }, 208 | { name = "pytest", specifier = ">=7.0.0" }, 209 | { name = "ruff", specifier = "==0.5.0" }, 210 | ] 211 | 212 | [[package]] 213 | name = "pydantic-core" 214 | version = "2.20.1" 215 | source = { registry = "https://pypi.org/simple" } 216 | dependencies = [ 217 | { name = "typing-extensions" }, 218 | ] 219 | sdist = { url = "https://files.pythonhosted.org/packages/12/e3/0d5ad91211dba310f7ded335f4dad871172b9cc9ce204f5a56d76ccd6247/pydantic_core-2.20.1.tar.gz", hash = "sha256:26ca695eeee5f9f1aeeb211ffc12f10bcb6f71e2989988fda61dabd65db878d4", size = 388371 } 220 | wheels = [ 221 | { url = "https://files.pythonhosted.org/packages/6b/9d/f30f080f745682e762512f3eef1f6e392c7d74a102e6e96de8a013a5db84/pydantic_core-2.20.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3acae97ffd19bf091c72df4d726d552c473f3576409b2a7ca36b2f535ffff4a3", size = 1837257 }, 222 | { url = "https://files.pythonhosted.org/packages/f2/89/77e7aebdd4a235497ac1e07f0a99e9f40e47f6e0f6783fe30500df08fc42/pydantic_core-2.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:41f4c96227a67a013e7de5ff8f20fb496ce573893b7f4f2707d065907bffdbd6", size = 1776715 }, 223 | { url = "https://files.pythonhosted.org/packages/18/50/5a4e9120b395108c2a0441a425356c0d26a655d7c617288bec1c28b854ac/pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f239eb799a2081495ea659d8d4a43a8f42cd1fe9ff2e7e436295c38a10c286a", size = 1789023 }, 224 | { url = "https://files.pythonhosted.org/packages/c7/e5/f19e13ba86b968d024b56aa53f40b24828652ac026e5addd0ae49eeada02/pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53e431da3fc53360db73eedf6f7124d1076e1b4ee4276b36fb25514544ceb4a3", size = 1775598 }, 225 | { url = "https://files.pythonhosted.org/packages/c9/c7/f3c29bed28bd022c783baba5bf9946c4f694cb837a687e62f453c81eb5c6/pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1f62b2413c3a0e846c3b838b2ecd6c7a19ec6793b2a522745b0869e37ab5bc1", size = 1977691 }, 226 | { url = "https://files.pythonhosted.org/packages/41/3e/f62c2a05c554fff34570f6788617e9670c83ed7bc07d62a55cccd1bc0be6/pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d41e6daee2813ecceea8eda38062d69e280b39df793f5a942fa515b8ed67953", size = 2693214 }, 227 | { url = "https://files.pythonhosted.org/packages/ae/49/8a6fe79d35e2f3bea566d8ea0e4e6f436d4f749d7838c8e8c4c5148ae706/pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d482efec8b7dc6bfaedc0f166b2ce349df0011f5d2f1f25537ced4cfc34fd98", size = 2061047 }, 228 | { url = "https://files.pythonhosted.org/packages/51/c6/585355c7c8561e11197dbf6333c57dd32f9f62165d48589b57ced2373d97/pydantic_core-2.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e93e1a4b4b33daed65d781a57a522ff153dcf748dee70b40c7258c5861e1768a", size = 1895106 }, 229 | { url = "https://files.pythonhosted.org/packages/ce/23/829f6b87de0775919e82f8addef8b487ace1c77bb4cb754b217f7b1301b6/pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e7c4ea22b6739b162c9ecaaa41d718dfad48a244909fe7ef4b54c0b530effc5a", size = 1968506 }, 230 | { url = "https://files.pythonhosted.org/packages/ca/2f/f8ca8f0c40b3ee0a4d8730a51851adb14c5eda986ec09f8d754b2fba784e/pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4f2790949cf385d985a31984907fecb3896999329103df4e4983a4a41e13e840", size = 2110217 }, 231 | { url = "https://files.pythonhosted.org/packages/bb/a0/1876656c7b17eb69cc683452cce6bb890dd722222a71b3de57ddb512f561/pydantic_core-2.20.1-cp310-none-win32.whl", hash = "sha256:5e999ba8dd90e93d57410c5e67ebb67ffcaadcea0ad973240fdfd3a135506250", size = 1709669 }, 232 | { url = "https://files.pythonhosted.org/packages/be/4a/576524eefa9b301c088c4818dc50ff1c51a88fe29efd87ab75748ae15fd7/pydantic_core-2.20.1-cp310-none-win_amd64.whl", hash = "sha256:512ecfbefef6dac7bc5eaaf46177b2de58cdf7acac8793fe033b24ece0b9566c", size = 1902386 }, 233 | { url = "https://files.pythonhosted.org/packages/61/db/f6a724db226d990a329910727cfac43539ff6969edc217286dd05cda3ef6/pydantic_core-2.20.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d2a8fa9d6d6f891f3deec72f5cc668e6f66b188ab14bb1ab52422fe8e644f312", size = 1834507 }, 234 | { url = "https://files.pythonhosted.org/packages/9b/83/6f2bfe75209d557ae1c3550c1252684fc1827b8b12fbed84c3b4439e135d/pydantic_core-2.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:175873691124f3d0da55aeea1d90660a6ea7a3cfea137c38afa0a5ffabe37b88", size = 1773527 }, 235 | { url = "https://files.pythonhosted.org/packages/93/ef/513ea76d7ca81f2354bb9c8d7839fc1157673e652613f7e1aff17d8ce05d/pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37eee5b638f0e0dcd18d21f59b679686bbd18917b87db0193ae36f9c23c355fc", size = 1787879 }, 236 | { url = "https://files.pythonhosted.org/packages/31/0a/ac294caecf235f0cc651de6232f1642bb793af448d1cfc541b0dc1fd72b8/pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25e9185e2d06c16ee438ed39bf62935ec436474a6ac4f9358524220f1b236e43", size = 1774694 }, 237 | { url = "https://files.pythonhosted.org/packages/46/a4/08f12b5512f095963550a7cb49ae010e3f8f3f22b45e508c2cb4d7744fce/pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:150906b40ff188a3260cbee25380e7494ee85048584998c1e66df0c7a11c17a6", size = 1976369 }, 238 | { url = "https://files.pythonhosted.org/packages/15/59/b2495be4410462aedb399071c71884042a2c6443319cbf62d00b4a7ed7a5/pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ad4aeb3e9a97286573c03df758fc7627aecdd02f1da04516a86dc159bf70121", size = 2691250 }, 239 | { url = "https://files.pythonhosted.org/packages/3c/ae/fc99ce1ba791c9e9d1dee04ce80eef1dae5b25b27e3fc8e19f4e3f1348bf/pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3f3ed29cd9f978c604708511a1f9c2fdcb6c38b9aae36a51905b8811ee5cbf1", size = 2061462 }, 240 | { url = "https://files.pythonhosted.org/packages/44/bb/eb07cbe47cfd638603ce3cb8c220f1a054b821e666509e535f27ba07ca5f/pydantic_core-2.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b0dae11d8f5ded51699c74d9548dcc5938e0804cc8298ec0aa0da95c21fff57b", size = 1893923 }, 241 | { url = "https://files.pythonhosted.org/packages/ce/ef/5a52400553b8faa0e7f11fd7a2ba11e8d2feb50b540f9e7973c49b97eac0/pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:faa6b09ee09433b87992fb5a2859efd1c264ddc37280d2dd5db502126d0e7f27", size = 1966779 }, 242 | { url = "https://files.pythonhosted.org/packages/4c/5b/fb37fe341344d9651f5c5f579639cd97d50a457dc53901aa8f7e9f28beb9/pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9dc1b507c12eb0481d071f3c1808f0529ad41dc415d0ca11f7ebfc666e66a18b", size = 2109044 }, 243 | { url = "https://files.pythonhosted.org/packages/70/1a/6f7278802dbc66716661618807ab0dfa4fc32b09d1235923bbbe8b3a5757/pydantic_core-2.20.1-cp311-none-win32.whl", hash = "sha256:fa2fddcb7107e0d1808086ca306dcade7df60a13a6c347a7acf1ec139aa6789a", size = 1708265 }, 244 | { url = "https://files.pythonhosted.org/packages/35/7f/58758c42c61b0bdd585158586fecea295523d49933cb33664ea888162daf/pydantic_core-2.20.1-cp311-none-win_amd64.whl", hash = "sha256:40a783fb7ee353c50bd3853e626f15677ea527ae556429453685ae32280c19c2", size = 1901750 }, 245 | { url = "https://files.pythonhosted.org/packages/6f/47/ef0d60ae23c41aced42921728650460dc831a0adf604bfa66b76028cb4d0/pydantic_core-2.20.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:595ba5be69b35777474fa07f80fc260ea71255656191adb22a8c53aba4479231", size = 1839225 }, 246 | { url = "https://files.pythonhosted.org/packages/6a/23/430f2878c9cd977a61bb39f71751d9310ec55cee36b3d5bf1752c6341fd0/pydantic_core-2.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a4f55095ad087474999ee28d3398bae183a66be4823f753cd7d67dd0153427c9", size = 1768604 }, 247 | { url = "https://files.pythonhosted.org/packages/9e/2b/ec4e7225dee79e0dc80ccc3c35ab33cc2c4bbb8a1a7ecf060e5e453651ec/pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9aa05d09ecf4c75157197f27cdc9cfaeb7c5f15021c6373932bf3e124af029f", size = 1789767 }, 248 | { url = "https://files.pythonhosted.org/packages/64/b0/38b24a1fa6d2f96af3148362e10737ec073768cd44d3ec21dca3be40a519/pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e97fdf088d4b31ff4ba35db26d9cc472ac7ef4a2ff2badeabf8d727b3377fc52", size = 1772061 }, 249 | { url = "https://files.pythonhosted.org/packages/5e/da/bb73274c42cb60decfa61e9eb0c9029da78b3b9af0a9de0309dbc8ff87b6/pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc633a9fe1eb87e250b5c57d389cf28998e4292336926b0b6cdaee353f89a237", size = 1974573 }, 250 | { url = "https://files.pythonhosted.org/packages/c8/65/41693110fb3552556180460daffdb8bbeefb87fc026fd9aa4b849374015c/pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d573faf8eb7e6b1cbbcb4f5b247c60ca8be39fe2c674495df0eb4318303137fe", size = 2625596 }, 251 | { url = "https://files.pythonhosted.org/packages/09/b3/a5a54b47cccd1ab661ed5775235c5e06924753c2d4817737c5667bfa19a8/pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26dc97754b57d2fd00ac2b24dfa341abffc380b823211994c4efac7f13b9e90e", size = 2099064 }, 252 | { url = "https://files.pythonhosted.org/packages/52/fa/443a7a6ea54beaba45ff3a59f3d3e6e3004b7460bcfb0be77bcf98719d3b/pydantic_core-2.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:33499e85e739a4b60c9dac710c20a08dc73cb3240c9a0e22325e671b27b70d24", size = 1900345 }, 253 | { url = "https://files.pythonhosted.org/packages/8e/e6/9aca9ffae60f9cdf0183069de3e271889b628d0fb175913fcb3db5618fb1/pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bebb4d6715c814597f85297c332297c6ce81e29436125ca59d1159b07f423eb1", size = 1968252 }, 254 | { url = "https://files.pythonhosted.org/packages/46/5e/6c716810ea20a6419188992973a73c2fb4eb99cd382368d0637ddb6d3c99/pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:516d9227919612425c8ef1c9b869bbbee249bc91912c8aaffb66116c0b447ebd", size = 2119191 }, 255 | { url = "https://files.pythonhosted.org/packages/06/fc/6123b00a9240fbb9ae0babad7a005d51103d9a5d39c957a986f5cdd0c271/pydantic_core-2.20.1-cp312-none-win32.whl", hash = "sha256:469f29f9093c9d834432034d33f5fe45699e664f12a13bf38c04967ce233d688", size = 1717788 }, 256 | { url = "https://files.pythonhosted.org/packages/d5/36/e61ad5a46607a469e2786f398cd671ebafcd9fb17f09a2359985c7228df5/pydantic_core-2.20.1-cp312-none-win_amd64.whl", hash = "sha256:035ede2e16da7281041f0e626459bcae33ed998cca6a0a007a5ebb73414ac72d", size = 1898188 }, 257 | { url = "https://files.pythonhosted.org/packages/49/75/40b0e98b658fdba02a693b3bacb4c875a28bba87796c7b13975976597d8c/pydantic_core-2.20.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0827505a5c87e8aa285dc31e9ec7f4a17c81a813d45f70b1d9164e03a813a686", size = 1838688 }, 258 | { url = "https://files.pythonhosted.org/packages/75/02/d8ba2d4a266591a6a623c68b331b96523d4b62ab82a951794e3ed8907390/pydantic_core-2.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19c0fa39fa154e7e0b7f82f88ef85faa2a4c23cc65aae2f5aea625e3c13c735a", size = 1768409 }, 259 | { url = "https://files.pythonhosted.org/packages/91/ae/25ecd9bc4ce4993e99a1a3c9ab111c082630c914260e129572fafed4ecc2/pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa223cd1e36b642092c326d694d8bf59b71ddddc94cdb752bbbb1c5c91d833b", size = 1789317 }, 260 | { url = "https://files.pythonhosted.org/packages/7a/80/72057580681cdbe55699c367963d9c661b569a1d39338b4f6239faf36cdc/pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c336a6d235522a62fef872c6295a42ecb0c4e1d0f1a3e500fe949415761b8a19", size = 1771949 }, 261 | { url = "https://files.pythonhosted.org/packages/a2/be/d9bbabc55b05019013180f141fcaf3b14dbe15ca7da550e95b60c321009a/pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7eb6a0587eded33aeefea9f916899d42b1799b7b14b8f8ff2753c0ac1741edac", size = 1974392 }, 262 | { url = "https://files.pythonhosted.org/packages/79/2d/7bcd938c6afb0f40293283f5f09988b61fb0a4f1d180abe7c23a2f665f8e/pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:70c8daf4faca8da5a6d655f9af86faf6ec2e1768f4b8b9d0226c02f3d6209703", size = 2625565 }, 263 | { url = "https://files.pythonhosted.org/packages/ac/88/ca758e979457096008a4b16a064509028e3e092a1e85a5ed6c18ced8da88/pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9fa4c9bf273ca41f940bceb86922a7667cd5bf90e95dbb157cbb8441008482c", size = 2098784 }, 264 | { url = "https://files.pythonhosted.org/packages/eb/de/2fad6d63c3c42e472e985acb12ec45b7f56e42e6f4cd6dfbc5e87ee8678c/pydantic_core-2.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:11b71d67b4725e7e2a9f6e9c0ac1239bbc0c48cce3dc59f98635efc57d6dac83", size = 1900198 }, 265 | { url = "https://files.pythonhosted.org/packages/fe/50/077c7f35b6488dc369a6d22993af3a37901e198630f38ac43391ca730f5b/pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:270755f15174fb983890c49881e93f8f1b80f0b5e3a3cc1394a255706cabd203", size = 1968005 }, 266 | { url = "https://files.pythonhosted.org/packages/5d/1f/f378631574ead46d636b9a04a80ff878b9365d4b361b1905ef1667d4182a/pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c81131869240e3e568916ef4c307f8b99583efaa60a8112ef27a366eefba8ef0", size = 2118920 }, 267 | { url = "https://files.pythonhosted.org/packages/7a/ea/e4943f17df7a3031d709481fe4363d4624ae875a6409aec34c28c9e6cf59/pydantic_core-2.20.1-cp313-none-win32.whl", hash = "sha256:b91ced227c41aa29c672814f50dbb05ec93536abf8f43cd14ec9521ea09afe4e", size = 1717397 }, 268 | { url = "https://files.pythonhosted.org/packages/13/63/b95781763e8d84207025071c0cec16d921c0163c7a9033ae4b9a0e020dc7/pydantic_core-2.20.1-cp313-none-win_amd64.whl", hash = "sha256:65db0f2eefcaad1a3950f498aabb4875c8890438bc80b19362cf633b87a8ab20", size = 1898013 }, 269 | { url = "https://files.pythonhosted.org/packages/73/73/0c7265903f66cce39ed7ca939684fba344210cefc91ccc999cfd5b113fd3/pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a45f84b09ac9c3d35dfcf6a27fd0634d30d183205230a0ebe8373a0e8cfa0906", size = 1828190 }, 270 | { url = "https://files.pythonhosted.org/packages/27/55/60b8b0e58b49ee3ed36a18562dd7c6bc06a551c390e387af5872a238f2ec/pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d02a72df14dfdbaf228424573a07af10637bd490f0901cee872c4f434a735b94", size = 1715252 }, 271 | { url = "https://files.pythonhosted.org/packages/28/3d/d66314bad6bb777a36559195a007b31e916bd9e2c198f7bb8f4ccdceb4fa/pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2b27e6af28f07e2f195552b37d7d66b150adbaa39a6d327766ffd695799780f", size = 1782641 }, 272 | { url = "https://files.pythonhosted.org/packages/9e/f5/f178f4354d0d6c1431a8f9ede71f3c4269ac4dc55d314fdb7555814276dc/pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:084659fac3c83fd674596612aeff6041a18402f1e1bc19ca39e417d554468482", size = 1928788 }, 273 | { url = "https://files.pythonhosted.org/packages/9c/51/1f5e27bb194df79e30b593b608c66e881ed481241e2b9ed5bdf86d165480/pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:242b8feb3c493ab78be289c034a1f659e8826e2233786e36f2893a950a719bb6", size = 1886116 }, 274 | { url = "https://files.pythonhosted.org/packages/ac/76/450d9258c58dc7c70b9e3aadf6bebe23ddd99e459c365e2adbde80e238da/pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:38cf1c40a921d05c5edc61a785c0ddb4bed67827069f535d794ce6bcded919fc", size = 1960125 }, 275 | { url = "https://files.pythonhosted.org/packages/dd/9e/0309a7a4bea51771729515e413b3987be0789837de99087f7415e0db1f9b/pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e0bbdd76ce9aa5d4209d65f2b27fc6e5ef1312ae6c5333c26db3f5ade53a1e99", size = 2100407 }, 276 | { url = "https://files.pythonhosted.org/packages/af/93/06d44e08277b3b818b75bd5f25e879d7693e4b7dd3505fde89916fcc9ca2/pydantic_core-2.20.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:254ec27fdb5b1ee60684f91683be95e5133c994cc54e86a0b0963afa25c8f8a6", size = 1914966 }, 277 | ] 278 | 279 | [[package]] 280 | name = "pygments" 281 | version = "2.18.0" 282 | source = { registry = "https://pypi.org/simple" } 283 | sdist = { url = "https://files.pythonhosted.org/packages/8e/62/8336eff65bcbc8e4cb5d05b55faf041285951b6e80f33e2bff2024788f31/pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199", size = 4891905 } 284 | wheels = [ 285 | { url = "https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a", size = 1205513 }, 286 | ] 287 | 288 | [[package]] 289 | name = "pytest" 290 | version = "8.3.2" 291 | source = { registry = "https://pypi.org/simple" } 292 | dependencies = [ 293 | { name = "colorama", marker = "sys_platform == 'win32'" }, 294 | { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, 295 | { name = "iniconfig" }, 296 | { name = "packaging" }, 297 | { name = "pluggy" }, 298 | { name = "tomli", marker = "python_full_version < '3.11'" }, 299 | ] 300 | sdist = { url = "https://files.pythonhosted.org/packages/b4/8c/9862305bdcd6020bc7b45b1b5e7397a6caf1a33d3025b9a003b39075ffb2/pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce", size = 1439314 } 301 | wheels = [ 302 | { url = "https://files.pythonhosted.org/packages/0f/f9/cf155cf32ca7d6fa3601bc4c5dd19086af4b320b706919d48a4c79081cf9/pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5", size = 341802 }, 303 | ] 304 | 305 | [[package]] 306 | name = "pyyaml" 307 | version = "6.0.2" 308 | source = { registry = "https://pypi.org/simple" } 309 | sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 } 310 | wheels = [ 311 | { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199 }, 312 | { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758 }, 313 | { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463 }, 314 | { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280 }, 315 | { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239 }, 316 | { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802 }, 317 | { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527 }, 318 | { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052 }, 319 | { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774 }, 320 | { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612 }, 321 | { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040 }, 322 | { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829 }, 323 | { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167 }, 324 | { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952 }, 325 | { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301 }, 326 | { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638 }, 327 | { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850 }, 328 | { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980 }, 329 | { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873 }, 330 | { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302 }, 331 | { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154 }, 332 | { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223 }, 333 | { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542 }, 334 | { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164 }, 335 | { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611 }, 336 | { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591 }, 337 | { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338 }, 338 | { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 }, 339 | { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 }, 340 | { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 }, 341 | { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 }, 342 | { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 }, 343 | { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 }, 344 | { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 }, 345 | { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 }, 346 | { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, 347 | ] 348 | 349 | [[package]] 350 | name = "rich" 351 | version = "13.7.1" 352 | source = { registry = "https://pypi.org/simple" } 353 | dependencies = [ 354 | { name = "markdown-it-py" }, 355 | { name = "pygments" }, 356 | ] 357 | sdist = { url = "https://files.pythonhosted.org/packages/b3/01/c954e134dc440ab5f96952fe52b4fdc64225530320a910473c1fe270d9aa/rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432", size = 221248 } 358 | wheels = [ 359 | { url = "https://files.pythonhosted.org/packages/87/67/a37f6214d0e9fe57f6ae54b2956d550ca8365857f42a1ce0392bb21d9410/rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222", size = 240681 }, 360 | ] 361 | 362 | [[package]] 363 | name = "ruff" 364 | version = "0.5.0" 365 | source = { registry = "https://pypi.org/simple" } 366 | sdist = { url = "https://files.pythonhosted.org/packages/28/9a/dde343d95ecd0747207e4e8d143c373ef961cbd6b78c61a659f67582dbd2/ruff-0.5.0.tar.gz", hash = "sha256:eb641b5873492cf9bd45bc9c5ae5320648218e04386a5f0c264ad6ccce8226a1", size = 2587996 } 367 | wheels = [ 368 | { url = "https://files.pythonhosted.org/packages/55/5d/0d9510720d61df753df39bf24a96d6c141080c94fe6025568747fbea856a/ruff-0.5.0-py3-none-linux_armv6l.whl", hash = "sha256:ee770ea8ab38918f34e7560a597cc0a8c9a193aaa01bfbd879ef43cb06bd9c4c", size = 9434156 }, 369 | { url = "https://files.pythonhosted.org/packages/be/5a/7f466f5449dce168c2d956ad4a207d62dc7b76836d46f1c04249a4daaf34/ruff-0.5.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:38f3b8327b3cb43474559d435f5fa65dacf723351c159ed0dc567f7ab735d1b6", size = 8536948 }, 370 | { url = "https://files.pythonhosted.org/packages/e2/a2/afc6952d5a0199e7e6c0a2051d6f4780fb70376f5bd07f27838f8bc0cf47/ruff-0.5.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7594f8df5404a5c5c8f64b8311169879f6cf42142da644c7e0ba3c3f14130370", size = 8107163 }, 371 | { url = "https://files.pythonhosted.org/packages/34/54/ea77237405b7573298f5cc00045d1aceab609841d3cc88de3d7c3d2a6163/ruff-0.5.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:adc7012d6ec85032bc4e9065110df205752d64010bed5f958d25dbee9ce35de3", size = 9877009 }, 372 | { url = "https://files.pythonhosted.org/packages/56/db/3f74873bc0ca915f79d26575e549eb5e633022d56315d314e6f9c0fa596a/ruff-0.5.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d505fb93b0fabef974b168d9b27c3960714d2ecda24b6ffa6a87ac432905ea38", size = 9219926 }, 373 | { url = "https://files.pythonhosted.org/packages/57/08/1052c80f3f44321631a8c1337e55883dd7a7b02b4efe5c9282258db42358/ruff-0.5.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9dc5cfd3558f14513ed0d5b70ce531e28ea81a8a3b1b07f0f48421a3d9e7d80a", size = 10031146 }, 374 | { url = "https://files.pythonhosted.org/packages/8f/a2/f7c01c4a02b87998c9e1379ec8d7345d6a45f8b34e326e8700c13da391c3/ruff-0.5.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:db3ca35265de239a1176d56a464b51557fce41095c37d6c406e658cf80bbb362", size = 10770796 }, 375 | { url = "https://files.pythonhosted.org/packages/12/a1/5f45ab0948a202da7fe13c6e0678f907bd88caacc7e4f4909603d3774051/ruff-0.5.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b1a321c4f68809fddd9b282fab6a8d8db796b270fff44722589a8b946925a2a8", size = 10364804 }, 376 | { url = "https://files.pythonhosted.org/packages/7e/40/83f88d5bda41496a90871ec82dd82545def4c4683e1c2f4a42f5a168ae3e/ruff-0.5.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c4dfcd8d34b143916994b3876b63d53f56724c03f8c1a33a253b7b1e6bf2a7d", size = 11241308 }, 377 | { url = "https://files.pythonhosted.org/packages/af/79/8a57016a761d11491b913460a3d1545cdbe96dca6acb1279102814c9147b/ruff-0.5.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81e5facfc9f4a674c6a78c64d38becfbd5e4f739c31fcd9ce44c849f1fad9e4c", size = 10064506 }, 378 | { url = "https://files.pythonhosted.org/packages/67/34/fd7cd8be0d8cd4bcce0dbef807933f6c9685d5dc2549b729da7ee7a7a5cc/ruff-0.5.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e589e27971c2a3efff3fadafb16e5aef7ff93250f0134ec4b52052b673cf988d", size = 9866155 }, 379 | { url = "https://files.pythonhosted.org/packages/7b/54/8a654417265fe91de3ff303274a9d4d64774496eaa2eadd7da8e88a48b82/ruff-0.5.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d2ffbc3715a52b037bcb0f6ff524a9367f642cdc5817944f6af5479bbb2eb50e", size = 9285874 }, 380 | { url = "https://files.pythonhosted.org/packages/86/39/564161e306b12ab40d2b6be0a0bc843c692a8295cc7101fa930db89e1e7e/ruff-0.5.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cd096e23c6a4f9c819525a437fa0a99d1c67a1b6bb30948d46f33afbc53596cf", size = 9645133 }, 381 | { url = "https://files.pythonhosted.org/packages/3b/67/3203d56ee41d3dee8d94c7926b298b13a150f105a55fef38b75ccf5e0901/ruff-0.5.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:46e193b36f2255729ad34a49c9a997d506e58f08555366b2108783b3064a0e1e", size = 10143022 }, 382 | { url = "https://files.pythonhosted.org/packages/71/2e/1bab3c5a3929f348cdc086a3f3013ea0b8823ec3d273f3334ef621f4f83f/ruff-0.5.0-py3-none-win32.whl", hash = "sha256:49141d267100f5ceff541b4e06552e98527870eafa1acc9dec9139c9ec5af64c", size = 7735210 }, 383 | { url = "https://files.pythonhosted.org/packages/48/05/04bf25784ba73abf0e639065fd7a785c005c895c4bf64aa2729d26a1984f/ruff-0.5.0-py3-none-win_amd64.whl", hash = "sha256:e9118f60091047444c1b90952736ee7b1792910cab56e9b9a9ac20af94cd0440", size = 8536440 }, 384 | { url = "https://files.pythonhosted.org/packages/63/ab/a10ab4a751514d4f954079fbd2f645cc0c5982a18f510ab411048a2a5409/ruff-0.5.0-py3-none-win_arm64.whl", hash = "sha256:ed5c4df5c1fb4518abcb57725b576659542bdbe93366f4f329e8f398c4b71178", size = 7949476 }, 385 | ] 386 | 387 | [[package]] 388 | name = "tomli" 389 | version = "2.0.1" 390 | source = { registry = "https://pypi.org/simple" } 391 | sdist = { url = "https://files.pythonhosted.org/packages/c0/3f/d7af728f075fb08564c5949a9c95e44352e23dee646869fa104a3b2060a3/tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f", size = 15164 } 392 | wheels = [ 393 | { url = "https://files.pythonhosted.org/packages/97/75/10a9ebee3fd790d20926a90a2547f0bf78f371b2f13aa822c759680ca7b9/tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", size = 12757 }, 394 | ] 395 | 396 | [[package]] 397 | name = "typing-extensions" 398 | version = "4.12.2" 399 | source = { registry = "https://pypi.org/simple" } 400 | sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } 401 | wheels = [ 402 | { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, 403 | ] 404 | 405 | [[package]] 406 | name = "virtualenv" 407 | version = "20.26.3" 408 | source = { registry = "https://pypi.org/simple" } 409 | dependencies = [ 410 | { name = "distlib" }, 411 | { name = "filelock" }, 412 | { name = "platformdirs" }, 413 | ] 414 | sdist = { url = "https://files.pythonhosted.org/packages/68/60/db9f95e6ad456f1872486769c55628c7901fb4de5a72c2f7bdd912abf0c1/virtualenv-20.26.3.tar.gz", hash = "sha256:4c43a2a236279d9ea36a0d76f98d84bd6ca94ac4e0f4a3b9d46d05e10fea542a", size = 9057588 } 415 | wheels = [ 416 | { url = "https://files.pythonhosted.org/packages/07/4d/410156100224c5e2f0011d435e477b57aed9576fc7fe137abcf14ec16e11/virtualenv-20.26.3-py3-none-any.whl", hash = "sha256:8cc4a31139e796e9a7de2cd5cf2489de1217193116a8fd42328f1bd65f434589", size = 5684792 }, 417 | ] 418 | --------------------------------------------------------------------------------