├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ └── feature_request.yml └── workflows │ └── tests.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── assets └── logo.png ├── examples ├── basic.py ├── defaults.py ├── list_and_tuples.py └── unions_and_optionals.py ├── noxfile.py ├── py.typed ├── pyproject.toml ├── requirements-dev.txt ├── requirements.txt ├── tests └── tests.py └── typed_configparser ├── __init__.py ├── exceptions.py └── parser.py /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Report a bug or unexpected behavior in typed_configparser 3 | labels: [bug, pending] 4 | 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: Thank you for contributing to typed_configparser! 9 | 10 | - type: textarea 11 | id: description 12 | attributes: 13 | label: Description 14 | description: | 15 | Please explain what you're seeing and what you would expect to see. 16 | Please provide as much detail as possible to make understanding and solving your problem as quick as possible. 17 | validations: 18 | required: true 19 | 20 | - type: textarea 21 | id: example 22 | attributes: 23 | label: Example Code 24 | description: > 25 | If applicable, please add a self-contained, 26 | [minimal, reproducible, example](https://stackoverflow.com/help/minimal-reproducible-example) 27 | demonstrating the bug. 28 | 29 | placeholder: | 30 | from typed_configparser import ConfigParser 31 | 32 | ... 33 | render: Python 34 | 35 | - type: textarea 36 | id: version 37 | attributes: 38 | label: Python, typed_configparser & OS Version 39 | description: | 40 | Which version of Python & typed_configparser are you using, and which Operating System? 41 | Please run the following command for the version and copy the output below: 42 | 43 | ```bash 44 | python -c "import typed_configparser; print(typed_configparser.__version__)" 45 | ``` 46 | 47 | render: Text 48 | validations: 49 | required: true 50 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: Ask a Question 4 | url: "https://github.com/ajatkj/typed_configparser/discussions/new?category=q-a" 5 | about: Ask a question about how to use typed_configparser using github discussions 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: "Suggest a new feature for typed_configparser" 3 | labels: [feature request] 4 | 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: Thank you for contributing to typed_configparser! 9 | 10 | - type: checkboxes 11 | id: searched 12 | attributes: 13 | label: Initial Checks 14 | description: | 15 | Just a few checks to make sure you need to create a feature request. 16 | options: 17 | - label: I have searched Google & GitHub for similar requests and couldn't find anything 18 | required: true 19 | 20 | - type: textarea 21 | id: description 22 | attributes: 23 | label: Description 24 | description: | 25 | Please give as much detail as possible about the feature you would like to suggest. 26 | 27 | You might like to add: 28 | * A demo of how code might look when using the feature 29 | * Your use case(s) for the feature 30 | validations: 31 | required: true 32 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 3 | 4 | name: Test 5 | 6 | on: 7 | push: 8 | branches: ["main"] 9 | pull_request: 10 | branches: ["main"] 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | os: [ubuntu, macos, windows] 19 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Set up Python ${{ matrix.python-version }} 24 | uses: actions/setup-python@v5 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | if [ -f requirements-dev.txt ]; then pip install -r requirements-dev.txt; fi 31 | - name: Lint 32 | run: | 33 | poe lint 34 | - run: mkdir coverage 35 | - name: Test 36 | run: | 37 | poe coverage 38 | poe coverage_report 39 | env: 40 | COVERAGE_FILE: coverage/.coverage.${{ runner.os }}-py${{ matrix.python-version }} 41 | CONTEXT: ${{ runner.os }}-py${{ matrix.python-version }} 42 | 43 | - name: store coverage files 44 | uses: actions/upload-artifact@v4 45 | with: 46 | name: coverage-${{ matrix.os }}-${{ matrix.python-version }} 47 | path: coverage 48 | 49 | coverage-combine: 50 | needs: [test] 51 | runs-on: ubuntu-latest 52 | 53 | steps: 54 | - name: Dump GitHub context 55 | env: 56 | GITHUB_CONTEXT: ${{ toJson(github) }} 57 | run: echo "$GITHUB_CONTEXT" 58 | - uses: actions/checkout@v4 59 | - uses: actions/setup-python@v5 60 | with: 61 | python-version: "3.8" 62 | - name: Get coverage files 63 | uses: actions/download-artifact@v4 64 | with: 65 | merge-multiple: true 66 | pattern: coverage-* 67 | path: coverage 68 | - run: pip install coverage[toml] 69 | - run: ls -la coverage 70 | - run: coverage combine coverage 71 | - run: coverage report 72 | - run: coverage html --show-contexts --title "typed-configparser coverage for ${{ github.sha }}" 73 | - name: Store coverage html 74 | uses: actions/upload-artifact@v4 75 | with: 76 | name: coverage-html 77 | path: htmlcov 78 | - name: Upload coverage reports to Codecov 79 | uses: codecov/codecov-action@v4 80 | env: 81 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 82 | with: 83 | verbose: true 84 | 85 | # https://github.com/marketplace/actions/alls-green 86 | check: # This job does nothing and is only used for the branch protection 87 | if: always() 88 | needs: 89 | - coverage-combine 90 | runs-on: ubuntu-latest 91 | steps: 92 | - name: Dump GitHub context 93 | env: 94 | GITHUB_CONTEXT: ${{ toJson(github) }} 95 | run: echo "$GITHUB_CONTEXT" 96 | - name: Decide whether the needed jobs succeeded or failed 97 | uses: re-actors/alls-green@release/v1 98 | id: all-green 99 | with: 100 | jobs: ${{ toJSON(needs) }} 101 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .mypy_cache 2 | .nox 3 | .ruff_cache 4 | .vscode 5 | __pycache__ 6 | dist 7 | site 8 | env 9 | env* 10 | venv 11 | *.egg-info 12 | build 13 | 14 | # vim temporary files 15 | *~ 16 | .*.sw? 17 | .cache 18 | 19 | # macOS 20 | .DS_Store -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | default_language_version: 4 | python: python3.10 5 | repos: 6 | - repo: https://github.com/pre-commit/pre-commit-hooks 7 | rev: v4.5.0 8 | hooks: 9 | - id: check-added-large-files 10 | - id: check-toml 11 | - id: end-of-file-fixer 12 | - id: trailing-whitespace 13 | - repo: https://github.com/charliermarsh/ruff-pre-commit 14 | rev: v0.1.14 15 | hooks: 16 | - id: ruff 17 | args: 18 | - --fix 19 | - id: ruff-format 20 | ci: 21 | autofix_commit_msg: 🎨 [pre-commit.ci] Auto format from pre-commit.com hooks 22 | autoupdate_commit_msg: ⬆ [pre-commit.ci] pre-commit hook autoupdate 23 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | I'd love you to contribute to typed_configparser! 4 | 5 | ## Issues 6 | 7 | Questions, feature requests and bug reports are all welcome as [discussions or issues](https://github.com/ajatkj/typed_configparser/issues/new/choose). 8 | 9 | To make it as simple as possible for us to help you, please include the output of the following call in your issue: 10 | 11 | ```bash 12 | python -c "import typed_configparser; print(typed_configparser.__version__)" 13 | ``` 14 | 15 | ## Pull Requests 16 | 17 | It should be extremely simple to get started and create a Pull Request. 18 | 19 | Unless your change is trivial (typo, etc.), please create an issue to discuss the change before creating a pull request. 20 | 21 | To make contributing as easy and fast as possible, you'll want to run tests and linting locally. 22 | 23 | ## Prerequisites 24 | 25 | You'll need the following prerequisites: 26 | 27 | Any Python version between Python 3.8 and 3.12 28 | 29 | - a virtual environment tool 30 | - git 31 | - pyenv & nox (to optionally test your changes on all versions of python) 32 | 33 | ## Installation and setup 34 | 35 | Fork the repository on GitHub and clone your fork locally. 36 | 37 | ```sh 38 | # Clone your fork and cd into the repo directory 39 | git clone git@github.com:/typed_configparser.git 40 | cd typed_configparser 41 | ``` 42 | 43 | ## Install typed-configparser locally 44 | 45 | ```sh 46 | # Create the virtual environment using your favourite tool 47 | python3 -m venv env 48 | 49 | # Activate the virtual environment 50 | source env/bin/activate 51 | 52 | # Install typed-configparser in your virtual environment 53 | # This will install all development dependencies to help you get start 54 | python3 -m pip install -r requirements.txt 55 | ``` 56 | 57 | ## Check out a new branch and make your changes 58 | 59 | Create a new branch for your changes. 60 | 61 | ```sh 62 | # Checkout a new branch and make your changes 63 | git checkout -b my-new-feature-branch 64 | # Make your changes... 65 | ``` 66 | 67 | ## Run tests and linting 68 | 69 | Run tests and linting locally to make sure everything is working as expected. 70 | 71 | ```sh 72 | # Run automated code formatting and linting 73 | poe lint 74 | # typed_configparser uses ruff for linting and formatting 75 | # https://github.com/astral-sh/ruff 76 | 77 | # Run tests and linting 78 | poe test 79 | # There are few commands set-up using poethepoet task manager. 80 | # You can check list of all commands using `poe` 81 | # https://github.com/nat-n/poethepoet 82 | 83 | # Check test coverage 84 | # Make sure test coverage is 100% else PR will fail in CI pipeline. 85 | poe coverage 86 | poe converage_report 87 | ``` 88 | 89 | ## Run automation test on all python versions 90 | 91 | You can optionally run tests and linting on all python versions from 3.8 to 3.12 to make sure code is compliant across all supported python versions. 92 | If you don't do this, any errors will be caught in the PR check phase. 93 | 94 | ```sh 95 | # Install pyenv using https://github.com/pyenv/pyenv?tab=readme-ov-file#installation 96 | # Install nox using https://nox.thea.codes/en/stable/ 97 | # You can either install it globally or using pipx (do not install it in project virtual environment) 98 | 99 | # Install all supported python versions using pyenv 100 | pyenv install 3.8 && pyenv install 3.9 && pyenv install 3.10 && pyenv install 3.11 && pyenv install 3.12 101 | 102 | # Go to project root and set pyenv global environment (do not activate project virtual environment) 103 | pyenv global 3.8 3.9 3.10 3.11 3.12 104 | 105 | # Run nox 106 | nox 107 | ``` 108 | 109 | ## Commit and push your changes 110 | 111 | Commit your changes, push your branch to GitHub, and create a pull request. 112 | 113 | Please follow the pull request template and fill in as much information as possible. Link to any relevant issues and include a description of your changes. 114 | 115 | When your pull request is ready for review, add a comment with the message "please review" and we'll take a look as soon as we can. 116 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Ankit Jain (ajatkj.dev@gmail.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Description of the image 3 |

