├── .github └── workflows │ ├── develop_docs.yml │ ├── python-publish.yml │ ├── release_docs.yml │ └── tests.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs ├── changelog.md ├── configurator.md ├── css │ └── code_select.css ├── exceptions.md ├── flask-custom-example.md ├── flask-simple-example.md ├── index.md ├── installation.md ├── requirements.txt └── usage-examples.md ├── examples ├── custom │ ├── __init__.py │ ├── app.py │ ├── extensions │ │ └── config.py │ └── utils.py └── standard │ ├── __init__.py │ ├── app.py │ └── extensions │ └── config.py ├── mkdocs.yml ├── overrides └── main.html ├── pyproject.toml ├── requirements-dev.txt ├── requirements.txt ├── src └── simple_toml_configurator │ ├── __init__.py │ ├── exceptions.py │ └── toml_configurator.py └── tests └── test_toml_configurator.py /.github/workflows/develop_docs.yml: -------------------------------------------------------------------------------- 1 | name: Build/Publish Develop Docs 2 | on: 3 | push: 4 | branches: 5 | - main 6 | permissions: 7 | contents: write 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | with: 14 | fetch-depth: 0 15 | - uses: actions/setup-python@v4 16 | with: 17 | python-version: 3.10.6 18 | - name: Install Dependencies 19 | run: | 20 | pip install -r docs/requirements.txt 21 | pip install pillow cairosvg mike 22 | - name: Setup Docs Deploy 23 | run: | 24 | git config --global user.name "Docs Deploy" 25 | git config --global user.email "docs.deploy@example.co.uk" 26 | - name: Build Docs Website 27 | run: mike deploy --push develop -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | release: 5 | types: 6 | - released 7 | workflow_dispatch: 8 | 9 | jobs: 10 | deploy: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Set up Python 16 | uses: actions/setup-python@v1 17 | with: 18 | python-version: '3.x' 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | pip install setuptools wheel twine build 23 | - name: Build and publish 24 | env: 25 | TWINE_USERNAME: __token__ 26 | TWINE_PASSWORD: ${{ secrets.PYPI_API_KEY }} 27 | run: | 28 | python -m build 29 | twine upload dist/* -------------------------------------------------------------------------------- /.github/workflows/release_docs.yml: -------------------------------------------------------------------------------- 1 | name: Build/Publish Latest Release Docs 2 | on: 3 | release: 4 | types: [published] 5 | 6 | permissions: 7 | contents: write 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | with: 14 | fetch-depth: 0 15 | - uses: actions/setup-python@v4 16 | with: 17 | python-version: 3.10.6 18 | - name: Install Dependencies 19 | run: | 20 | pip install -r docs/requirements.txt 21 | pip install pillow cairosvg mike 22 | - name: Setup Docs Deploy 23 | run: | 24 | git config --global user.name "Docs Deploy" 25 | git config --global user.email "docs.deploy@example.co.uk" 26 | - name: Build Docs Website 27 | run: mike deploy --push --update-aliases ${{ github.event.release.tag_name }} latest -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 3 | 4 | name: Test Package 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | build: 17 | 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | - name: Set up Python 3.10 23 | uses: actions/setup-python@v3 24 | with: 25 | python-version: "3.10" 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install flake8 pytest 30 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 31 | pip install . 32 | - name: Lint with flake8 33 | run: | 34 | # stop the build if there are Python syntax errors or undefined names 35 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 36 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 37 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 38 | - name: Test with pytest 39 | run: | 40 | pytest 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Testing 2 | testing/ 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | *.py,cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | cover/ 56 | *config/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | local_settings.py 65 | db.sqlite3 66 | db.sqlite3-journal 67 | 68 | # Flask stuff: 69 | instance/ 70 | .webassets-cache 71 | 72 | # Scrapy stuff: 73 | .scrapy 74 | 75 | # Sphinx documentation 76 | docs/_build/ 77 | 78 | # PyBuilder 79 | .pybuilder/ 80 | target/ 81 | 82 | # Jupyter Notebook 83 | .ipynb_checkpoints 84 | 85 | # IPython 86 | profile_default/ 87 | ipython_config.py 88 | 89 | # pyenv 90 | # For a library or package, you might want to ignore these files since the code is 91 | # intended to run in multiple environments; otherwise, check them in: 92 | # .python-version 93 | 94 | # pipenv 95 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 96 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 97 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 98 | # install all needed dependencies. 99 | #Pipfile.lock 100 | 101 | # poetry 102 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 103 | # This is especially recommended for binary packages to ensure reproducibility, and is more 104 | # commonly ignored for libraries. 105 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 106 | #poetry.lock 107 | 108 | # pdm 109 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 110 | #pdm.lock 111 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 112 | # in version control. 113 | # https://pdm.fming.dev/#use-with-ide 114 | .pdm.toml 115 | 116 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 117 | __pypackages__/ 118 | 119 | # Celery stuff 120 | celerybeat-schedule 121 | celerybeat.pid 122 | 123 | # SageMath parsed files 124 | *.sage.py 125 | 126 | # Environments 127 | .env 128 | .venv 129 | env/ 130 | venv*/ 131 | ENV/ 132 | env.bak/ 133 | venv.bak/ 134 | 135 | # Spyder project settings 136 | .spyderproject 137 | .spyproject 138 | 139 | # Rope project settings 140 | .ropeproject 141 | 142 | # mkdocs documentation 143 | /site 144 | 145 | # mypy 146 | .mypy_cache/ 147 | .dmypy.json 148 | dmypy.json 149 | 150 | # Pyre type checker 151 | .pyre/ 152 | 153 | # pytype static type analyzer 154 | .pytype/ 155 | 156 | # Cython debug symbols 157 | cython_debug/ 158 | 159 | # PyCharm 160 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 161 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 162 | # and can be added to the global gitignore or merged into this file. For a more nuclear 163 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 164 | #.idea/ 165 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog], 6 | and this project adheres to [Semantic Versioning]. 7 | 8 | ## [Unreleased] 9 | 10 | - / 11 | 12 | ## [1.3.0] - 2024-07-14 13 | 14 | ### Added 15 | 16 | Added new method `get_envs` that returns all the environment variables set in a dict. 17 | ```python 18 | >>> settings.get_envs() 19 | {'PREFIX_APP_IP': '0.0.0.0'} 20 | ``` 21 | 22 | ### Fixed 23 | 24 | Fixed docstring indentation. 25 | 26 | ## [1.2.2] - 2024-03-10 27 | 28 | ## New Release v.1.2.2 29 | 30 | ### Fixes 31 | 32 | Fixes `TypeError: unsupported operand type(s) for |: 'type' and 'type'.` error when using Python version < 3.10 33 | 34 | ## [1.2.1] - 2024-02-04 35 | 36 | ## New Release: v1.2.1 - Set environment variables from configuration. 37 | 38 | ### What's New 39 | 40 | Environment variable are now automatically set from the configuration. This makes it easier to use the configuration values in your application. 41 | Env variables of all config keys are set as uppercase. e.g. `APP_HOST` and `APP_PORT` or `APP_CONFIG_APP_HOST` and `APP_CONFIG_APP_PORT` if `env_prefix` is set to "app_config". 42 | 43 | It will also try and convert the values to the correct type. e.g. `APP_PORT` will be set as an integer if the env value is `8080` 44 | 45 | Nested values can also be accessed. ex: `TABLE_KEY_LEVEL1_KEY_LEVEL2_KEY`. This works for any level of nesting. 46 | 47 | Any existing env variables that matches will not be overwritten, but instead will overwrite the existing config value. 48 | 49 | ```python 50 | import os 51 | from simple_toml_configurator import Configuration 52 | 53 | os.environ["PROJECT_APP_PORT"] = "1111" 54 | default = {"app": {"host": "localhost", "port": 8080}} 55 | config = Configuration( 56 | config_path="config_folder", 57 | defaults=default, 58 | config_file_name="app_settings", 59 | env_prefix="project") 60 | 61 | print(os.environ["PROJECT_APP_HOST"]) # Output: 'localhost' 62 | print(os.environ["PROJECT_APP_PORT"]) # Output: '1111' 63 | ``` 64 | 65 | ### Fixes 66 | 67 | - Fixed a bug where update_config() would not update the attributes of the Configuration object. 68 | 69 | ## [1.1.0] - 2024-01-28 70 | 71 | ## New Release: v1.1.0 - True Nested Configuration Support with Attribute Access 72 | 73 | This release introduces a significant new feature: Nested Configuration Support with Attribute Access. 74 | 75 | ### What's New 76 | 77 | **Nested Configuration Support with Attribute Access:** In previous versions, accessing and updating nested configuration values required dictionary-style access. With this release, we've made it easier and more intuitive to work with nested configuration values. Now, you can access and update these values using attribute-style access, similar to how you would interact with properties of an object in JavaScript. 78 | 79 | Here's an example: 80 | 81 | ```python 82 | # Access nested configuration values 83 | print(settings.mysql.databases.prod) # Output: 'db1' 84 | settings.mysql.databases.prod = 'new_value' 85 | settings.update() 86 | print(settings.mysql.databases.prod) # Output: 'new_value' 87 | ``` 88 | 89 | ## [1.0.0] - 2023-08-27 90 | 91 | - initial release 92 | 93 | 94 | [keep a changelog]: https://keepachangelog.com/en/1.0.0/ 95 | [semantic versioning]: https://semver.org/spec/v2.0.0.html 96 | 97 | 98 | [unreleased]: https://github.com/gilbn/simple-toml-configurator/compare/1.3.0...HEAD 99 | [1.3.0]: https://github.com/gilbn/simple-toml-configurator/releases/tag/1.3.0 100 | [1.2.2]: https://github.com/gilbn/simple-toml-configurator/releases/tag/1.2.2 101 | [1.2.1]: https://github.com/gilbn/simple-toml-configurator/releases/tag/1.2.1 102 | [1.1.0]: https://github.com/gilbn/simple-toml-configurator/releases/tag/1.1.0 103 | [1.0.0]: https://github.com/gilbn/simple-toml-configurator/releases/tag/1.0.0 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 GilbN 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Simple TOML Configurator 2 | 3 | [![PyPI version](https://badge.fury.io/py/Simple-TOML-Configurator.svg)](https://badge.fury.io/py/Simple-TOML-Configurator) 4 | ![PyPI - License](https://img.shields.io/pypi/l/Simple-TOML-Configurator) 5 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/Simple-TOML-Configurator) 6 | ![PyPI - Wheel](https://img.shields.io/pypi/wheel/Simple-TOML-Configurator) 7 | ![Build](https://github.com/gilbn/Simple-TOML-Configurator/actions/workflows/tests.yml/badge.svg?event=push) 8 | 9 | The **Simple TOML Configurator** is a versatile Python library designed to streamline the handling and organization of configuration settings across various types of applications. This library facilitates the management of configuration values through a user-friendly interface and stores these settings in TOML file format for easy readability and accessibility. 10 | 11 | ## Documentation 12 | 13 | https://gilbn.github.io/Simple-TOML-Configurator/ 14 | 15 | ## Features 16 | 17 | 1. **Effortless Configuration Management:** The heart of the library is the `Configuration` class, which simplifies the management of configuration settings. It provides intuitive methods to load, update, and store configurations, ensuring a smooth experience for developers. 18 | 19 | 2. **Universal Applicability:** The **Simple TOML Configurator** is designed to work seamlessly with any type of Python application, ranging from web frameworks like Flask, Django, and FastAPI to standalone scripts and command-line utilities. 20 | 21 | 3. **TOML File Storage:** Configuration settings are stored in TOML files, a popular human-readable format. This enables developers to review, modify, and track configuration changes easily. 22 | 23 | 4. **Attribute-Based Access:** Accessing configuration values is straightforward, thanks to the attribute-based approach. Settings can be accessed and updated as attributes, making it convenient for both reading and modifying values. 24 | 25 | 5. **Environment Variable Support:** Configuration values are automatically set as environment variables, making it easier to use the configuration values in your application. Environment variable are set as uppercase. e.g. `APP_HOST` and `APP_PORT` or `PROJECT_APP_HOST` and `PROJECT_APP_PORT` if `env_prefix` is set to "project". This also works for nested values. ex: `TABLE_KEY_LEVEL1_KEY_LEVEL2_KEY`. This works for any level of nesting.**Environment variables set before the configuration is loaded will not be overwritten, but instead will overwrite the existing config value.** 26 | 27 | 6. **Default Values:** Developers can define default values for various configuration sections and keys. The library automatically incorporates new values and manages the removal of outdated ones. 28 | 29 | 7. **Customization Capabilities:** The `Configuration` class can be extended and customized to cater to application-specific requirements. Developers can implement custom logic with getters and setters to handle unique settings or scenarios. 30 | 31 | ## Installation 32 | 33 | Install with 34 | ```bash 35 | pip install simple-toml-configurator 36 | ``` 37 | 38 | ## Usage Example 39 | 40 | See [Usage](https://gilbn.github.io/Simple-TOML-Configurator/latest/usage-examples/) for more examples. 41 | 42 | ```python 43 | import os 44 | from simple_toml_configurator import Configuration 45 | 46 | # Define default configuration values 47 | default_config = { 48 | "app": { 49 | "ip": "0.0.0.0", 50 | "host": "", 51 | "port": 5000, 52 | "upload_folder": "uploads", 53 | }, 54 | "mysql": { 55 | "user": "root", 56 | "password": "root", 57 | "databases": { 58 | "prod": "db1", 59 | "dev": "db2", 60 | }, 61 | } 62 | } 63 | 64 | # Set environment variables 65 | os.environ["PROJECT_APP_UPLOAD_FOLDER"] = "overridden_uploads" 66 | 67 | # Initialize the Simple TOML Configurator 68 | settings = Configuration(config_path="config", defaults=default_config, config_file_name="app_config", env_prefix="project") 69 | # Creates an `app_config.toml` file in the `config` folder at the current working directory. 70 | 71 | # Access and update configuration values 72 | print(settings.app.ip) # Output: '0.0.0.0' 73 | settings.app.ip = "1.2.3.4" 74 | settings.update() 75 | print(settings.app_ip) # Output: '1.2.3.4' 76 | 77 | # Access nested configuration values 78 | print(settings.mysql.databases.prod) # Output: 'db1' 79 | settings.mysql.databases.prod = 'new_value' 80 | settings.update() 81 | print(settings.mysql.databases.prod) # Output: 'new_value' 82 | 83 | # Access and update configuration values 84 | print(settings.app_ip) # Output: '1.2.3.4' 85 | settings.update_config({"app_ip": "1.1.1.1"}) 86 | print(settings.app_ip) # Output: '1.1.1.1' 87 | 88 | # Access all settings as a dictionary 89 | all_settings = settings.get_settings() 90 | print(all_settings) 91 | # Output: {'app_ip': '1.1.1.1', 'app_host': '', 'app_port': 5000, 'app_upload_folder': 'overridden_uploads', 'mysql_user': 'root', 'mysql_password': 'root', 'mysql_databases': {'prod': 'new_value', 'dev': 'db2'}} 92 | 93 | # Modify values directly in the config dictionary 94 | settings.config["mysql"]["databases"]["prod"] = "db3" 95 | settings.update() 96 | print(settings.mysql_databases["prod"]) # Output: 'db3' 97 | 98 | # Access environment variables 99 | print(os.environ["PROJECT_MYSQL_DATABASES_PROD"]) # Output: 'db3' 100 | print(os.environ["PROJECT_APP_UPLOAD_FOLDER"]) # Output: 'overridden_uploads' 101 | ``` 102 | 103 | **`app_config.toml` contents** 104 | 105 | ```toml 106 | [app] 107 | ip = "1.1.1.1" 108 | host = "" 109 | port = 5000 110 | upload_folder = "overridden_uploads" 111 | 112 | [mysql] 113 | user = "root" 114 | password = "root" 115 | 116 | [mysql.databases] 117 | prod = "db3" 118 | dev = "db2" 119 | ``` 120 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | --8<-- "CHANGELOG.md" -------------------------------------------------------------------------------- /docs/configurator.md: -------------------------------------------------------------------------------- 1 | # Simple TOML Configurator Docs 2 | ::: src.simple_toml_configurator.toml_configurator -------------------------------------------------------------------------------- /docs/css/code_select.css: -------------------------------------------------------------------------------- 1 | .language-pycon .gp, .language-pycon .go { /* Generic.Prompt, Generic.Output */ 2 | -webkit-user-select: none; 3 | user-select: none; 4 | } -------------------------------------------------------------------------------- /docs/exceptions.md: -------------------------------------------------------------------------------- 1 | # Exceptions 2 | ::: src.simple_toml_configurator.exceptions -------------------------------------------------------------------------------- /docs/flask-custom-example.md: -------------------------------------------------------------------------------- 1 | # Custom Flask Example 2 | 3 | The `Configuration` class can be extended and customized to cater to application-specific requirements. Developers can implement custom logic with getters and setters to handle unique settings or scenarios. 4 | 5 | ## Folder contents 6 | 7 | ``` 8 | ├── __init__.py 9 | ├── app.py 10 | ├── utils.py 11 | └── extensions 12 | └── config.py 13 | ``` 14 | 15 | This example uses a custom Configuration class in the config.py module that inherits from `Configuration`. 16 | 17 | ??? example "CustomConfiguration" 18 | ```python 19 | class CustomConfiguration(Configuration): 20 | def __init__(self): 21 | super().__init__() 22 | 23 | @property 24 | def logging_debug(self): 25 | return getattr(self, "_logging_debug") 26 | 27 | @logging_debug.setter 28 | def logging_debug(self, value: bool): 29 | if not isinstance(value, bool): 30 | raise ValueError(f"value must be of type bool not {type(value)}") 31 | self._logging_debug = value 32 | log_level = "DEBUG" if value else "INFO" 33 | configure_logging(log_level) 34 | ``` 35 | 36 | The custom class uses a property with a setter that executes the `configure_logging` function from `utils.py` whenever the logging_debug attribute is updated. Thus setting the log level to "DEBUG" if the value is True. 37 | 38 | This will change the log level on you Flask app without restarting it. 39 | 40 | ### Code examples 41 | 42 | ??? example "extensions/config.py" 43 | ```python title="config.py" 44 | --8<-- "examples/custom/extensions/config.py" 45 | ``` 46 | 47 | ??? example "utils.py" 48 | ```python title="utils.py" 49 | --8<-- "examples/custom/utils.py" 50 | ``` 51 | 52 | !!! example "app.py" 53 | ```python title="app.py" 54 | --8<-- "examples/custom/app.py" 55 | ``` -------------------------------------------------------------------------------- /docs/flask-simple-example.md: -------------------------------------------------------------------------------- 1 | # Simple Flask Example 2 | 3 | ## Folder contents 4 | 5 | ``` 6 | ├── __init__.py 7 | ├── app.py 8 | └── extensions 9 | └── config.py 10 | ``` 11 | 12 | This is a simple example using flask showing how to update the TOML configuration 13 | 14 | ### Code examples 15 | 16 | ??? example "extensions/config.py" 17 | ```python title="config.py" 18 | --8<-- "examples/standard/extensions/config.py" 19 | ``` 20 | 21 | !!! example "app.py" 22 | ```python title="app.py" 23 | --8<-- "examples/standard/app.py" 24 | ``` -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --8<-- "README.md" -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | ## Installation 2 | 3 | To use the Simple TOML Configurator in your Python projects, you can install it using the `pip` package manager. The package is available on PyPI (Python Package Index). 4 | 5 | ### Prerequisites 6 | 7 | Before installing, make sure you have Python and `pip` installed on your system. You can check if `pip` is installed by running: 8 | 9 | ```bash 10 | pip --version 11 | ``` 12 | 13 | If `pip` is not installed, follow the [official `pip` installation guide](https://pip.pypa.io/en/stable/installing/) to get it set up. 14 | 15 | ### Installation Steps 16 | 17 | To install the Simple TOML Configurator package, open your terminal or command prompt and run the following command: 18 | 19 | ```bash 20 | pip install simple-toml-configurator 21 | ``` 22 | 23 | This command will download and install the package along with its dependencies. After the installation is complete, you can start using the package in your Python projects. 24 | 25 | See [Usage](usage-examples.md) or [Examples](flask-simple-example.md) for more information on how to use the package. -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | mkdocs>=1.4.0 2 | mkdocstrings[python]>=0.19.0 3 | mkdocs-material>=8.5.6 4 | mkdocs-section-index>=0.3.4 5 | mkdocs-redirects>=1.0.1 6 | mkdocs-git-revision-date-localized-plugin 7 | mkdocs-minify-plugin 8 | pyspelling 9 | mkdocs-include-markdown-plugin>=4.0.4 10 | mkdocs-awesome-pages-plugin>=2.4.0 11 | mkdocs-macros-plugin>=0.4.18 12 | pymdown-extensions>=3.3.2 13 | mike -------------------------------------------------------------------------------- /docs/usage-examples.md: -------------------------------------------------------------------------------- 1 | ## Usage Examples 2 | 3 | ### Initializing Configuration 4 | 5 | To get started with the Simple TOML Configurator, you need to initialize the configuration settings using the `init_config` method or init `Configuration` object with the arguments. This sets up the default values and configuration file paths. 6 | 7 | ```python 8 | from simple_toml_configurator import Configuration 9 | 10 | # Define default configuration values 11 | default_config = { 12 | "app": { 13 | "ip": "0.0.0.0", 14 | "host": "", 15 | "port": 5000, 16 | "upload_folder": "uploads" 17 | }, 18 | "mysql": { 19 | "databases": { 20 | "prod": "db1", 21 | "dev": "db2" 22 | } 23 | } 24 | } 25 | 26 | # Initialize the Simple TOML Configurator 27 | settings = Configuration() 28 | settings.init_config("config", default_config, "app_config") 29 | ``` 30 | 31 | #### Load defaults from a TOML file 32 | 33 | ```python 34 | from simple_toml_configurator import Configuration 35 | import tomlkit 36 | import os 37 | from pathlib import Path 38 | 39 | default_file_path = Path(os.path.join(os.getcwd(), "default.toml")) 40 | defaults = tomlkit.loads(file_path.read_text()) 41 | settings = Configuration("config", defaults, "app_config") 42 | ``` 43 | 44 | ### Accessing Configuration Values 45 | 46 | You can access configuration values as attributes of the `Configuration` instance. This attribute-based access makes it straightforward to retrieve settings. 47 | There are two main ways to access configuration values: 48 | 49 | 1. Attribute access: 50 | - This is the default access method. ex: `settings.app.ip` 51 | 52 | 2. Table prefix access: 53 | - Add the table name as a prefix to the key name. ex: `settings.app_ip` instead of `settings.app.ip`. **This only works for the first level of nesting.** 54 | 55 | !!! info Attribute access 56 | If the table contains a nested table, you can access the nested table using the same syntax. ex: `settings.mysql.databases.prod` 57 | 58 | !!! note 59 | This works for any level of nesting. 60 | 61 | ### Environment variable access 62 | 63 | Access the configuration values as environment variables. ex: `APP_IP` instead of `settings.app.ip`. 64 | 65 | Or `APP_CONFIG_APP_IP` if `env_prefix` is set to "app_config". 66 | 67 | You can also access nested values. ex: `MYSQL_DATABASES_PROD`. 68 | 69 | !!! note 70 | This works for any level of nesting. 71 | 72 | ### Updating Configuration Settings 73 | 74 | Use the `update_config` or `update` method to modify values while ensuring consistency across instances. 75 | 76 | #### update() method 77 | 78 | ```python 79 | # Update the ip key in the app table 80 | settings.app.ip = "1.2.3.4" 81 | settings.update() 82 | ``` 83 | 84 | #### update_config() method 85 | 86 | ```python 87 | # Update the ip key in the app table 88 | settings.update_config({"app_ip": "1.2.3.4"}) 89 | ``` 90 | 91 | #### Update the config dictionary directly 92 | 93 | You can directly modify configuration values within the `config` dictionary. After making changes, use the `update` method to write the updated configuration to the file. 94 | 95 | ```python 96 | # Modify values directly and update configuration 97 | settings.config["app"]["ip"] = "0.0.0.0" 98 | settings.update() 99 | ``` 100 | 101 | ### Accessing All Settings 102 | 103 | Retrieve all configuration settings as a dictionary using the `get_settings` method. This provides an overview of all configured values. 104 | 105 | ```python 106 | # Get all configuration settings 107 | all_settings = settings.get_settings() 108 | print(all_settings) # Output: {'app_ip': '1.2.3.4', 'app_host': '', 'app_port': 5000, 'app_upload_folder': 'uploads'} 109 | ``` 110 | 111 | ### Customization with Inheritance 112 | 113 | For advanced use cases, you can create a custom configuration class by inheriting from `Configuration`. This allows you to add custom logic and properties tailored to your application. 114 | 115 | ```python 116 | from simple_toml_configurator import Configuration 117 | 118 | class CustomConfiguration(Configuration): 119 | def __init__(self): 120 | super().__init__() 121 | 122 | # Add custom properties with getters and setters 123 | @property 124 | def custom_setting(self): 125 | return getattr(self, "_custom_setting") 126 | 127 | @custom_setting.setter 128 | def custom_setting(self, value): 129 | # Custom logic for updating custom_setting 130 | self._custom_setting = value 131 | # Additional actions can be performed here 132 | 133 | # Initialize and use the custom configuration 134 | custom_settings = CustomConfiguration() 135 | custom_settings.init_config("config", default_config, "custom_config") 136 | ``` -------------------------------------------------------------------------------- /examples/custom/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GilbN/Simple-TOML-Configurator/ed483e7fafafe09fcf3edc41c8f06cc253182554/examples/custom/__init__.py -------------------------------------------------------------------------------- /examples/custom/app.py: -------------------------------------------------------------------------------- 1 | 2 | from flask import Flask, jsonify, request, url_for,redirect 3 | from extensions.config import settings 4 | import logging 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | def create_app(): 9 | app = Flask(__name__) 10 | app.config["SECRET_KEY"] = settings.config.get("app").get("flask_secret_key") 11 | app.config['APP_PORT'] = settings.config.get("app").get("port") 12 | app.config['APP_IP'] = settings.config.get("app").get("ip") 13 | app.config['APP_HOST'] = settings.config.get("app").get("host") 14 | app.config["DEBUG"] = settings.config.get("app").get("debug") 15 | return app 16 | 17 | app = create_app() 18 | 19 | # simple route that returns the config 20 | @app.route("/") 21 | def app_settings(): 22 | return jsonify( 23 | { 24 | "response": { 25 | "data": { 26 | "configuration": settings.get_settings(), 27 | "toml_config": settings.config, 28 | } 29 | } 30 | }) 31 | 32 | # Update settings route 33 | @app.route("/update", methods=["POST"]) 34 | def update_settings(): 35 | """Update settings route""" 36 | data = request.get_json() # {"logging_debug": True, "app_debug": True} 37 | settings.update_config(data) 38 | return redirect(url_for("app_settings")) 39 | 40 | # Get settings value route 41 | @app.route("/logger", methods=["GET"]) 42 | def get_settings(): 43 | """Sets logging_debug to True or False""" 44 | value = False if settings.logging_debug else True 45 | settings.update_config({"logging_debug": value}) 46 | return jsonify({"debug_logging": settings.logging_debug}) 47 | 48 | if __name__ == "__main__": 49 | app.run(port=app.config.get("APP_PORT"), host=app.config.get("APP_IP"), debug=app.config.get("APP_DEBUG")) 50 | -------------------------------------------------------------------------------- /examples/custom/extensions/config.py: -------------------------------------------------------------------------------- 1 | 2 | import logging 3 | import os 4 | from binascii import hexlify 5 | from simple_toml_configurator import Configuration 6 | from utils import configure_logging 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | default_config = { 11 | "app": { 12 | "ip": "0.0.0.0", 13 | "host": "", 14 | "port": 5000, 15 | "upload_folder": "uploads", 16 | "flask_secret_key": "", 17 | "proxy": "", 18 | "site_url": "http://localhost:5000", 19 | "debug": True, 20 | }, 21 | "mysql": { 22 | "host": "", 23 | "port": "", 24 | "user": "", 25 | "password": "", 26 | "databases": {"prod":"test", "dev":"test2"}, 27 | }, 28 | "scheduler": { 29 | "disabled": True 30 | }, 31 | "logging": { 32 | "debug": True 33 | }, 34 | "queue": { 35 | "disabled": True 36 | }, 37 | } 38 | 39 | config_path = os.environ.get("CONFIG_PATH", os.path.join(os.getcwd(), "config")) 40 | 41 | class CustomConfiguration(Configuration): 42 | def __init__(self): 43 | super().__init__() 44 | 45 | @property 46 | def logging_debug(self): 47 | return getattr(self, "_logging_debug") 48 | 49 | @logging_debug.setter 50 | def logging_debug(self, value: bool): 51 | if not isinstance(value, bool): 52 | raise ValueError(f"value must be of type bool not {type(value)}") 53 | self._logging_debug = value 54 | log_level = "DEBUG" if value else "INFO" 55 | configure_logging(log_level) 56 | 57 | settings = CustomConfiguration() 58 | settings.init_config(config_path, default_config) 59 | 60 | # create random Flask secret_key if there's none in config.toml 61 | if not settings.config.get("app", {}).get("flask_secret_key"): 62 | key = os.environ.get("APP_FLASK_SECRET_KEY", hexlify(os.urandom(16)).decode()) 63 | settings.update_config({"app_flask_secret_key": key}) 64 | 65 | # Set default mysql host 66 | if not settings.config.get("mysql",{}).get("host"): 67 | mysql_host = os.environ.get("MYSQL_HOST", "localhost") 68 | settings.update_config({"mysql_host": mysql_host}) 69 | 70 | # Set default mysql port 71 | if not settings.config.get("mysql",{}).get("port"): 72 | mysql_port = os.environ.get("MYSQL_PORT", "3306") 73 | settings.update_config({"mysql_port": mysql_port}) 74 | 75 | # Set default mysql user 76 | if not settings.config.get("mysql",{}).get("user"): 77 | mysql_user = os.environ.get("MYSQL_USER", "root") 78 | settings.update_config({"mysql_user": mysql_user}) 79 | 80 | # Set default mysql password 81 | if not settings.config.get("mysql",{}).get("password"): 82 | mysql_password = os.environ.get("MYSQL_PASSWORD", "root") 83 | settings.update_config({"mysql_password": mysql_password}) 84 | 85 | if not settings.config.get("mysql",{}).get("database"): 86 | mysql_database = os.environ.get("MYSQL_DATABASE", "some_database") 87 | settings.update_config({"mysql_database": mysql_database}) -------------------------------------------------------------------------------- /examples/custom/utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | # simple logger function that takes log_level as argument and sets the log level 3 | logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") 4 | logger = logging.getLogger() 5 | 6 | def configure_logging(log_level: str) -> None: 7 | """Configure logging for the application 8 | 9 | Args: 10 | log_level (str): Log level to set 11 | """ 12 | if logger.getEffectiveLevel() == logging.getLevelName(log_level): 13 | return 14 | logger.setLevel(log_level) 15 | if log_level=="DEBUG": 16 | logger.debug(f"Debug logging enabled") 17 | -------------------------------------------------------------------------------- /examples/standard/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GilbN/Simple-TOML-Configurator/ed483e7fafafe09fcf3edc41c8f06cc253182554/examples/standard/__init__.py -------------------------------------------------------------------------------- /examples/standard/app.py: -------------------------------------------------------------------------------- 1 | 2 | from flask import Flask, jsonify, request, url_for,redirect 3 | from extenstions.config import settings 4 | 5 | def create_app(): 6 | app = Flask(__name__) 7 | app.config["SECRET_KEY"] = settings.config.get("app").get("flask_secret_key") 8 | app.config['APP_PORT'] = settings.config.get("app").get("port") 9 | app.config['APP_IP'] = settings.config.get("app").get("ip") 10 | app.config['APP_HOST'] = settings.config.get("app").get("host") 11 | app.config["DEBUG"] = settings.config.get("app").get("debug") 12 | return app 13 | 14 | app = create_app() 15 | 16 | # simple route that returns the config 17 | @app.route("/") 18 | def app_settings(): 19 | return jsonify( 20 | { 21 | "response": { 22 | "data": { 23 | "configuration": settings.get_settings(), 24 | } 25 | } 26 | }) 27 | 28 | # Update settings route 29 | @app.route("/update", methods=["POST"]) 30 | def update_settings(): 31 | data = request.get_json() # {"app_proxy": "http://localhost:8080", "app_debug": "True} 32 | settings.update_config(data) 33 | return redirect(url_for("app_settings")) 34 | 35 | # Get settings value route 36 | @app.route("/get", methods=["GET"]) 37 | def get_settings(): 38 | key = request.args.get("key","") 39 | value = request.args.get("value","") 40 | config_attr_value = settings.config.get(key, {}).get(value, "not found") 41 | instance_attr_value = getattr(settings, key, "not found") 42 | return jsonify( 43 | { 44 | "response": { 45 | "data": { 46 | "Configuration.config": { 47 | "key": key, 48 | "value": config_attr_value, 49 | }, 50 | "Configuration": { 51 | "key": key, 52 | "value": instance_attr_value, 53 | }, 54 | } 55 | } 56 | }) 57 | 58 | if __name__ == "__main__": 59 | app.run(port=app.config.get("APP_PORT"), host=app.config.get("APP_IP"), debug=app.config.get("APP_DEBUG")) 60 | -------------------------------------------------------------------------------- /examples/standard/extensions/config.py: -------------------------------------------------------------------------------- 1 | 2 | import os 3 | from binascii import hexlify 4 | from simple_toml_configurator import Configuration 5 | 6 | default_config = { 7 | "app": { 8 | "ip": "0.0.0.0", 9 | "host": "", 10 | "port": 5000, 11 | "upload_folder": "uploads", 12 | "flask_secret_key": "", 13 | "proxy": "", 14 | "site_url": "http://localhost:5000", 15 | "debug": True, 16 | }, 17 | "mysql": { 18 | "host": "", 19 | "port": "", 20 | "user": "", 21 | "password": "", 22 | "databases": {"prod":"test", "dev":"test2"}, 23 | }, 24 | "scheduler": { 25 | "disabled": True 26 | }, 27 | "logging": { 28 | "debug": True 29 | }, 30 | "queue": { 31 | "disabled": True 32 | }, 33 | } 34 | 35 | config_path = os.environ.get("CONFIG_PATH", os.path.join(os.getcwd(), "config")) 36 | settings = Configuration() 37 | settings.init_config(config_path, default_config) 38 | 39 | # create random Flask secret_key if there's none in config.toml 40 | if not settings.config.get("app", {}).get("flask_secret_key"): 41 | key = os.environ.get("APP_FLASK_SECRET_KEY", hexlify(os.urandom(16)).decode()) 42 | settings.update_config({"app_flask_secret_key": key}) 43 | 44 | # Set default mysql host 45 | if not settings.config.get("mysql",{}).get("host"): 46 | mysql_host = os.environ.get("MYSQL_HOST", "localhost") 47 | settings.update_config({"mysql_host": mysql_host}) 48 | 49 | # Set default mysql port 50 | if not settings.config.get("mysql",{}).get("port"): 51 | mysql_port = os.environ.get("MYSQL_PORT", "3306") 52 | settings.update_config({"mysql_port": mysql_port}) 53 | 54 | # Set default mysql user 55 | if not settings.config.get("mysql",{}).get("user"): 56 | mysql_user = os.environ.get("MYSQL_USER", "root") 57 | settings.update_config({"mysql_user": mysql_user}) 58 | 59 | # Set default mysql password 60 | if not settings.config.get("mysql",{}).get("password"): 61 | mysql_password = os.environ.get("MYSQL_PASSWORD", "root") 62 | settings.update_config({"mysql_password": mysql_password}) 63 | 64 | if not settings.config.get("mysql",{}).get("database"): 65 | mysql_database = os.environ.get("MYSQL_DATABASE", "some_database") 66 | settings.update_config({"mysql_database": mysql_database}) -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Simple TOML Configurator Docs 2 | site_description: "" 3 | 4 | repo_url: https://github.com/GilbN/Simple-TOML-Configurator 5 | repo_name: GilbN/Simple-TOML-Configurator 6 | edit_uri: https://github.com/GilbN/Simple-TOML-Configurator/edit/main/docs/ 7 | 8 | theme: 9 | name: material 10 | custom_dir: overrides 11 | features: 12 | - content.code.annotate 13 | - navigation.tabs 14 | - navigation.tabs.sticky 15 | - navigation.instant 16 | - navigation.indexes 17 | - navigation.expand 18 | - navigation.footer 19 | icon: 20 | logo: material/book-open-page-variant 21 | palette: 22 | - media: "(prefers-color-scheme: light)" 23 | scheme: default 24 | primary: teal 25 | accent: teal 26 | toggle: 27 | icon: material/brightness-7 28 | name: Switch to dark mode 29 | - media: "(prefers-color-scheme: dark)" 30 | scheme: slate 31 | primary: indigo 32 | accent: indigo 33 | toggle: 34 | icon: fontawesome/brands/github 35 | name: Switch to light mode 36 | font: 37 | text: Roboto 38 | code: Roboto Mono 39 | 40 | plugins: 41 | - mkdocstrings: 42 | - search 43 | - autorefs 44 | - awesome-pages 45 | - section-index 46 | - macros 47 | - include-markdown 48 | - git-revision-date-localized: 49 | fallback_to_build_date: true 50 | - minify: 51 | minify_html: true 52 | - mike: 53 | 54 | watch: 55 | - src/ 56 | - CHANGELOG.md 57 | - examples/ 58 | 59 | extra_css: 60 | - css/code_select.css 61 | 62 | nav: 63 | - Home: index.md 64 | - Docs: 65 | - Configurator: configurator.md 66 | - Exceptions: exceptions.md 67 | - Examples: 68 | - Flask Simple: flask-simple-example.md 69 | - Flask Custom: flask-custom-example.md 70 | - How-To Guides: 71 | - Installation: installation.md 72 | - Usage: usage-examples.md 73 | - Changelog: changelog.md 74 | 75 | markdown_extensions: 76 | - pymdownx.highlight: 77 | use_pygments: true 78 | pygments_lang_class: true 79 | anchor_linenums: true 80 | - admonition 81 | - pymdownx.superfences: 82 | custom_fences: 83 | - name: mermaid 84 | class: mermaid 85 | format: !!python/name:pymdownx.superfences.fence_code_format 86 | - pymdownx.details 87 | - pymdownx.emoji 88 | - pymdownx.magiclink 89 | - pymdownx.tasklist: 90 | custom_checkbox: true 91 | - pymdownx.snippets 92 | - pymdownx.inlinehilite 93 | - pymdownx.keys 94 | - toc: 95 | permalink: true 96 | 97 | extra: 98 | version: 99 | provider: mike 100 | homepage: https://github.com/GilbN/Simple-TOML-Configurator 101 | social: 102 | - icon: fontawesome/solid/heart 103 | link: "https://github.com/sponsors/GilbN" 104 | name: Donate 105 | - icon: fontawesome/brands/discord 106 | link: "https://docs.theme-park.dev/discord" 107 | name: Discord 108 | - icon: fontawesome/brands/github 109 | link: "https://github.com/gilbn" 110 | name: GitHub 111 | -------------------------------------------------------------------------------- /overrides/main.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block outdated %} 4 | You're not viewing the latest version. 5 | 6 | Click here to go to latest. 7 | 8 | {% endblock %} -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "Simple-TOML-Configurator" 7 | version = "1.3.0" 8 | license = {text = "MIT License"} 9 | authors = [{name = "GilbN", email = "me@gilbn.dev"}] 10 | description = "A simple TOML configurator for Python" 11 | readme = "README.md" 12 | requires-python = ">=3.7.0" 13 | classifiers = [ 14 | "License :: OSI Approved :: MIT License", 15 | "Programming Language :: Python", 16 | "Programming Language :: Python :: 3.7", 17 | "Programming Language :: Python :: 3.8", 18 | "Programming Language :: Python :: 3.9", 19 | "Programming Language :: Python :: 3.10", 20 | "Programming Language :: Python :: 3.11", 21 | ] 22 | keywords = ["configuration", "TOML", "settings", "management", "Python"] 23 | dependencies = [ 24 | "tomlkit>=0.12.1", 25 | "python-dateutil>=2.8.2" 26 | ] 27 | 28 | [project.urls] 29 | Homepage = "https://github.com/GilbN/Simple-TOML-Configurator" 30 | 31 | [project.optional-dependencies] 32 | dev = ["pytest","pytest-cov","pytest-mock"] 33 | 34 | [tool.setuptools.dynamic] 35 | version = {attr = "simple_toml_configurator.__version__"} -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | # install all requirements from requirements.txt 2 | -r requirements.txt 3 | 4 | # ... and install more 5 | pytest 6 | pytest-cov 7 | pytest-mock 8 | wheel -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | tomlkit>=0.12.1 2 | python-dateutil>=2.8.2 -------------------------------------------------------------------------------- /src/simple_toml_configurator/__init__.py: -------------------------------------------------------------------------------- 1 | from .toml_configurator import Configuration, ConfigObject 2 | 3 | __all__ = ["Configuration"] 4 | -------------------------------------------------------------------------------- /src/simple_toml_configurator/exceptions.py: -------------------------------------------------------------------------------- 1 | class TOMLConfiguratorException(Exception): 2 | """Base class for all exceptions raised by the TomlConfigurator class.""" 3 | pass 4 | 5 | class TOMLConfigUpdateError(TOMLConfiguratorException): 6 | """Raised when an error occurs while updating the TOML configuration file.""" 7 | pass 8 | 9 | class TOMLWriteConfigError(TOMLConfiguratorException): 10 | """Raised when an error occurs while writing the TOML configuration file.""" 11 | pass 12 | 13 | class TOMLConfigFileNotFound(TOMLConfiguratorException): 14 | """Raised when the TOML configuration file is not found.""" 15 | pass 16 | 17 | class TOMLCreateConfigError(TOMLConfiguratorException): 18 | """Raised when an error occurs while creating the TOML configuration file.""" 19 | pass 20 | 21 | class TOMLLoadConfigError(TOMLConfiguratorException): 22 | """Raised when an error occurs while loading the TOML configuration file.""" 23 | pass -------------------------------------------------------------------------------- /src/simple_toml_configurator/toml_configurator.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Any 3 | import os 4 | import re 5 | from collections.abc import Mapping 6 | import logging 7 | import ast 8 | from dateutil.parser import parse, ParserError 9 | from pathlib import Path 10 | import tomlkit 11 | from tomlkit import TOMLDocument 12 | from .exceptions import TOMLConfigUpdateError, TOMLWriteConfigError, TOMLCreateConfigError, TOMLLoadConfigError 13 | 14 | __version__ = "1.3.0" 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | class Configuration: 19 | """Class to set our configuration values we can use around in our app. 20 | The configuration is stored in a toml file as well as on the instance. 21 | 22 | Examples: 23 | ```pycon 24 | >>> default_config = { 25 | "app": { 26 | "ip": "0.0.0.0", 27 | "host": "", 28 | "port": 5000, 29 | "upload_folder": "uploads" 30 | } 31 | } 32 | >>> import os 33 | >>> from simple_toml_configurator import Configuration 34 | >>> settings = Configuration("config", default_config, "app_config") 35 | {'app': {'ip': '0.0.0.0', 'host': '', 'port': 5000, 'upload_folder': 'uploads'}} 36 | # Update the config dict directly 37 | >>> settings.app.ip = "1.1.1.1" 38 | >>> settings.update() 39 | >>> settings.app.ip 40 | '1.1.1.1' 41 | >>> settings.get_settings() 42 | {'app_ip': '1.1.1.1', 'app_host': '', 'app_port': 5000, 'app_upload_folder': 'uploads'} 43 | >>> settings.update_config({"app_ip":"1.2.3.4"}) 44 | >>> settings.app_ip 45 | '1.2.3.4' 46 | >>> settings.config.get("app").get("ip") 47 | '1.2.3.4' 48 | >>> settings.config["app"]["ip"] = "0.0.0.0" 49 | >>> settings.update() 50 | >>> settings.app_ip 51 | '0.0.0.0' 52 | >>> print(os.environ.get("APP_UPLOAD_FOLDER")) 53 | 'uploads' 54 | ``` 55 | 56 | Attributes: 57 | config (TOMLDocument): TOMLDocument with all config values 58 | config_path (str|Path): Path to config folder 59 | config_file_name (str): Name of the config file 60 | defaults (dict[str,dict]): Dictionary with all default values for your application 61 | 62 | !!! Info 63 | Changing a table name in your `defaults` will remove the old table in config including all keys and values. 64 | 65 | !!! Note 66 | If updating an attribute needs extra logic, make a custom class that inherits from this class and add property attributes with a getter and setter. 67 | 68 | Example: 69 | 70 | ```python 71 | from simple_toml_configurator import Configuration 72 | from utils import configure_logging 73 | 74 | class CustomConfiguration(Configuration): 75 | def __init__(self): 76 | super().__init__() 77 | 78 | @property 79 | def logging_debug(self): 80 | return getattr(self, "_logging_debug") 81 | 82 | @logging_debug.setter 83 | def logging_debug(self, value: bool): 84 | if not isinstance(value, bool): 85 | raise ValueError(f"value must be of type bool not {type(value)}") 86 | self._logging_debug = value 87 | log_level = "DEBUG" if value else "INFO" 88 | configure_logging(log_level) 89 | logger.debug(f"Debug logging set to {value}") 90 | 91 | defaults = { 92 | "logging": { 93 | "debug": False 94 | ... 95 | } 96 | 97 | config_path = os.environ.get("CONFIG_PATH", "config") 98 | settings = CustomConfiguration() 99 | settings.init_config(config_path, defaults, "app_config")) 100 | ``` 101 | """ 102 | 103 | def __init__(self, *args, **kwargs) -> None: 104 | self.logger = logging.getLogger("Configuration") 105 | if args or kwargs: 106 | self.init_config(*args, **kwargs) 107 | 108 | def __repr__(self) -> str: 109 | return f"Configuration(config_path={getattr(self,'config_path', None)}, defaults={getattr(self, 'defaults',None)}, config_file_name={getattr(self, 'config_file_name', None)})" # pragma: no cover 110 | 111 | def __str__(self) -> str: 112 | return f" File:'{getattr(self, 'config_file_name', None)}' Path:'{getattr(self, '_full_config_path', None)}'" # pragma: no cover 113 | 114 | def init_config(self, config_path:str|Path, defaults:dict[str,dict], config_file_name:str="config",env_prefix:str="") -> TOMLDocument: 115 | """ 116 | Creates the config folder and toml file if needed. 117 | 118 | Upon init it will add any new/missing values/tables from `defaults` into the existing TOML config. 119 | Removes any old values/tables from `self.config` that are not in `self.defaults`. 120 | 121 | Sets all config keys as attributes on the class. e.g. `self.table.key`, `self.table_key` and `self._table_key` 122 | 123 | Env variables of all config keys are set as uppercase. e.g. `APP_HOST` and `APP_PORT` or `APP_CONFIG_APP_HOST` and `APP_CONFIG_APP_PORT` if `env_prefix` is set to "app_config". 124 | 125 | Nested tables are set as nested environment variables. e.g. `APP_CONFIG_APP_DATABASES_PROD` and `APP_CONFIG_APP_DATABASES_DEV`. 126 | 127 | Examples: 128 | ```pycon 129 | >>> settings = Configuration() 130 | >>> settings.init_config("config", defaults, "app_config")) 131 | ``` 132 | 133 | Args: 134 | config_path (str|Path): Path to config folder 135 | defaults (dict[str,dict]): Dictionary with all default values for your application 136 | config_file_name (str, optional): Name of the config file. Defaults to "config". 137 | env_prefix (str, optional): Prefix for environment variables. Defaults to "". 138 | 139 | Returns: 140 | dict[str,Any]: Returns a TOMLDocument. 141 | """ 142 | 143 | if not isinstance(config_path, (str, Path)): 144 | raise TypeError(f"argument config_path must be of type {type(str)} or {type(Path)}, not: {type(config_path)}") # pragma: no cover 145 | if not isinstance(defaults, dict): 146 | raise TypeError(f"argument defaults must be of type {type(dict)}, not: {type(defaults)}") # pragma: no cover 147 | if not isinstance(env_prefix, str): 148 | raise TypeError(f"argument env_prefix must be of type {type(str)}, not: {type(env_prefix)}") # pragma: no cover 149 | if not isinstance(config_file_name, str): 150 | raise TypeError(f"argument config_file_name must be of type {type(str)}, not: {type(config_file_name)}") # pragma: no cover 151 | 152 | self.defaults:dict[str,dict] = defaults 153 | self.config_path:str|Path = config_path 154 | self.config_file_name:str = f"{config_file_name}.toml" 155 | self.env_prefix:str = env_prefix 156 | self._envs: dict[str,Any] = {} 157 | self._full_config_path:str = os.path.join(self.config_path, self.config_file_name) 158 | self.config:TOMLDocument = self._load_config() 159 | self._sync_config_values() 160 | self._set_os_env() 161 | self._set_attributes() 162 | return self.config 163 | 164 | def _sync_config_values(self) -> None: 165 | """Add any new/missing values/tables from self.defaults into the existing TOML config 166 | - If a table is missing from the config, it will be added with the default table. 167 | - If a table is missing from the defaults, it will be removed from the config. 168 | If there is a mismatch in types between the default value and the config value, the config value will be replaced with the default value. 169 | 170 | For example if the default value is a string and the existing config value is a dictionary, the config value will be replaced with the default value. 171 | """ 172 | def update_dict(current_dict:dict, default_dict:dict) -> dict: 173 | """Recursively update a dictionary with another dictionary. 174 | 175 | Args: 176 | current_dict (dict): The dictionary to update. Loaded from the config file. 177 | default_dict (dict): The dictionary to update with. Loaded from the defaults. 178 | 179 | Returns: 180 | dict: The updated dictionary 181 | """ 182 | for key, value in default_dict.items(): 183 | if isinstance(value, Mapping): 184 | if not isinstance(current_dict.get(key, {}), Mapping): 185 | logger.warning("Mismatched types for key '%s': expected dictionary, got %s. Replacing with new dictionary.", key, type(current_dict.get(key))) # pragma: no cover 186 | current_dict[key] = {} 187 | if key not in current_dict: 188 | logger.info("Adding new Table: ('%s')", key) # pragma: no cover 189 | current_dict[key] = update_dict(current_dict.get(key, {}), value) 190 | else: 191 | if key not in current_dict: 192 | logger.info("Adding new Key: ('%s':'***') in table: %s", key, current_dict) # pragma: no cover 193 | current_dict[key] = value 194 | return current_dict 195 | 196 | self.config = update_dict(self.config, self.defaults) 197 | self._write_config_to_file() 198 | self._clear_old_config_values() 199 | 200 | def _clear_old_config_values(self) -> None: 201 | """Remove any old values/tables from self.config that are not in self.defaults 202 | """ 203 | def remove_keys(config:dict, defaults:dict) -> None: 204 | # Create a copy of config to iterate over 205 | config_copy = config.copy() 206 | 207 | # Remove keys that are in config but not in defaults 208 | for key in config_copy: 209 | if key not in defaults: 210 | del config[key] 211 | logger.info("Removing Key: ('%s') in Table: ( '%s' )", key, config_copy) 212 | elif isinstance(config[key], Mapping): 213 | remove_keys(config[key], defaults[key]) 214 | 215 | remove_keys(self.config, self.defaults) 216 | self._write_config_to_file() 217 | 218 | def get_settings(self) -> dict[str, Any]: 219 | """Get all config key values as a dictionary. 220 | 221 | Dict keys are formatted as: `table_key`: 222 | 223 | Examples: 224 | ```pycon 225 | >>> defaults = {...} 226 | >>> settings = Configuration() 227 | >>> settings.init_config("config", defaults, "app_config")) 228 | >>> settings.get_settings() 229 | {'app_ip': '0.0.0.0', 'app_host': '', 'app_port': 5000, 'app_upload_folder': 'uploads'} 230 | ``` 231 | 232 | Returns: 233 | dict[str, Any]: Dictionary with config key values. 234 | """ 235 | settings: dict[str, Any] = {} 236 | for table in self.config: 237 | for key, value in self.config[table].items(): 238 | settings[f"{table}_{key}"] = value 239 | return settings 240 | 241 | def get_envs(self) -> dict[str, Any]: 242 | """Get all the environment variables we set as a dictionary. 243 | 244 | Examples: 245 | ```pycon 246 | >>> defaults = {...} 247 | >>> settings = Configuration() 248 | >>> settings.init_config("config", defaults, "app_config")) 249 | >>> settings.get_envs() 250 | {'PREFIX_APP_IP': '0.0.0.0'} 251 | 252 | Returns: 253 | dict[str, Any]: Dictionary with all environment variables name as keys and their value. 254 | """ 255 | return self._envs 256 | 257 | def _set_attributes(self) -> dict[str, Any]: 258 | """Set all config keys as attributes on the class. 259 | 260 | Two different attributes are set for each key. 261 | _TOMLtable_key: e.g. `_app_host` 262 | TOMLtable_key: e.g. `app_host` 263 | 264 | This makes it so that the instance attributes are updated when the a value in self.config is updated as they reference the same object. 265 | 266 | Returns: 267 | dict[str, Any]: Returns all attributes in a dictionary 268 | """ 269 | for table in self.config: 270 | setattr(self,table,ConfigObject(self.config[table])) 271 | for key, value in self.config[table].items(): 272 | setattr(self, f"_{table}_{key}", value) 273 | setattr(self, f"{table}_{key}", value) 274 | self._update_os_env(table, key, value) 275 | 276 | def _parse_env_value(self, value:str) -> Any: 277 | """Parse the environment variable value to the correct type. 278 | 279 | Args: 280 | value (str): The value to parse 281 | 282 | Returns: 283 | Any: The parsed value as the correct type or the original value if it could not be parsed. 284 | """ 285 | if str(value).lower() == "true": 286 | return True 287 | if str(value).lower() == "false": 288 | return False 289 | try: 290 | parsed_value = ast.literal_eval(value) 291 | if isinstance(parsed_value, (int, float, bool, str, list, dict)): 292 | return parsed_value 293 | except (ValueError, SyntaxError): 294 | pass 295 | try: 296 | return parse(value) 297 | except ParserError: 298 | pass 299 | return str(value) 300 | 301 | def _make_env_name(self, table:str, key:str) -> str: 302 | """Create an environment variable name from the table and key. 303 | 304 | Args: 305 | table (str): The table name 306 | key (str): The key name 307 | 308 | Returns: 309 | str: The environment variable name 310 | """ 311 | if self.env_prefix: 312 | return f"{self.env_prefix.upper()}_{table.upper()}_{str(key).upper()}" 313 | return f"{table.upper()}_{str(key).upper()}" 314 | 315 | def _update_os_env(self, table:str, key:str, value:Any) -> None: 316 | """Update the os environment variable with the same name as the config table and key. 317 | 318 | If the value is a dictionary, creates an environment variable for each item in the dictionary, 319 | handling nested dictionaries recursively. 320 | 321 | Args: 322 | table (str): The table name 323 | key (str): The key name 324 | value (Any): The value 325 | """ 326 | if isinstance(value, dict): 327 | for subkey, subvalue in value.items(): 328 | self._update_os_env(table, f"{key}_{subkey}", subvalue) 329 | else: 330 | env_var = self._make_env_name(table, key) 331 | os.environ[env_var] = str(value) 332 | self._envs[env_var] = value 333 | 334 | def _set_os_env(self) -> None: 335 | """Set all config keys as environment variables. 336 | 337 | The environment variable is set as uppercase. e.g. `APP_HOST` and `APP_PORT` or `APP_CONFIG_APP_HOST` and `APP_CONFIG_APP_PORT` if `env_prefix` is set to "app_config". 338 | 339 | If the environment variable already exists, the config value is updated to the env value and will overwrite any config value. 340 | """ 341 | for table in self.config.copy(): 342 | for key, value in self.config[table].items(): 343 | existing_env = os.environ.get(self._make_env_name(table, key)) 344 | if existing_env: 345 | logger.info("Setting Table: ('%s') Key: ('%s') to value from existing environment variable: ('%s')",table, key, existing_env) 346 | self.config[table][key] = self._parse_env_value(existing_env) 347 | continue 348 | self._update_os_env(table, key, value) 349 | self._write_config_to_file() 350 | 351 | def update_config(self, settings: dict[str,Any]) -> None: 352 | """Update all config values from a dictionary, set new attribute values and write the config to file. 353 | Use the same format as `self.get_settings()` returns to update the config. 354 | 355 | Examples: 356 | ```pycon 357 | >>> defaults = {"mysql": {"databases": {"prod":"prod_db1", "dev":"dev_db1"}}} 358 | >>> settings = Configuration() 359 | >>> settings.init_config("config", defaults, "app_config") 360 | >>> settings.update_config({"mysql_databases": {"prod":"prod_db1", "dev":"dev_db2"}}) 361 | print(settings.mysql_databases["dev"]) 362 | 'dev_db2' 363 | ``` 364 | 365 | Args: 366 | settings (dict): Dict with key values 367 | """ 368 | if not isinstance(settings, dict): 369 | raise TypeError(f"Argument settings must be of type {type(dict)}, not: {type(settings)}") # pragma: no cover 370 | try: 371 | for table in self.config: 372 | for key, value in settings.items(): 373 | table_key = key.split(f"{table}_")[-1] 374 | if self.config.get(table) and table_key in self.config[table].keys(): 375 | if self.config[table][table_key] != value: 376 | self.logger.info("Updating TOML Document -> Table: ('%s') Key: ('%s')",table, table_key) 377 | self.config[table][table_key] = value 378 | except Exception as exc: # pragma: no cover 379 | self.logger.exception("Could not update config!") 380 | raise TOMLConfigUpdateError("unable to update config!") from exc 381 | self._write_config_to_file() 382 | self._set_attributes() 383 | 384 | def update(self): 385 | """Write the current config to file. 386 | 387 | Examples: 388 | ```pycon 389 | >>> from simple_toml_configurator import Configuration 390 | >>> settings = Configuration() 391 | >>> defaults = {"mysql": {"databases": {"prod":"prod_db1", "dev":"dev_db1"}}} 392 | >>> settings.init_config("config", defaults, "app_config") 393 | >>> settings.mysql.databases.prod = "prod_db2" 394 | >>> settings.update() 395 | >>> settings.config["mysql"]["databases"]["prod"] 396 | 'prod_db2' 397 | >>> settings.config["mysql"]["databases"]["prod"] = "prod_db3" 398 | >>> settings.update() 399 | >>> settings.mysql_databases["prod"] 400 | 'prod_db3' 401 | ``` 402 | """ 403 | self._write_config_to_file() 404 | self._set_attributes() 405 | 406 | def _write_config_to_file(self) -> None: 407 | """Update and write the config to file""" 408 | self.logger.debug("Writing config to file") 409 | try: 410 | with Path(self._full_config_path).open("w", encoding="utf-8") as conf: 411 | toml_document = tomlkit.dumps(self.config) 412 | # Use regular expression to replace consecutive empty lines with a single newline 413 | cleaned_toml = re.sub(r'\n{3,}', '\n\n', toml_document) 414 | conf.write(cleaned_toml) 415 | except (OSError,FileNotFoundError,TypeError) as exc: # pragma: no cover 416 | self.logger.exception("Could not write config file!") 417 | raise TOMLWriteConfigError("unable to write config file!") from exc # pragma: no cover 418 | self.config = self._load_config() 419 | 420 | def _load_config(self) -> TOMLDocument: 421 | """Load the config from file and return it as a TOMLDocument""" 422 | try: 423 | return tomlkit.loads(Path(self._full_config_path).read_text()) 424 | except FileNotFoundError: # pragma: no cover 425 | self._create_config(self._full_config_path) # Create the config folder and toml file if needed. 426 | try: 427 | return tomlkit.loads(Path(self._full_config_path).read_text()) 428 | except Exception as exc: 429 | self.logger.exception("Could not load config file!") 430 | raise TOMLLoadConfigError("unable to load config file!") from exc 431 | 432 | def _create_config(self, config_file_path:str) -> None: 433 | """Create the config folder and toml file. 434 | 435 | Args: 436 | config_file_path (str): Path to the config file 437 | """ 438 | 439 | # Check if config path exists 440 | try: 441 | if not os.path.isdir(os.path.dirname(config_file_path)): 442 | os.makedirs(os.path.dirname(config_file_path), exist_ok=True) # pragma: no cover 443 | except OSError as exc: # pragma: no cover 444 | self.logger.exception("Could not create config folder!") 445 | raise TOMLCreateConfigError(f"unable to create config folder: ({os.path.dirname(config_file_path)})") from exc # pragma: no cover 446 | try: 447 | self.logger.debug("Creating config") 448 | with Path(config_file_path).open("w") as conf: 449 | conf.write(tomlkit.dumps(self.defaults)) 450 | except OSError as exc: # pragma: no cover 451 | self.logger.exception("Could not create config file!") 452 | raise TOMLCreateConfigError(f"unable to create config file: ({config_file_path})") from exc 453 | 454 | class ConfigObject: 455 | """ 456 | Represents a configuration object that wraps a dictionary and provides attribute access. 457 | 458 | Any key in the dictionary can be accessed as an attribute. 459 | 460 | Args: 461 | table (dict): The dictionary representing the configuration. 462 | 463 | Attributes: 464 | _table (dict): The internal dictionary representing the configuration. 465 | 466 | """ 467 | 468 | def __init__(self, table: dict): 469 | self._table = table 470 | for key, value in table.items(): 471 | if isinstance(value, dict): 472 | self.__dict__[key] = ConfigObject(value) 473 | else: 474 | self.__dict__[key] = value 475 | 476 | def __setattr__(self, __name: str, __value: Any) -> None: 477 | """Update the table value when an attribute is set""" 478 | super().__setattr__(__name, __value) 479 | if __name == "_table": 480 | return 481 | if hasattr(self, "_table") and __name in self._table: 482 | self._table[__name] = __value 483 | 484 | def __repr__(self) -> str: 485 | return f"ConfigObject({self._table})" 486 | 487 | def __str__(self) -> str: 488 | return f" {self._table}" -------------------------------------------------------------------------------- /tests/test_toml_configurator.py: -------------------------------------------------------------------------------- 1 | import os 2 | from datetime import datetime 3 | import pytest 4 | from pathlib import Path 5 | from tomlkit import TOMLDocument 6 | from simple_toml_configurator import Configuration 7 | from simple_toml_configurator.toml_configurator import ConfigObject 8 | 9 | @pytest.fixture 10 | def tmp_config_file_name() -> str: 11 | yield "config" 12 | 13 | @pytest.fixture 14 | def tmp_config_path(tmp_path) -> str: 15 | yield tmp_path 16 | 17 | @pytest.fixture 18 | def default_config() -> dict[str, dict]: 19 | yield { 20 | "logging": { 21 | "debug": False 22 | } 23 | } 24 | 25 | @pytest.fixture 26 | def config_instance(tmp_config_path,default_config,tmp_config_file_name) -> Configuration: 27 | config = Configuration() 28 | config.init_config(tmp_config_path, default_config, tmp_config_file_name) 29 | yield config 30 | 31 | @pytest.fixture 32 | def config_instance_env_prefix(tmp_config_path,default_config,tmp_config_file_name) -> Configuration: 33 | config = Configuration() 34 | config.init_config(tmp_config_path, default_config, tmp_config_file_name,env_prefix="TEST") 35 | yield config 36 | 37 | def test_init_config(config_instance: Configuration, tmp_config_path: str, tmp_config_file_name: str, default_config: dict[str, dict]): 38 | 39 | assert isinstance(tmp_config_path, (str, Path)) 40 | assert isinstance(tmp_config_file_name, str) 41 | assert isinstance(default_config, dict) 42 | 43 | config_instance.init_config(tmp_config_path, default_config, tmp_config_file_name) 44 | assert isinstance(config_instance.config, TOMLDocument) 45 | assert config_instance.config_path == tmp_config_path 46 | assert config_instance.defaults == default_config 47 | assert config_instance.config_file_name == f"{tmp_config_file_name}.toml" 48 | 49 | def test_sync_config_values(config_instance: Configuration): 50 | config_instance.defaults = { 51 | "logging": { 52 | "debug": False, 53 | "level": "info" 54 | } 55 | } 56 | config_instance.config = TOMLDocument() 57 | config_instance._sync_config_values() 58 | assert "logging" in config_instance.config 59 | assert "debug" in config_instance.config["logging"] 60 | assert "level" in config_instance.config["logging"] 61 | assert config_instance.config["logging"]["level"] == "info" 62 | 63 | def test_clear_old_config_values(config_instance: Configuration): 64 | config_instance.defaults = { 65 | "logging": { 66 | "debug": False 67 | } 68 | } 69 | config_instance.config = TOMLDocument() 70 | config_instance.config["logging"] = { 71 | "debug": False, 72 | "level": "info" 73 | } 74 | config_instance._clear_old_config_values() 75 | assert "level" not in config_instance.config["logging"] 76 | 77 | def test_get_settings(config_instance: Configuration): 78 | config_instance.config = TOMLDocument() 79 | config_instance.config["app"] = { 80 | "host": "localhost", 81 | "port": 8080 82 | } 83 | settings = config_instance.get_settings() 84 | assert settings == {"app_host": "localhost", "app_port": 8080} 85 | 86 | def test_set_attributes(config_instance: Configuration): 87 | config_instance.config = TOMLDocument() 88 | config_instance.config["app"] = { 89 | "host": "localhost", 90 | "port": 8080 91 | } 92 | config_instance._set_attributes() 93 | assert hasattr(config_instance, "_app_host") 94 | assert hasattr(config_instance, "app_host") 95 | assert config_instance._app_host == "localhost" 96 | assert config_instance.app_host == "localhost" 97 | assert config_instance.app.host == "localhost" 98 | 99 | def test_update_config(config_instance: Configuration): 100 | config_instance.config = TOMLDocument() 101 | config_instance.config["app"] = { 102 | "host": "localhost", 103 | "port": 8080 104 | } 105 | config_instance.update_config({"app_host": "test_localhost", "app_port": 8888}) 106 | assert config_instance.config["app"]["host"] == "test_localhost" 107 | assert config_instance.config["app"]["port"] == 8888 108 | assert config_instance.app_host == "test_localhost" 109 | assert config_instance.app.port == 8888 110 | 111 | def test_write_config_to_file(config_instance: Configuration, default_config: dict[str, dict]): 112 | config_instance.config = TOMLDocument() 113 | config_instance.config = default_config 114 | config_instance._write_config_to_file() 115 | assert config_instance.config == default_config 116 | 117 | def test_load_config(config_instance: Configuration): 118 | config_instance.config = TOMLDocument() 119 | config_instance.config["app"] = { 120 | "host": "localhost", 121 | "port": 8080 122 | } 123 | config_instance._write_config_to_file() 124 | assert config_instance._load_config() == config_instance.config 125 | 126 | def test_create_config(config_instance: Configuration): 127 | config_instance.config = TOMLDocument() 128 | config_instance.defaults = { 129 | "app": { 130 | "host": "local", 131 | "port": 1000 132 | }} 133 | config_instance._create_config(config_instance._full_config_path) 134 | assert os.path.exists(config_instance._full_config_path) 135 | assert config_instance._load_config() == config_instance.defaults 136 | 137 | def test_update(config_instance: Configuration, default_config: dict[str, dict]): 138 | config_instance.config = default_config 139 | config_instance.config["logging"]["debug"] = True 140 | config_instance.update() 141 | assert config_instance.logging_debug is True 142 | 143 | def test_update_attribute(config_instance: Configuration): 144 | config_instance.logging.debug = True 145 | config_instance.update() 146 | assert config_instance.logging_debug is True 147 | assert config_instance.config["logging"]["debug"] is True 148 | 149 | def test_os_environment_variables(tmp_config_path: str, tmp_config_file_name: str): 150 | config = Configuration() 151 | new_default = {"env": {"test": "test"}} 152 | config.init_config(tmp_config_path, new_default, tmp_config_file_name) 153 | assert os.environ.get("ENV_TEST") == "test" 154 | 155 | def test_nested_os_environment_variavles(tmp_config_path: str, tmp_config_file_name: str): 156 | config = Configuration() 157 | new_default = {"env": {"level1": {"level2": {"test": "test"}}}} 158 | config.init_config(tmp_config_path, new_default, tmp_config_file_name) 159 | assert os.environ.get("ENV_LEVEL1_LEVEL2_TEST") == "test" 160 | assert os.environ.get("ENV_LEVEL1_LEVEL2") is None 161 | 162 | def test_os_environment_variables_with_prefix(config_instance_env_prefix: Configuration): 163 | assert os.environ.get("TEST_LOGGING_DEBUG") == "False" 164 | config_instance_env_prefix.logging.debug = True 165 | config_instance_env_prefix.update() 166 | assert os.environ.get("TEST_LOGGING_DEBUG") == "True" 167 | 168 | def test_os_environment_override(default_config: dict[str, dict], tmp_config_path: str, tmp_config_file_name: str): 169 | os.environ["LOGGING_DEBUG"] = "Disabled" 170 | config = Configuration() 171 | config.init_config(tmp_config_path, default_config, tmp_config_file_name) 172 | assert config.logging_debug == "Disabled" 173 | assert config.logging.debug == "Disabled" 174 | assert config.config["logging"]["debug"] == "Disabled" 175 | 176 | @pytest.fixture 177 | def config_object(): 178 | config = { 179 | "app": { 180 | "host": "localhost", 181 | "port": 8080 182 | }, 183 | "logging": { 184 | "debug": False, 185 | "level": "info" 186 | } 187 | } 188 | return ConfigObject(config) 189 | 190 | def test_config_object_attribute_access(config_object): 191 | assert config_object.app.host == "localhost" 192 | assert config_object.app.port == 8080 193 | assert config_object.logging.debug is False 194 | assert config_object.logging.level == "info" 195 | 196 | def test_config_object_attribute_update(config_object): 197 | config_object.app.host = "test_localhost" 198 | config_object.app.port = 8888 199 | config_object.logging.debug = True 200 | config_object.logging.level = "debug" 201 | assert config_object.app.host == "test_localhost" 202 | assert config_object.app.port == 8888 203 | assert config_object.logging.debug is True 204 | assert config_object.logging.level == "debug" 205 | 206 | def test_parse_env_value_true(config_instance: Configuration): 207 | values = ["true", "True", "TRUE"] 208 | for value in values: 209 | parsed_value = config_instance._parse_env_value(value) 210 | assert parsed_value is True 211 | 212 | def test_parse_env_value_false(config_instance: Configuration): 213 | values = ["false", "False", "FALSE"] 214 | for value in values: 215 | parsed_value = config_instance._parse_env_value(value) 216 | assert parsed_value is False 217 | 218 | def test_parse_env_value_integer(config_instance: Configuration): 219 | values = ["123000","123_000"] 220 | for value in values: 221 | parsed_value = config_instance._parse_env_value(value) 222 | assert parsed_value == 123000 223 | 224 | def test_parse_env_value_float(config_instance: Configuration): 225 | value = "3.14" 226 | parsed_value = config_instance._parse_env_value(value) 227 | assert parsed_value == 3.14 228 | 229 | def test_parse_env_value_string(config_instance: Configuration): 230 | value = "hello" 231 | parsed_value = config_instance._parse_env_value(value) 232 | assert parsed_value == "hello" 233 | 234 | def test_parse_env_value_list(config_instance: Configuration): 235 | value = "[1, 2, 3]" 236 | parsed_value = config_instance._parse_env_value(value) 237 | assert parsed_value == [1, 2, 3] 238 | 239 | def test_parse_env_value_dict(config_instance: Configuration): 240 | value = '{"key": "value"}' 241 | parsed_value = config_instance._parse_env_value(value) 242 | assert parsed_value == {"key": "value"} 243 | 244 | def test_parse_env_value_datetime(config_instance: Configuration): 245 | value = "2022-01-01 00:00:00" 246 | parsed_value = config_instance._parse_env_value(value) 247 | assert isinstance(parsed_value, datetime) 248 | assert parsed_value.year == 2022 249 | assert parsed_value.month == 1 250 | assert parsed_value.day == 1 251 | assert parsed_value.hour == 0 252 | assert parsed_value.minute == 0 253 | assert parsed_value.second == 0 254 | 255 | def test_parse_env_value_invalid(config_instance: Configuration): 256 | value = "normal string" 257 | parsed_value = config_instance._parse_env_value(value) 258 | assert parsed_value == "normal string" 259 | 260 | def test_make_env_name_with_prefix(config_instance: Configuration): 261 | config_instance.env_prefix = "TEST" 262 | table = "logging" 263 | key = "debug" 264 | env_name = config_instance._make_env_name(table, key) 265 | assert env_name == "TEST_LOGGING_DEBUG" 266 | 267 | def test_make_env_name_without_prefix(config_instance: Configuration): 268 | config_instance.env_prefix = None 269 | table = "app" 270 | key = "port" 271 | env_name = config_instance._make_env_name(table, key) 272 | assert env_name == "APP_PORT" 273 | 274 | def test_get_envs(config_instance: Configuration): 275 | config_instance.logging.debug = False 276 | config_instance.update() 277 | assert config_instance.get_envs() == {"LOGGING_DEBUG": False} 278 | --------------------------------------------------------------------------------