4 |

5 | 6 | Test 7 | 8 | 9 | Coverage 10 | 11 | 12 | Package version 13 | 14 | 15 | Supported Python versions 16 | 17 |

18 | 19 | # typed-configparser 20 | 21 | typed-configparser is an extension of the standard configparser module with support for typed configurations using dataclasses. 22 | It leverages Python's type hints and dataclasses to provide a convenient way of parsing and validating configuration files. 23 | 24 | ## Features 25 | 26 | ✓ Fully typed.
27 | ✓ Use dataclasses to parse the configuration file.
28 | ✓ Support for almost all python built-in data types - `int`, `float`, `str`, `list`, `tuple`, `dict` and complex data types using `Union` and `Optional`.
29 | ✓ Supports almost all features of dataclasses including field level init flag, **post_init** method, InitVars and more.
30 | ✓ Built on top of `configparser`, hence retains all functionalities of `configparser`.
31 | ✓ Support for optional values (optional values are automatically set to `None` if not provided).
32 | ✓ Smarter defaults (see below). 33 | 34 | ## Installation 35 | 36 | You can install `typed_configparser` using `pip`: 37 | 38 | ```sh 39 | pip install typed-configparser 40 | ``` 41 | 42 | ## Usage 43 | 44 | `examples/basic.py` 45 | 46 | ```py3 47 | # This is a complete example and should work as is 48 | 49 | from typing import List 50 | from typed_configparser import ConfigParser 51 | from dataclasses import dataclass 52 | 53 | 54 | @dataclass 55 | class BASIC: 56 | option1: int 57 | option2: str 58 | option3: float 59 | option4: List[str] 60 | 61 | 62 | config = """ 63 | [BASIC] 64 | option1 = 10 65 | option2 = value2 66 | option3 = 5.2 67 | option4 = [foo,bar] 68 | """ 69 | 70 | parser = ConfigParser() 71 | parser.read_string(config) 72 | section = parser.parse_section(using_dataclass=BASIC) 73 | 74 | print(section) 75 | ``` 76 | 77 | ```py3 78 | BASIC(option1=10, option2=value2, option3=5.2, option4=['foo', 'bar']) 79 | ``` 80 | 81 | `examples/unions_and_optionals.py` 82 | 83 | ```py3 84 | # This is a complete example and should work as is 85 | 86 | from typing import List, Union, Optional, Dict, Tuple 87 | from typed_configparser import ConfigParser 88 | from dataclasses import dataclass, field 89 | 90 | 91 | @dataclass 92 | class DEFAULT_EXAMPLE: 93 | option1: int 94 | option2: Union[List[Tuple[str, str]], List[int]] 95 | option3: Dict[str, str] = field(default_factory=lambda: {"default_key": "default_value"}) 96 | option4: Optional[float] = None 97 | 98 | 99 | config = """ 100 | [DEFAULT] 101 | option1 = 20 102 | option2 = default_value2 103 | 104 | [MY_SECTION_1] 105 | option2 = [10,20] 106 | option4 = 5.2 107 | 108 | [MY_SECTION_2] 109 | option2 = [(value2a, value2b), (value2c, value2b), (value2c, value2d)] 110 | option3 = {key: value} 111 | option4 = none 112 | """ 113 | 114 | parser = ConfigParser() 115 | parser.read_string(config) 116 | my_section_1 = parser.parse_section(using_dataclass=DEFAULT_EXAMPLE, section_name="MY_SECTION_1") 117 | my_section_2 = parser.parse_section(using_dataclass=DEFAULT_EXAMPLE, section_name="MY_SECTION_2") 118 | 119 | print(my_section_1) 120 | print(my_section_2) 121 | ``` 122 | 123 | ```py3 124 | DEFAULT_EXAMPLE(option1=20, option2=[10, 20], option3={'default_key': 'default_value'}, option4=5.2) 125 | DEFAULT_EXAMPLE(option1=20, option2=[('value2a', 'value2b'), ('value2c', 'value2b'), ('value2c', 'value2d')], option3={'key': 'value'}, option4=None) 126 | ``` 127 | 128 | Check `example` directory for more examples. 129 | 130 | ## Defaults 131 | 132 | - `configparser` includes sensible defaults options which allows you to declare a `[DEFAULT]` section in the config file for fallback values. 133 | - `typed_configparser` goes a step further and allows you to set a final (last) level of defaults at dataclass level. 134 | 135 | # License 136 | 137 | [MIT License](./LICENSE) 138 | 139 | # Contribution 140 | 141 | If you are interested in contributing to typed_configparser, please take a look at the [contributing guidelines](./CONTRIBUTING.md). 142 | -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajatkj/typed_configparser/e69b6f1b69d29e0904e424571dd623276e5f2d61/assets/logo.png -------------------------------------------------------------------------------- /examples/basic.py: -------------------------------------------------------------------------------- 1 | # This is a complete example and should work as is 2 | 3 | from typing import List 4 | from typed_configparser import ConfigParser 5 | from dataclasses import dataclass 6 | 7 | 8 | @dataclass 9 | class BASIC: 10 | option1: int 11 | option2: str 12 | option3: float 13 | option4: List[str] 14 | 15 | 16 | config = """ 17 | [BASIC] 18 | option1 = 10 19 | option2 = value2 20 | option3 = 5.2 21 | option4 = [foo,bar] 22 | """ 23 | 24 | parser = ConfigParser() 25 | parser.read_string(config) 26 | section = parser.parse_section(using_dataclass=BASIC) 27 | 28 | print(section) 29 | 30 | # Output: 31 | # BASIC(option1=10, option2=value2, option3=5.2, option4=['foo', 'bar']) 32 | # 33 | -------------------------------------------------------------------------------- /examples/defaults.py: -------------------------------------------------------------------------------- 1 | # This is a complete example and should work as is 2 | 3 | from typing import List 4 | from typed_configparser import ConfigParser 5 | from dataclasses import dataclass, field 6 | 7 | 8 | @dataclass 9 | class DEFAULT_EXAMPLE: 10 | option1: int 11 | option2: str = "class_default2" 12 | option3: float = 0.0 13 | option4: List[str] = field(default_factory=lambda: ["hello", "world"]) 14 | 15 | 16 | config = """ 17 | [DEFAULT] 18 | option1 = 20 19 | option2 = default_value2 20 | 21 | [MY_SECTION] 22 | option2 = value2 23 | option3 = 5.2 24 | """ 25 | 26 | parser = ConfigParser() 27 | parser.read_string(config) 28 | section = parser.parse_section(using_dataclass=DEFAULT_EXAMPLE, section_name="MY_SECTION") 29 | 30 | print(section) 31 | 32 | # Output: 33 | # DEFAULT_EXAMPLE(option1=20, option2=value2, option3=5.2, option4=['hello', 'world']) 34 | # 35 | -------------------------------------------------------------------------------- /examples/list_and_tuples.py: -------------------------------------------------------------------------------- 1 | # This is a complete example and should work as is 2 | 3 | from typing import List, Union, Tuple 4 | from typed_configparser import ConfigParser 5 | from dataclasses import dataclass 6 | 7 | 8 | @dataclass 9 | class SECTION: 10 | option1: List[int] 11 | option2: List[List[str]] 12 | option3: List[Union[int, str]] 13 | option4: Tuple[int, str] 14 | option5: List[Tuple[str, str]] 15 | 16 | 17 | config = """ 18 | [SECTION] 19 | option1 = [10,20] 20 | option2 = [[foo, bar, baz, hello world]] 21 | option3 = [10, foo, bar] 22 | option4 = (10, foo) 23 | option5 = [(foo, bar), (bar, baz), (baz, qux), (qux, foo)] 24 | """ 25 | 26 | parser = ConfigParser() 27 | parser.read_string(config) 28 | my_section_1 = parser.parse_section(using_dataclass=SECTION) 29 | 30 | print(my_section_1) 31 | 32 | # Output: 33 | # SECTION(option1=[10, 20], option2=[['foo', 'bar', 'baz', 'hello world']], option3=[10, 'foo', 'bar'], option4=(10, 'foo'), option5=[('foo', 'bar'), ('bar', 'baz'), ('baz', 'qux'), ('qux', 'foo')]) 34 | # 35 | -------------------------------------------------------------------------------- /examples/unions_and_optionals.py: -------------------------------------------------------------------------------- 1 | # This is a complete example and should work as is 2 | 3 | from typing import List, Union, Optional, Dict, Tuple 4 | from typed_configparser import ConfigParser 5 | from dataclasses import dataclass, field 6 | 7 | 8 | @dataclass 9 | class DEFAULT_EXAMPLE: 10 | option1: int 11 | option2: Union[List[Tuple[str, str]], List[int]] 12 | option3: Dict[str, str] = field(default_factory=lambda: {"default_key": "default_value"}) 13 | option4: Optional[float] = None 14 | 15 | 16 | config = """ 17 | [DEFAULT] 18 | option1 = 20 19 | option2 = default_value2 20 | 21 | [MY_SECTION_1] 22 | option2 = [10,20] 23 | option4 = 5.2 24 | 25 | [MY_SECTION_2] 26 | option2 = [(value2a, value2b), (value2c, value2b), (value2c, value2d)] 27 | option3 = {key: value} 28 | option4 = none 29 | """ 30 | 31 | parser = ConfigParser() 32 | parser.read_string(config) 33 | my_section_1 = parser.parse_section(using_dataclass=DEFAULT_EXAMPLE, section_name="MY_SECTION_1") 34 | my_section_2 = parser.parse_section(using_dataclass=DEFAULT_EXAMPLE, section_name="MY_SECTION_2") 35 | 36 | print(my_section_1) 37 | print(my_section_2) 38 | 39 | # Output: 40 | # DEFAULT_EXAMPLE(option1=20, option2=10, option3={'default_key': 'default_value'}, option4=5.2) 41 | # DEFAULT_EXAMPLE(option1=20, option2=['value2a', 'value2b', 'value2c'], option3={'key': 'value'}, option4=None) 42 | # 43 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | # type: ignore 2 | import nox 3 | 4 | nox.options.reuse_existing_virtualenvs = True 5 | 6 | PYTHON_VERSIONS = ["3.8", "3.9", "3.10", "3.11", "3.12"] 7 | DEFAULT_PYTHON_VERSION = "3.8" 8 | 9 | 10 | @nox.session(python=PYTHON_VERSIONS) 11 | def tests(session: nox.Session) -> None: 12 | """Run the test script.""" 13 | # session.install("-r", "requirements-dev.txt") # Install dev dependencies 14 | session.run("python", "-m", "unittest", "tests/tests.py") 15 | 16 | 17 | @nox.session(python=DEFAULT_PYTHON_VERSION) 18 | def lint(session: nox.Session) -> None: 19 | """Run the test script.""" 20 | session.install("-r", "requirements-dev.txt") # Install dev dependencies 21 | session.run("ruff", ".") 22 | session.run("ruff", "format", "--check", "-q", ".") 23 | session.run("mypy", "--install-types", "--non-interactive") 24 | -------------------------------------------------------------------------------- /py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ajatkj/typed_configparser/e69b6f1b69d29e0904e424571dd623276e5f2d61/py.typed -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "setuptools_scm"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "typed_configparser" 7 | authors = [{ name = "Ankit Jain", email = "ajatkj.dev@gmail.com" }] 8 | classifiers = [ 9 | "License :: OSI Approved :: MIT License", 10 | "Programming Language :: Python :: 3 :: Only", 11 | "Programming Language :: Python :: 3.8", 12 | "Programming Language :: Python :: 3.9", 13 | "Programming Language :: Python :: 3.10", 14 | "Programming Language :: Python :: 3.11", 15 | "Programming Language :: Python :: 3.12", 16 | "Typing :: Typed", 17 | ] 18 | description = "A typed configparser" 19 | readme = "README.md" 20 | requires-python = ">=3.8" 21 | keywords = ["configparser", "typed"] 22 | license = { text = "MIT" } 23 | dynamic = ["version"] 24 | 25 | [project.urls] 26 | Repository = "https://github.com/ajatkj/typed_configparser" 27 | 28 | [tool.setuptools.packages.find] 29 | exclude = ["typed_configparser.tests*", "typed_configparser.examples*"] 30 | 31 | [tool.setuptools_scm] 32 | 33 | [tool.mypy] 34 | strict = true 35 | 36 | [[tool.mypy.overrides]] 37 | module = "tests.*" 38 | ignore_missing_imports = true 39 | check_untyped_defs = true 40 | 41 | [tool.ruff] 42 | line-length = 122 43 | 44 | [tool.poe.tasks] 45 | _format = "ruff format -q ." 46 | _format_check = "ruff format --check -q ." 47 | _lint = "ruff ." 48 | _mypy = "mypy --install-types --non-interactive ." 49 | 50 | [tool.poe.tasks.lint] 51 | sequence = ["_lint", "_format", "_mypy"] 52 | help = "Lint code and report any issues." 53 | 54 | [tool.poe.tasks.check] 55 | sequence = ["_lint", "_format_check", "_mypy"] 56 | help = "Check linting, formatting and static analysis and report any issues." 57 | 58 | [tool.poe.tasks.clean] 59 | shell = """ 60 | rm -rf `find . -name __pycache__` 61 | """ 62 | help = "Clear all cache." 63 | 64 | [tool.poe.tasks.test] 65 | cmd = "python3 -m unittest -v tests/tests.py" 66 | help = "Run tests" 67 | 68 | [tool.poe.tasks._coverage] 69 | shell = "coverage run -m unittest tests/tests.py" 70 | env.COVERAGE_FILE.default = ".coverage_default/coverage_local" 71 | env.CONTEXT.default = "default_context" 72 | 73 | [tool.poe.tasks._coverage_pre] 74 | shell = "mkdir -p $(dirname $COVERAGE_FILE)" 75 | env.COVERAGE_FILE.default = ".coverage_default/coverage_local" 76 | 77 | [tool.poe.tasks.coverage] 78 | sequence = ["_coverage_pre", "_coverage"] 79 | help = "Run coverage" 80 | 81 | [tool.poe.tasks.coverage_report] 82 | shell = "coverage report && coverage html --show-contexts --title 'typed_configparser coverage'" 83 | help = "" 84 | env.COVERAGE_FILE.default = ".coverage_default/coverage_local" 85 | env.CONTEXT.default = "default_context" 86 | 87 | [tool.coverage.report] 88 | exclude_also = [ 89 | "def _CUSTOM_REPR_METHOD", 90 | "def _CUSTOM_STR_METHOD", 91 | "from _typeshed", 92 | ] 93 | 94 | [tool.coverage.run] 95 | omit = ["tests/*"] 96 | context = '${CONTEXT}' 97 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | mypy 2 | ruff 3 | poethepoet 4 | pre-commit 5 | coverage 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -e . 2 | -r requirements-dev.txt -------------------------------------------------------------------------------- /tests/tests.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | from pathlib import Path, PosixPath 3 | import typing 4 | import unittest 5 | 6 | from typed_configparser.exceptions import ParseError 7 | from typed_configparser.parser import ConfigParser 8 | 9 | _SECTION_ = "test_section" 10 | 11 | 12 | class TestConfigParser(unittest.TestCase): 13 | def setUp(self) -> None: 14 | self.config_parser = ConfigParser() 15 | 16 | def test_parse_section_check_datatypes(self) -> None: 17 | @dataclasses.dataclass 18 | class TestDataclass: 19 | option1: int 20 | option2: str 21 | option3: float 22 | option4: bool 23 | option5: None 24 | 25 | self.config_parser.add_section(_SECTION_) 26 | self.config_parser.set(_SECTION_, "option1", "42") 27 | self.config_parser.set(_SECTION_, "option2", "value") 28 | self.config_parser.set(_SECTION_, "option3", "10.5") 29 | self.config_parser.set(_SECTION_, "option4", "True") 30 | self.config_parser.set(_SECTION_, "option5", "None") 31 | 32 | result = self.config_parser.parse_section(TestDataclass, _SECTION_) 33 | 34 | self.assertIsInstance(result, TestDataclass) 35 | self.assertEqual(result.option1, 42) 36 | self.assertIsInstance(result.option1, int) 37 | self.assertEqual(result.option2, "value") 38 | self.assertIsInstance(result.option2, str) 39 | self.assertEqual(result.option3, 10.5) 40 | self.assertIsInstance(result.option3, float) 41 | self.assertEqual(result.option4, True) 42 | self.assertIsInstance(result.option4, bool) 43 | self.assertEqual(result.option5, None) 44 | self.assertIsInstance(result.option5, type(None)) 45 | 46 | def test_parse_section_invalid_dataclass(self) -> None: 47 | class NotADataclass: 48 | pass 49 | 50 | self.config_parser.add_section(_SECTION_) 51 | 52 | with self.assertRaisesRegex(ParseError, f"ParseError in section '{_SECTION_}'"): 53 | self.config_parser.parse_section(NotADataclass, _SECTION_) # type: ignore 54 | 55 | def test_parse_section_dataclass_init_false(self) -> None: 56 | @dataclasses.dataclass(init=False) 57 | class TestDataclass: 58 | option1: int 59 | 60 | self.config_parser.add_section(_SECTION_) 61 | 62 | with self.assertRaisesRegex(TypeError, "init flag must be True for dataclass 'TestDataclass'"): 63 | self.config_parser.parse_section(TestDataclass, _SECTION_) 64 | 65 | def test_parse_section_extra_fields_allow(self) -> None: 66 | @dataclasses.dataclass 67 | class TestDataclass: 68 | option1: int 69 | option2: str 70 | 71 | self.config_parser.add_section(_SECTION_) 72 | self.config_parser.set(_SECTION_, "option1", "42") 73 | self.config_parser.set(_SECTION_, "option2", "value") 74 | self.config_parser.set(_SECTION_, "extra_option", "extra_value") 75 | 76 | result = self.config_parser.parse_section(TestDataclass, _SECTION_, extra="allow") 77 | 78 | self.assertIsInstance(result, TestDataclass) 79 | self.assertEqual(result.option1, 42) 80 | self.assertEqual(result.option2, "value") 81 | self.assertEqual(getattr(result, "extra_option", None), "extra_value") 82 | 83 | def test_parse_section_extra_fields_error(self) -> None: 84 | @dataclasses.dataclass 85 | class TestDataclass: 86 | option1: int 87 | option2: str 88 | 89 | self.config_parser.add_section(_SECTION_) 90 | self.config_parser.set(_SECTION_, "option1", "42") 91 | self.config_parser.set(_SECTION_, "option2", "value") 92 | self.config_parser.set(_SECTION_, "extra_option", "extra_value") 93 | 94 | with self.assertRaises(ParseError): 95 | self.config_parser.parse_section(TestDataclass, _SECTION_, extra="error") 96 | 97 | def test_parse_section_extra_fields_ignore(self) -> None: 98 | @dataclasses.dataclass 99 | class TestDataclass: 100 | option1: int 101 | option2: str 102 | 103 | self.config_parser.add_section(_SECTION_) 104 | self.config_parser.set(_SECTION_, "option1", "42") 105 | self.config_parser.set(_SECTION_, "option2", "value") 106 | self.config_parser.set(_SECTION_, "extra_option", "extra_value") 107 | 108 | result = self.config_parser.parse_section(TestDataclass, _SECTION_, extra="ignore") 109 | 110 | self.assertIsInstance(result, TestDataclass) 111 | self.assertEqual(result.option1, 42) 112 | self.assertEqual(result.option2, "value") 113 | self.assertFalse(hasattr(result, "extra_option")) 114 | 115 | def test_parse_section_list_fields(self) -> None: 116 | @dataclasses.dataclass 117 | class TestDataclass: 118 | option1: typing.Union[typing.List[int], int] 119 | option2: typing.Union[typing.List[int], int] 120 | 121 | self.config_parser.add_section(_SECTION_) 122 | self.config_parser.set(_SECTION_, "option1", "42") 123 | self.config_parser.set(_SECTION_, "option2", "[42,43]") 124 | 125 | result = self.config_parser.parse_section(TestDataclass, _SECTION_) 126 | 127 | self.assertIsInstance(result, TestDataclass) 128 | self.assertEqual(result.option1, 42) 129 | self.assertIsInstance(result.option1, int) 130 | self.assertEqual(result.option2, [42, 43]) 131 | self.assertIsInstance(result.option2, typing.List) 132 | 133 | def test_parse_section_tuple_fields(self) -> None: 134 | @dataclasses.dataclass 135 | class TestDataclass: 136 | option1: typing.Tuple[int, str] 137 | option2: typing.List[typing.Tuple[str, str]] 138 | 139 | self.config_parser.add_section(_SECTION_) 140 | self.config_parser.set(_SECTION_, "option1", "(10, foo)") 141 | self.config_parser.set(_SECTION_, "option2", "[(foo, bar), (bar, baz), (baz, qux), (qux, foo)]") 142 | result = self.config_parser.parse_section(TestDataclass, _SECTION_) 143 | 144 | self.assertIsInstance(result, TestDataclass) 145 | self.assertEqual(result.option1, (10, "foo")) 146 | self.assertIsInstance(result.option1, tuple) 147 | self.assertIsInstance(result.option1[0], int) 148 | self.assertIsInstance(result.option1[1], str) 149 | self.assertEqual(result.option2, [("foo", "bar"), ("bar", "baz"), ("baz", "qux"), ("qux", "foo")]) 150 | self.assertIsInstance(result.option2, typing.List) 151 | self.assertIsInstance(result.option2[0], tuple) 152 | self.assertIsInstance(result.option2[0][0], str) 153 | self.assertIsInstance(result.option2[0][1], str) 154 | 155 | def test_parse_section_optional_fields(self) -> None: 156 | @dataclasses.dataclass 157 | class TestDataclass: 158 | option1: typing.Optional[typing.List[str]] 159 | 160 | self.config_parser.add_section(_SECTION_) 161 | 162 | result = self.config_parser.parse_section(TestDataclass, _SECTION_) 163 | 164 | self.assertIsInstance(result, TestDataclass) 165 | self.assertEqual(getattr(result, "option1", None), None) 166 | 167 | def test_parse_section_defaults(self) -> None: 168 | @dataclasses.dataclass 169 | class TestDataclass: 170 | option1: typing.Optional[str] 171 | option2: str 172 | option3: typing.Optional[int] = 0 173 | option4: typing.Optional[float] = 10.2 174 | 175 | self.config_parser.set("DEFAULT", "option1", "default_value1") 176 | self.config_parser.set("DEFAULT", "option2", "default_value2") 177 | self.config_parser.add_section(_SECTION_) 178 | self.config_parser.set(_SECTION_, "option1", "test_value1") 179 | self.config_parser.set(_SECTION_, "option4", "11.2") 180 | 181 | result = self.config_parser.parse_section(TestDataclass, _SECTION_) 182 | self.assertIsInstance(result, TestDataclass) 183 | self.assertEqual(result.option1, "test_value1") 184 | self.assertIsInstance(result.option1, str) 185 | self.assertEqual(result.option2, "default_value2") 186 | self.assertIsInstance(result.option2, str) 187 | self.assertEqual(result.option3, 0) 188 | self.assertIsInstance(result.option3, int) 189 | self.assertEqual(result.option4, 11.2) 190 | self.assertIsInstance(result.option4, float) 191 | 192 | def test_parse_section_arbitrary_types(self) -> None: 193 | @dataclasses.dataclass 194 | class TestDataclass: 195 | option1: Path 196 | option2: typing.Dict[str, str] 197 | option3: typing.Tuple[str, typing.Dict[str, str]] 198 | 199 | self.config_parser.add_section(_SECTION_) 200 | self.config_parser.set(_SECTION_, "option1", "./home") 201 | self.config_parser.set(_SECTION_, "option2", "{key1: value1, key2: 10, key3: value3}") 202 | self.config_parser.set(_SECTION_, "option3", "(foo, {key: value})") 203 | 204 | result = self.config_parser.parse_section(TestDataclass, _SECTION_) 205 | 206 | self.assertIsInstance(result, TestDataclass) 207 | self.assertEqual(result.option1, Path("./home")) 208 | self.assertIsInstance(result.option1, PosixPath) 209 | self.assertEqual(result.option2, {"key1": "value1", "key2": "10", "key3": "value3"}) 210 | self.assertIsInstance(result.option2, typing.Dict) 211 | self.assertEqual(result.option3, ("foo", {"key": "value"})) 212 | self.assertIsInstance(result.option3, tuple) 213 | 214 | def test_parse_section_extra_fields_dataclass(self) -> None: 215 | @dataclasses.dataclass 216 | class TestDataclass: 217 | option1: Path 218 | 219 | self.config_parser.add_section(_SECTION_) 220 | 221 | with self.assertRaisesRegex(ParseError, f"ParseError in section '{_SECTION_}' for option 'option1'"): 222 | self.config_parser.parse_section(TestDataclass, _SECTION_) 223 | 224 | def test_parse_section_invalid_boolean(self) -> None: 225 | @dataclasses.dataclass 226 | class TestDataclass: 227 | option1: bool 228 | 229 | self.config_parser.add_section(_SECTION_) 230 | self.config_parser.set(_SECTION_, "option1", "foo") 231 | 232 | with self.assertRaisesRegex( 233 | ParseError, f"ParseError in section '{_SECTION_}' for option 'option1': Cannot cast value 'foo' to 'boolean'" 234 | ): 235 | self.config_parser.parse_section(TestDataclass, _SECTION_) 236 | 237 | def test_parse_section_invalid_int(self) -> None: 238 | @dataclasses.dataclass 239 | class TestDataclass: 240 | option1: int 241 | 242 | self.config_parser.add_section(_SECTION_) 243 | self.config_parser.set(_SECTION_, "option1", "foo") 244 | 245 | with self.assertRaisesRegex( 246 | ParseError, f"ParseError in section '{_SECTION_}' for option 'option1': Cannot cast value 'foo' to 'int'" 247 | ): 248 | self.config_parser.parse_section(TestDataclass, _SECTION_) 249 | 250 | def test_parse_section_invalid_float(self) -> None: 251 | @dataclasses.dataclass 252 | class TestDataclass: 253 | option1: float 254 | 255 | self.config_parser.add_section(_SECTION_) 256 | self.config_parser.set(_SECTION_, "option1", "foo") 257 | 258 | with self.assertRaisesRegex( 259 | ParseError, f"ParseError in section '{_SECTION_}' for option 'option1': Cannot cast value 'foo' to 'float'" 260 | ): 261 | self.config_parser.parse_section(TestDataclass, _SECTION_) 262 | 263 | def test_parse_section_invalid_str(self) -> None: 264 | @dataclasses.dataclass 265 | class TestDataclass: 266 | option1: str 267 | 268 | self.config_parser.add_section(_SECTION_) 269 | self.config_parser.set(_SECTION_, "option1", '["foo", "bar"]') 270 | 271 | with self.assertRaisesRegex( 272 | ParseError, 273 | f"ParseError in section '{_SECTION_}' for option 'option1': Cannot cast value '" 274 | + '\\["foo", "bar"\\]' 275 | + "' to 'str'", 276 | ): 277 | self.config_parser.parse_section(TestDataclass, _SECTION_) 278 | 279 | def test_parse_section_invalid_none(self) -> None: 280 | @dataclasses.dataclass 281 | class TestDataclass: 282 | option1: None 283 | 284 | self.config_parser.add_section(_SECTION_) 285 | self.config_parser.set(_SECTION_, "option1", "foo") 286 | 287 | with self.assertRaisesRegex( 288 | ParseError, f"ParseError in section '{_SECTION_}' for option 'option1': Cannot cast value 'foo' to 'None'" 289 | ): 290 | self.config_parser.parse_section(TestDataclass, _SECTION_) 291 | 292 | def test_parse_section_invalid_union(self) -> None: 293 | @dataclasses.dataclass 294 | class TestDataclass: 295 | option1: typing.Union[int, float] 296 | 297 | self.config_parser.add_section(_SECTION_) 298 | self.config_parser.set(_SECTION_, "option1", "foo") 299 | 300 | with self.assertRaisesRegex( 301 | ParseError, 302 | f"ParseError in section '{_SECTION_}' for option 'option1': Cannot cast value 'foo' to '\(int|float\)' type", 303 | ): 304 | self.config_parser.parse_section(TestDataclass, _SECTION_) 305 | 306 | def test_parse_section_invalid_list(self) -> None: 307 | @dataclasses.dataclass 308 | class TestDataclass: 309 | option1: typing.List[int] 310 | 311 | self.config_parser.add_section(_SECTION_) 312 | self.config_parser.set(_SECTION_, "option1", "12") 313 | 314 | with self.assertRaisesRegex( 315 | ParseError, f"ParseError in section '{_SECTION_}' for option 'option1': Cannot cast value '12' to 'list'" 316 | ): 317 | self.config_parser.parse_section(TestDataclass, _SECTION_) 318 | 319 | def test_parse_section_invalid_tuple(self) -> None: 320 | @dataclasses.dataclass 321 | class TestDataclass: 322 | option1: typing.Tuple[str, int] 323 | 324 | self.config_parser.add_section(_SECTION_) 325 | self.config_parser.set(_SECTION_, "option1", "12") 326 | 327 | with self.assertRaisesRegex( 328 | ParseError, f"ParseError in section '{_SECTION_}' for option 'option1': Cannot cast value '12' to 'tuple'" 329 | ): 330 | self.config_parser.parse_section(TestDataclass, _SECTION_) 331 | 332 | def test_parse_section_invalid_dict(self) -> None: 333 | @dataclasses.dataclass 334 | class TestDataclass: 335 | option1: typing.Dict[str, int] 336 | 337 | self.config_parser.add_section(_SECTION_) 338 | self.config_parser.set(_SECTION_, "option1", "foo") 339 | 340 | with self.assertRaisesRegex( 341 | ParseError, f"ParseError in section '{_SECTION_}' for option 'option1': Cannot cast value 'foo' to 'dict'" 342 | ): 343 | self.config_parser.parse_section(TestDataclass, _SECTION_) 344 | 345 | def test_parse_section_invalid_any(self) -> None: 346 | class CustomType: 347 | def __init__(self) -> None: 348 | pass 349 | 350 | @dataclasses.dataclass 351 | class TestDataclass: 352 | option1: CustomType 353 | 354 | self.config_parser.add_section(_SECTION_) 355 | self.config_parser.set(_SECTION_, "option1", "foo") 356 | 357 | with self.assertRaisesRegex( 358 | ParseError, 359 | f"ParseError in section '{_SECTION_}' for option 'option1': Cannot cast value 'foo' to 'CustomType'", 360 | ): 361 | self.config_parser.parse_section(TestDataclass, _SECTION_) 362 | 363 | def test_parse_section_default_factory(self) -> None: 364 | @dataclasses.dataclass 365 | class TestDataclass: 366 | option1: typing.List[str] = dataclasses.field(default_factory=lambda: ["foo", "bar", "baz"]) 367 | 368 | self.config_parser.add_section(_SECTION_) 369 | result = self.config_parser.parse_section(TestDataclass, _SECTION_) 370 | 371 | self.assertIsInstance(result, TestDataclass) 372 | self.assertEqual(result.option1, ["foo", "bar", "baz"]) 373 | self.assertIsInstance(result.option1, typing.List) 374 | 375 | def test_parse_section_field_level_init_flag(self) -> None: 376 | @dataclasses.dataclass 377 | class TestDataclass: 378 | option1: int 379 | option2: float = dataclasses.field(init=False) 380 | 381 | self.config_parser.add_section(_SECTION_) 382 | self.config_parser.set(_SECTION_, "option1", "10") 383 | result = self.config_parser.parse_section(TestDataclass, _SECTION_) 384 | 385 | self.assertIsInstance(result, TestDataclass) 386 | self.assertEqual(result.option1, 10) 387 | self.assertFalse(hasattr(result, "option2")) 388 | 389 | def test_parse_section_post_init_method(self) -> None: 390 | @dataclasses.dataclass 391 | class TestDataclass: 392 | option1: int 393 | option2: float = dataclasses.field(init=False) 394 | 395 | def __post_init__(self) -> None: 396 | self.option2 = self.option1 + 20.2 397 | 398 | self.config_parser.add_section(_SECTION_) 399 | self.config_parser.set(_SECTION_, "option1", "10") 400 | result = self.config_parser.parse_section(TestDataclass, _SECTION_) 401 | 402 | self.assertIsInstance(result, TestDataclass) 403 | self.assertIsInstance(result.option1, int) 404 | self.assertEqual(result.option1, 10) 405 | self.assertIsInstance(result.option2, float) 406 | self.assertEqual(result.option2, 30.2) 407 | 408 | def test_parse_section_initvars(self) -> None: 409 | @dataclasses.dataclass 410 | class TestDataclass: 411 | option1: int 412 | option2: dataclasses.InitVar[typing.Optional[str]] = None 413 | option3: typing.Optional[str] = None 414 | option4: dataclasses.InitVar[typing.Optional[str]] = None 415 | 416 | def __post_init__(self, option2: typing.Optional[str], option4: typing.Optional[str]) -> None: 417 | self.option3 = option2 418 | 419 | self.config_parser.add_section(_SECTION_) 420 | self.config_parser.set(_SECTION_, "option1", "10") 421 | result = self.config_parser.parse_section(TestDataclass, _SECTION_, init_vars={"option2": "foo"}) 422 | 423 | self.assertIsInstance(result, TestDataclass) 424 | self.assertEqual(result.option1, 10) 425 | self.assertEqual(result.option3, "foo") 426 | 427 | 428 | def start_test() -> None: 429 | unittest.main() 430 | 431 | 432 | if __name__ == "__main__": 433 | start_test() 434 | -------------------------------------------------------------------------------- /typed_configparser/__init__.py: -------------------------------------------------------------------------------- 1 | """Fully typed configparser""" 2 | 3 | from .parser import ConfigParser 4 | 5 | __version__ = "1.1.0" 6 | 7 | __all__ = ["ConfigParser"] 8 | -------------------------------------------------------------------------------- /typed_configparser/exceptions.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | 4 | class ParseError(TypeError): 5 | def __init__(self, message: str, section: str, option: typing.Optional[str] = None) -> None: 6 | super().__init__(message) 7 | self.section = section 8 | self.option = option 9 | 10 | def __str__(self) -> str: 11 | if self.option is not None: 12 | return f"ParseError in section '{self.section}' for option '{self.option}': {self.args[0]}" 13 | else: 14 | return f"ParseError in section '{self.section}': {self.args[0]}" 15 | -------------------------------------------------------------------------------- /typed_configparser/parser.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import dataclasses 3 | import re 4 | import sys 5 | import types 6 | import typing 7 | 8 | import typing_extensions 9 | 10 | from typed_configparser.exceptions import ParseError 11 | 12 | if typing.TYPE_CHECKING: 13 | from _typeshed import DataclassInstance 14 | 15 | T = typing.TypeVar("T", bound="DataclassInstance") 16 | 17 | # This is a hack to make sure get_type_hints work correctly for InitVar 18 | # when __future__ annotations is turned on 19 | dataclasses.InitVar.__call__ = lambda *args: None # type: ignore[method-assign] 20 | 21 | BOOLEAN_STATES = { 22 | "1": True, 23 | "yes": True, 24 | "true": True, 25 | "on": True, 26 | "0": False, 27 | "no": False, 28 | "false": False, 29 | "off": False, 30 | } 31 | 32 | NONE_VALUES = {"none", "null"} 33 | 34 | _ORIGIN_KEY_ = "origin" 35 | _ARGS_KEY_ = "args" 36 | _REGEX_ = r",(?![^\[\(\{]*[\]\)\}])" 37 | 38 | LIST_TYPE = (list, typing.List) 39 | DICT_TYPE = (dict, typing.Dict) 40 | TUPLE_TYPE = (tuple, typing.Tuple) 41 | 42 | if sys.version_info >= (3, 10): # pragma: no cover 43 | UNION_TYPE = (types.UnionType, typing.Union) 44 | NONE_TYPE = (types.NoneType, type(None)) 45 | else: # pragma: no cover 46 | UNION_TYPE = (typing.Union,) 47 | NONE_TYPE = (type(None),) 48 | 49 | 50 | def _CUSTOM_REPR_METHOD(self: T) -> str: 51 | fields_str = ", ".join(f"{field.name}={getattr(self, field.name)}" for field in self.__dataclass_fields__.values()) 52 | return f"{self.__class__.__name__}({fields_str})" 53 | 54 | 55 | def _CUSTOM_STR_METHOD(self: T) -> str: 56 | fields_str = ", ".join(f"{field.name}={getattr(self, field.name)}" for field in self.__dataclass_fields__.values()) 57 | return f"{self.__class__.__name__}({fields_str})" 58 | 59 | 60 | def get_types(typ: typing.Type[typing.Any]) -> typing.Any: 61 | """ 62 | Recursively get the types information for a given type. 63 | 64 | Args: 65 | typ (Type): The type for which to retrieve information. 66 | 67 | Returns: 68 | Any: A dictionary containing information about the type, including its origin and arguments. 69 | 70 | """ 71 | origin = typing_extensions.get_origin(typ) 72 | args = typing_extensions.get_args(typ) 73 | if origin is None: 74 | return typ 75 | result = {_ORIGIN_KEY_: origin, _ARGS_KEY_: []} 76 | for arg in args: 77 | result[_ARGS_KEY_].append(get_types(arg)) 78 | return result 79 | 80 | 81 | def cast_bool(section: str, option: str, value: str) -> bool: 82 | if value.lower() not in BOOLEAN_STATES: 83 | raise ParseError(f"Cannot cast value '{value}' to 'boolean'", section, option=option) 84 | return BOOLEAN_STATES[value.lower()] 85 | 86 | 87 | def cast_none(section: str, option: str, value: str) -> None: 88 | if value and value.lower() in NONE_VALUES: 89 | return None 90 | 91 | raise ParseError(f"Cannot cast value '{value}' to 'None'", section, option=option) 92 | 93 | 94 | def cast_any(section: str, option: str, value: str, target_type: typing.Any) -> typing.Any: 95 | try: 96 | return target_type(value) 97 | except Exception: 98 | raise ParseError(f"Cannot cast value '{value}' to '{target_type.__name__}'", section, option=option) 99 | 100 | 101 | def cast_int(section: str, option: str, value: str) -> typing.Any: 102 | try: 103 | return int(value) 104 | except Exception: 105 | raise ParseError(f"Cannot cast value '{value}' to 'int'", section, option=option) 106 | 107 | 108 | def cast_float(section: str, option: str, value: str) -> typing.Any: 109 | try: 110 | return float(value) 111 | except Exception: 112 | raise ParseError(f"Cannot cast value '{value}' to 'float'", section, option=option) 113 | 114 | 115 | def cast_str(section: str, option: str, value: str) -> typing.Any: 116 | if is_list(value) or is_tuple(value) or is_dict(value): 117 | raise ParseError(f"Cannot cast value '{value}' to 'str'", section, option=option) 118 | 119 | try: 120 | return str(value) 121 | except Exception: # pragma: no cover 122 | raise ParseError(f"Cannot cast value '{value}' to 'str'", section, option=option) 123 | 124 | 125 | def get_name(args: typing.List[type]) -> str: 126 | return "|".join([getattr(arg, "__name__", repr(arg)) for arg in args]) 127 | 128 | 129 | def cast_value_wrapper(section: str, option: str, value: str, target_type: typing.Any) -> typing.Any: 130 | def cast_value(value: str, target_type: typing.Any) -> typing.Any: 131 | """ 132 | Cast a string value to the specified target type. 133 | Types are traversed recursively to match the first matching type. 134 | 135 | Lists are special. For list, the string should be separated by LIST_DELIMITED 136 | which is defaulted to a ",". 137 | 138 | Args: 139 | value (str): The string value to be cast. 140 | target_type (Any): The target type to which the value should be cast. 141 | 142 | Returns: 143 | Any: The casted value of the specified type. 144 | 145 | Raises: 146 | ParseError: If the value cannot be cast to any of the given types. 147 | 148 | """ 149 | if isinstance(target_type, DICT_TYPE): 150 | origin = target_type[_ORIGIN_KEY_] 151 | args = target_type[_ARGS_KEY_] 152 | if origin in UNION_TYPE: 153 | for arg in args: 154 | try: 155 | return cast_value(value, arg) 156 | except Exception: 157 | continue 158 | 159 | raise ParseError( 160 | f"Cannot cast value '{value}' to '({get_name(args)})' type", 161 | section, 162 | option=option, 163 | ) 164 | elif origin in LIST_TYPE: 165 | if is_list(value): 166 | values = re.split(_REGEX_, strip(value, "[", "]")) 167 | return [cast_value(item.strip(), args) for item in values] 168 | else: 169 | raise ParseError(f"Cannot cast value '{value}' to 'list'", section, option=option) 170 | elif origin in TUPLE_TYPE: 171 | if is_tuple(value): 172 | values = re.split(_REGEX_, strip(value, "(", ")")) 173 | return tuple([cast_value(item.strip(), arg) for item, arg in zip(values, args)]) 174 | else: 175 | raise ParseError(f"Cannot cast value '{value}' to 'tuple'", section, option=option) 176 | elif origin in DICT_TYPE: 177 | if is_dict(value): 178 | values = re.split(_REGEX_, strip(value, "{", "}")) 179 | return { 180 | cast_value(k.strip(), args[0]): cast_value(v.strip(), args[1]) 181 | for k, _, v in (val.partition(":") for val in values) 182 | } 183 | else: 184 | raise ParseError(f"Cannot cast value '{value}' to 'dict'", section, option=option) 185 | elif isinstance(target_type, LIST_TYPE): 186 | for arg in target_type: 187 | return cast_value(value, arg) 188 | elif target_type == int: 189 | return cast_int(section, option, value) 190 | elif target_type == float: 191 | return cast_float(section, option, value) 192 | elif target_type == str: 193 | return cast_str(section, option, value) 194 | elif target_type == bool: 195 | return cast_bool(section, option, value) 196 | elif target_type in NONE_TYPE: 197 | return cast_none(section, option, value) 198 | else: 199 | return cast_any(section, option, value, target_type) 200 | 201 | return cast_value(value, target_type) 202 | 203 | 204 | def is_field_optional(typ: typing.Type[T]) -> bool: 205 | """Check whether type contains any None type variable""" 206 | typs = get_types(typ) 207 | if isinstance(typs, DICT_TYPE): 208 | return any(N_ in typs.get(_ARGS_KEY_, ()) for N_ in NONE_TYPE) 209 | else: 210 | return typs in NONE_TYPE 211 | 212 | 213 | def is_field_default(field: typing.Any) -> bool: 214 | assert isinstance(field, dataclasses.Field) 215 | return field.default != dataclasses.MISSING or field.default_factory != dataclasses.MISSING or field.init is False 216 | 217 | 218 | def is_list(value: str) -> bool: 219 | """Check whether string value qualifies as a list""" 220 | if value.startswith("[") and value.endswith("]"): 221 | return True 222 | return False 223 | 224 | 225 | def is_tuple(value: str) -> bool: 226 | """Check whether string value qualifies as a tuple""" 227 | if value.startswith("(") and value.endswith(")"): 228 | return True 229 | return False 230 | 231 | 232 | def is_dict(value: str) -> bool: 233 | if value.startswith("{") and value.endswith("}") and value.find(":") > -1: 234 | return True 235 | return False 236 | 237 | 238 | def strip(value: str, first: str, last: str) -> str: 239 | """Strip single matching first and last character only if both match""" 240 | if value.startswith(first) and value.endswith(last): 241 | return value[1:-1] 242 | return value # pragma: no cover 243 | 244 | 245 | def generate_field(key: str, default: typing.Optional[str] = None) -> typing.Any: 246 | """Get a new empty field with just the name attribute""" 247 | f = dataclasses.field() 248 | f.name = key 249 | if default: 250 | f.default = default 251 | return f 252 | 253 | 254 | def is_dataclass(typ: typing.Type[T]) -> bool: 255 | """This is added for typing""" 256 | return dataclasses.is_dataclass(typ) 257 | 258 | 259 | class ConfigParser(configparser.ConfigParser): 260 | """ 261 | Extended configparser with support for typed configuration using dataclasses. 262 | 263 | Attributes: 264 | __config_class_mapper__ (Dict[str, Any]): A mapping of section names to corresponding 265 | dataclass types. 266 | 267 | Methods: 268 | _get_type(self, section: str, option: str) -> Any: 269 | Get the expected type for a given option in a section. 270 | 271 | _getitem(self, section: str, option: str) -> Any: 272 | Get the value of an option in a section and apply type conversion. 273 | 274 | """ 275 | 276 | __config_class_mapper__: typing.Dict[str, typing.Any] = {} 277 | 278 | def _get_type(self, section: str, option: str) -> typing.Any: 279 | """ 280 | Get the expected type for a given option in a section. 281 | 282 | Args: 283 | section (str): The name of the configuration section. 284 | option (str): The name of the configuration option. 285 | 286 | Returns: 287 | Any: The expected type of the option. 288 | 289 | Raises: 290 | Exception: If the option is not found in the dataclass annotations. 291 | 292 | """ 293 | config_class = self.__config_class_mapper__.get(section) 294 | if config_class: 295 | try: 296 | typ = typing_extensions.get_type_hints(config_class)[option] 297 | return lambda val: cast_value_wrapper(section, option, val, get_types(typ)) 298 | except KeyError: 299 | return str 300 | except Exception: # pragma: no cover 301 | raise 302 | else: # pragma: no cover 303 | raise TypeError("Config class not found") 304 | 305 | def _getitem(self, section: str, option: str) -> typing.Any: 306 | """ 307 | Get the value of an option in a section and apply type conversion. 308 | Uses super class method _get_conv to do the converstion. 309 | 310 | Args: 311 | section (str): The name of the configuration section. 312 | option (str): The name of the configuration option. 313 | 314 | Returns: 315 | Any: The value of the option after type conversion. 316 | 317 | """ 318 | conv = self._get_type(section, option) 319 | value = super()._get_conv(section, option, conv) 320 | return value 321 | 322 | def parse_section( 323 | self, 324 | using_dataclass: typing.Type[T], 325 | section_name: typing.Union[str, None] = None, 326 | extra: typing.Literal["allow", "ignore", "error"] = "allow", 327 | init_vars: typing.Dict[str, typing.Any] = {}, 328 | ) -> T: 329 | """ 330 | Parse a configuration section into a dataclass instance. 331 | 332 | Args: 333 | using_dataclass (Type[T]): The dataclass type to instantiate and populate. 334 | section_name (Union[str, None], optional): The name of the configuration section. 335 | If None, the name is derived from the dataclass name. Defaults to None. 336 | extra (Literal["allow", "ignore", "error"], optional): How to handle extra fields 337 | not present in the dataclass. "allow" allows extra fields, "ignore" ignores them, 338 | and "error" raises an ParseError. Defaults to "allow". 339 | init_vars (Dict[str, Any]): For any InitVars on dataclass, send values here as a dict 340 | which will be send to dataclasses's init method and eventually to post_init method. 341 | 342 | Returns: 343 | T: An instance of the specified dataclass populated with values from the configuration section. 344 | 345 | Raises: 346 | ParseError: If parsing of configuration fails. 347 | 348 | Note: 349 | This method modifies the provided dataclass type by setting its __init__ method to an 350 | empty function (_CUSTOM_INIT_METHOD) to avoid errors during instantiation. 351 | 352 | The function also sets the __repr__ and __str__ methods of the dataclass to custom methods 353 | (_CUSTOM_REPR_METHOD and _CUSTOM_STR_METHOD) for better representation. 354 | 355 | """ 356 | section_name_ = section_name or using_dataclass.__name__ 357 | if not is_dataclass(using_dataclass): 358 | raise ParseError(f"{using_dataclass.__name__} is not a valid dataclass", section_name_) 359 | 360 | if params := getattr(using_dataclass, "__dataclass_params__", None): 361 | if (init := getattr(params, "init", None)) is not None: 362 | if init is False: 363 | raise TypeError(f"init flag must be True for dataclass '{using_dataclass.__name__}'") 364 | 365 | self.__config_class_mapper__[section_name_] = using_dataclass 366 | # This are just "fields" and doesn't contain classvar or initvar fields 367 | dataclass_fields = {item.name: item for item in dataclasses.fields(using_dataclass)} 368 | initvar_fields = { 369 | item.name: item 370 | for item in using_dataclass.__dataclass_fields__.values() 371 | if item._field_type is dataclasses._FIELD_INITVAR # type: ignore [attr-defined] 372 | } 373 | options = [] 374 | # Adding all keys to args initially to maintain the order of position arguments 375 | # to be sent to dataclass init method. It is not required for keyword arguments 376 | args = {k: v for k, v in dataclass_fields.items() if not is_field_default(v)} 377 | kwargs = {} 378 | extra_fields = {} 379 | seen = set() 380 | 381 | # Iterate through config section to update args & kwargs 382 | # for fields present in dataclass. Anything not found in 383 | # dataclass is added to extra_fields 384 | for key, _ in self.items(section_name_): 385 | value = self._getitem(section_name_, key) 386 | options.append(key) 387 | if key in dataclass_fields: 388 | field_info = dataclass_fields[key] 389 | if is_field_default(field_info): 390 | kwargs[key] = value 391 | else: 392 | args[key] = value 393 | seen.add(key) 394 | else: 395 | extra_fields[key] = generate_field(key, default=value) 396 | 397 | # Now iterate through dataclass fields and update default value of 398 | # any "Optional" fields to None. 399 | # Any non-"Optional" fields present in dataclass but not found in 400 | # config options are missing fields and should raise error 401 | missing_fields = [] 402 | for field, field_info in dataclass_fields.items(): 403 | field_type = typing_extensions.get_type_hints(using_dataclass)[field] 404 | if not is_field_default(field_info) and field not in seen: 405 | if is_field_optional(field_type): 406 | args[field] = None # type: ignore 407 | elif field not in options: 408 | missing_fields.append(field) 409 | 410 | # Supply initvars as kwargs to the dataclass call 411 | for field, field_info in initvar_fields.items(): 412 | if field in init_vars: 413 | kwargs[field] = init_vars[field] 414 | else: 415 | kwargs[field] = None 416 | 417 | if len(missing_fields) > 0: 418 | raise ParseError( 419 | "Unable to find value in section, default section or dataclass defaults", 420 | section_name_, 421 | ", ".join(missing_fields), 422 | ) 423 | 424 | if len(extra_fields) > 0 and extra == "error": 425 | raise ParseError("Extra fields are not allowed in configuration.", section_name_) 426 | 427 | section = using_dataclass(*args.values(), **kwargs) 428 | 429 | if extra_fields and extra == "allow": 430 | for k, f in extra_fields.items(): 431 | setattr(section, k, f.default) 432 | setattr(using_dataclass, "__dataclass_extra_fields__", extra_fields) 433 | using_dataclass.__dataclass_fields__.update(extra_fields) 434 | # Since __repr__ and __str__ are created when dataclass is created using @dataclass 435 | # decorator, we need to rewrite our own methods for extra fields 436 | using_dataclass.__repr__ = _CUSTOM_REPR_METHOD # type: ignore[assignment] 437 | using_dataclass.__str__ = _CUSTOM_STR_METHOD # type: ignore[assignment] 438 | return section 439 | --------------------------------------------------------------------------------