├── .github └── workflows │ ├── build.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGES.rst ├── LICENSE ├── README.rst ├── goodconf ├── __init__.py ├── contrib │ ├── __init__.py │ ├── argparse.py │ └── django.py └── py.typed ├── pyproject.toml └── tests ├── __init__.py ├── test_django.py ├── test_file_helpers.py ├── test_files.py ├── test_goodconf.py ├── test_initial.py └── utils.py /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPI 2 | on: 3 | push: 4 | branches: [main, test-publish] 5 | tags: '*' 6 | pull_request: 7 | 8 | jobs: 9 | build: 10 | name: Build distribution 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Set up Python 16 | uses: actions/setup-python@v5 17 | with: 18 | python-version: "3.12" 19 | - name: Install hatch 20 | run: pip install hatch 21 | - name: Build a binary wheel and a source tarball 22 | run: hatch build 23 | - name: Store the distribution packages 24 | uses: actions/upload-artifact@v4 25 | with: 26 | name: python-package-distributions 27 | path: dist/ 28 | 29 | pypi-publish: 30 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') 31 | needs: [build] 32 | name: Upload release to PyPI 33 | runs-on: ubuntu-latest 34 | environment: 35 | name: pypi 36 | url: https://pypi.org/p/goodconf 37 | permissions: 38 | id-token: write 39 | steps: 40 | - name: Download all the dists 41 | uses: actions/download-artifact@v4 42 | with: 43 | name: python-package-distributions 44 | path: dist/ 45 | - name: Publish distribution to PyPI 46 | uses: pypa/gh-action-pypi-publish@release/v1 47 | 48 | github-release: 49 | name: >- 50 | Sign the Python 🐍 distribution 📦 with Sigstore 51 | and upload them to GitHub Release 52 | needs: 53 | - pypi-publish 54 | runs-on: ubuntu-latest 55 | 56 | permissions: 57 | contents: write # IMPORTANT: mandatory for making GitHub Releases 58 | id-token: write # IMPORTANT: mandatory for sigstore 59 | 60 | steps: 61 | - name: Download all the dists 62 | uses: actions/download-artifact@v4 63 | with: 64 | name: python-package-distributions 65 | path: dist/ 66 | - name: Sign the dists with Sigstore 67 | uses: sigstore/gh-action-sigstore-python@v3.0.0 68 | with: 69 | inputs: >- 70 | ./dist/*.tar.gz 71 | ./dist/*.whl 72 | - name: Create GitHub Release 73 | env: 74 | GITHUB_TOKEN: ${{ github.token }} 75 | run: >- 76 | gh release create 77 | '${{ github.ref_name }}' 78 | --repo '${{ github.repository }}' 79 | --generate-notes 80 | - name: Upload artifact signatures to GitHub Release 81 | env: 82 | GITHUB_TOKEN: ${{ github.token }} 83 | # Upload to GitHub Release using the `gh` CLI. 84 | # `dist/` contains the built packages, and the 85 | # sigstore-produced signatures and certificates. 86 | run: >- 87 | gh release upload 88 | '${{ github.ref_name }}' dist/** 89 | --repo '${{ github.repository }}' 90 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [main] 7 | 8 | permissions: 9 | contents: read # to fetch code (actions/checkout) 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | python: ["3.9", "3.10", "3.11", "3.12", "3.13"] 17 | env: 18 | PYTHON: ${{ matrix.python }} 19 | steps: 20 | - uses: actions/checkout@v3 21 | 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v4 24 | with: 25 | python-version: ${{ matrix.python }} 26 | cache: 'pip' 27 | cache-dependency-path: pyproject.toml 28 | 29 | - name: Install dependencies 30 | run: python -m pip install -e .[tests] 31 | 32 | - name: Test 33 | run: pytest --cov-report=xml --cov-report=term 34 | 35 | - uses: codecov/codecov-action@v3 36 | if: ${{ matrix.python == '3.12' }} 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /build 3 | /goodconf/_version.py 4 | .coverage 5 | htmlcov 6 | .pytest_cache 7 | *.egg-info 8 | env 9 | venv 10 | __pycache__ 11 | uv.lock 12 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.11.5 4 | hooks: 5 | # Run the linter. 6 | # - id: ruff 7 | # Run the formatter. 8 | - id: ruff-format 9 | - repo: https://github.com/asottile/pyupgrade 10 | rev: v3.19.1 11 | hooks: 12 | - id: pyupgrade 13 | args: ["--py39-plus"] 14 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | Change Log 3 | ========== 4 | 5 | 6.0.0 (8 October 2024) 6 | ======================== 7 | 8 | - **Backwards Incompatible Release** 9 | 10 | - Removed the ``_config_file`` attribute from ``GoodConf``. 11 | If you previously set this attribute, you are no longer be able to do so. 12 | - Support reading TOML files via ``tomllib`` on Python 3.11+ 13 | - Update Markdown generation, so that output matches v4 output 14 | 15 | 5.0.0 (13 August 2024) 16 | ======================== 17 | 18 | - **Backwards Incompatible Release** 19 | 20 | - Removed official support for Python 3.8 21 | - upgraded to pydantic2 22 | 23 | To upgrade from goodconf v4 to goodconf v5: 24 | 25 | - If subclassing ``GoodConf``, replace uses of ``class Config`` with ``model_config``. 26 | 27 | For example goodconf v4 code like this: 28 | 29 | .. code:: python 30 | 31 | from goodconf import GoodConf 32 | 33 | class AppConfig(GoodConf): 34 | "Configuration for My App" 35 | DATABASE_URL: PostgresDsn = "postgres://localhost:5432/mydb" 36 | 37 | class Config: 38 | default_files = ["/etc/myproject/myproject.yaml", "myproject.yaml"] 39 | 40 | config = AppConfig() 41 | 42 | should be replaced in goodconf v5 with: 43 | 44 | .. code:: python 45 | 46 | from goodconf import GoodConf 47 | 48 | class AppConfig(GoodConf): 49 | "Configuration for My App" 50 | DATABASE_URL: PostgresDsn = "postgres://localhost:5432/mydb" 51 | 52 | model_config = {"default_files": ["/etc/myproject/myproject.yaml", "myproject.yaml"]} 53 | 54 | config = AppConfig() 55 | 56 | 4.0.3 (13 August 2024) 57 | ======================== 58 | 59 | - Release from GitHub Actions 60 | 61 | 4.0.2 (11 February 2024) 62 | ======================== 63 | 64 | - Another markdown output fix 65 | - Fix for markdown generation generation on Python 3.8 & 3.9 66 | 67 | 4.0.1 (10 February 2024) 68 | ======================== 69 | 70 | - Fix trailing whitespace in markdown output 71 | 72 | 4.0.0 (10 February 2024) 73 | ======================== 74 | 75 | - Removed errant print statement 76 | - Removed official support for Python 3.7 77 | - Added support for Python 3.12 78 | 79 | 3.1.0 (10 February 2024) 80 | ======================== 81 | 82 | - Fixed type display in Markdown generation 83 | - Changed markdown output format (trailing spaces were problematic). 84 | 85 | 3.0.1 (30 June 2023) 86 | ==================== 87 | 88 | - pin to pydantic < 2 due to breaking changes in 2.0 89 | 90 | 3.0.0 (17 January 2023) 91 | ================== 92 | 93 | - TOML files are now supported as configuration source 94 | - Python 3.11 and 3.10 are now officially supported 95 | - Python 3.6 is no longer officially supported 96 | - Requires Pydantic 1.7+ 97 | - Variables can now be set during class initialization 98 | 99 | 100 | 2.0.1 (15 June 2021) 101 | ==================== 102 | 103 | - Change to newer syntax for safe loading yaml 104 | 105 | 106 | 2.0.0 (13 May 2021) 107 | =================== 108 | 109 | - **Backwards Incompatible Release** 110 | Internals replaced with `pydantic `_. Users can either pin to ``1.0.0`` or update their code as follows: 111 | 112 | - Replace ``goodconf.Value`` with ``goodconf.Field``. 113 | - Replace ``help`` keyword argument with ``description`` in ``Field`` (previously ``Value``). 114 | - Remove ``cast_as`` keyword argument from ``Field`` (previously ``Value``). Standard Python type annotations are now used. 115 | - Move ``file_env_var`` and ``default_files`` keyword arguments used in class initialization to a sub-class named ``Config`` 116 | 117 | Given a version ``1`` class that looks like this: 118 | 119 | .. code:: python 120 | 121 | from goodconf import GoodConf, Value 122 | 123 | class AppConfig(GoodConf): 124 | "Configuration for My App" 125 | DEBUG = Value(default=False, help="Toggle debugging.") 126 | MAX_REQUESTS = Value(cast_as=int) 127 | 128 | config = AppConfig(default_files=["config.yml"]) 129 | 130 | A class updated for version `2` would be: 131 | 132 | .. code:: python 133 | 134 | from goodconf import GoodConf, Field 135 | 136 | class AppConfig(GoodConf): 137 | "Configuration for My App" 138 | DEBUG: bool = Field(default=False, description="Toggle debugging.") 139 | MAX_REQUESTS: int 140 | 141 | class Config: 142 | default_files=["config.yml"] 143 | 144 | config = AppConfig() 145 | 146 | 2.0b3 (15 April 2021) 147 | ===================== 148 | 149 | - Environment variables take precedence over configuration files in the event of a conflict 150 | 151 | 2.0b2 (12 March 2021) 152 | ===================== 153 | 154 | - Use null value for initial if allowed 155 | - Store the config file parsed as ``GoodConf.Config._config_file`` 156 | 157 | 158 | 2.0b1 (11 March 2021) 159 | ===================== 160 | 161 | - Backwards Incompatible: Migrated backend to ``pydantic``. 162 | 163 | - ``Value`` is replaced by the `Field function `__. 164 | - ``help`` keyword arg is now ``description`` 165 | - ``GoodConf`` is now backed by `BaseSettings `__ 166 | Instead of passing keyword args when instantiating the class, they are now defined on a ``Config`` class on the object 167 | 168 | 169 | 170 | 1.0.0 (18 July 2018) 171 | ==================== 172 | 173 | - Allow overriding of values in the generate_* methods 174 | - Python 3.7 supported 175 | 176 | 177 | 0.9.1 (10 April 2018) 178 | ===================== 179 | 180 | - Explicit ``load`` method 181 | - ``django_manage`` method helper on ``GoodConf`` 182 | - Fixed a few minor bugs 183 | 184 | 185 | 0.9.0 (8 April 2018) 186 | ==================== 187 | 188 | - Use a declarative class to define GoodConf's values. 189 | 190 | - Change description to a docstring of the class. 191 | 192 | - Remove the redundant ``required`` argument from ``Values``. To make 193 | an value optional, give it a default. 194 | 195 | - Changed implicit loading to happen during instanciation rather than first 196 | access. Instanciate with ``load=False`` to avoid loading config initially. 197 | 198 | 0.8.3 (28 Mar 2018) 199 | =================== 200 | 201 | - Implicitly load config if not loaded by first access. 202 | 203 | 0.8.2 (28 Mar 2018) 204 | =================== 205 | 206 | - ``-c`` is used by Django's ``collectstatic``. Using ``-C`` instead. 207 | 208 | 0.8.1 (28 Mar 2018) 209 | =================== 210 | 211 | - Adds ``goodconf.contrib.argparse`` to add a config argument to an existing 212 | parser. 213 | 214 | 0.8.0 (27 Mar 2018) 215 | =================== 216 | 217 | - Major refactor from ``file-or-env`` to ``goodconf`` 218 | 219 | 0.6.1 (16 Mar 2018) 220 | ================ 221 | 222 | - Fixed packaging issue. 223 | 224 | 0.6.0 (16 Mar 2018) 225 | ================ 226 | 227 | - Fixes bug in stack traversal to find calling file. 228 | 229 | 230 | 0.5.1 (15 March 2018) 231 | ================== 232 | 233 | - Initial release 234 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Lincoln Loop 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Goodconf 2 | ======== 3 | 4 | .. image:: https://github.com/lincolnloop/goodconf/actions/workflows/test.yml/badge.svg?branch=main&event=push 5 | :target: https://github.com/lincolnloop/goodconf/actions/workflows/test.yml?query=branch%3Amain+event%3Apush 6 | 7 | .. image:: https://results.pre-commit.ci/badge/github/lincolnloop/goodconf/main.svg 8 | :target: https://results.pre-commit.ci/latest/github/lincolnloop/goodconf/main 9 | :alt: pre-commit.ci status 10 | 11 | .. image:: https://img.shields.io/codecov/c/github/lincolnloop/goodconf.svg 12 | :target: https://codecov.io/gh/lincolnloop/goodconf 13 | 14 | .. image:: https://img.shields.io/pypi/v/goodconf.svg 15 | :target: https://pypi.python.org/pypi/goodconf 16 | 17 | .. image:: https://img.shields.io/pypi/pyversions/goodconf.svg 18 | :target: https://pypi.python.org/pypi/goodconf 19 | 20 | A thin wrapper over `Pydantic's settings management `__. 21 | Allows you to define configuration variables and load them from environment or JSON/YAML/TOML 22 | file. Also generates initial configuration files and documentation for your 23 | defined configuration. 24 | 25 | 26 | Installation 27 | ------------ 28 | 29 | ``pip install goodconf`` or ``pip install goodconf[yaml]`` / 30 | ``pip install goodconf[toml]`` if parsing/generating YAML/TOML 31 | files is required. When running on Python 3.11+ the ``[toml]`` 32 | extra is only required for generating TOML files as parsing 33 | is supported natively. 34 | 35 | 36 | Quick Start 37 | ----------- 38 | 39 | Let's use configurable Django settings as an example. 40 | 41 | First, create a ``conf.py`` file in your project's directory, next to 42 | ``settings.py``: 43 | 44 | .. code:: python 45 | 46 | import base64 47 | import os 48 | 49 | from goodconf import GoodConf, Field 50 | from pydantic import PostgresDsn 51 | 52 | class AppConfig(GoodConf): 53 | "Configuration for My App" 54 | DEBUG: bool 55 | DATABASE_URL: PostgresDsn = "postgres://localhost:5432/mydb" 56 | SECRET_KEY: str = Field( 57 | initial=lambda: base64.b64encode(os.urandom(60)).decode(), 58 | description="Used for cryptographic signing. " 59 | "https://docs.djangoproject.com/en/2.0/ref/settings/#secret-key") 60 | 61 | model_config = {"default_files": ["/etc/myproject/myproject.yaml", "myproject.yaml"]} 62 | 63 | config = AppConfig() 64 | 65 | Next, use the config in your ``settings.py`` file: 66 | 67 | .. code:: python 68 | 69 | import dj_database_url 70 | from .conf import config 71 | 72 | config.load() 73 | 74 | DEBUG = config.DEBUG 75 | SECRET_KEY = config.SECRET_KEY 76 | DATABASES = {"default": dj_database_url.parse(config.DATABASE_URL)} 77 | 78 | In your initial developer installation instructions, give some advice such as: 79 | 80 | .. code:: shell 81 | 82 | python -c "import myproject; print(myproject.conf.config.generate_yaml(DEBUG=True))" > myproject.yaml 83 | 84 | Better yet, make it a function and `entry point `__ so you can install 85 | your project and run something like ``generate-config > myproject.yaml``. 86 | 87 | Usage 88 | ----- 89 | 90 | 91 | ``GoodConf`` 92 | ^^^^^^^^^^^^ 93 | 94 | Your subclassed ``GoodConf`` object can include a ``model_config`` dictionary with the following 95 | attributes: 96 | 97 | ``file_env_var`` 98 | The name of an environment variable which can be used for 99 | the name of the configuration file to load. 100 | ``default_files`` 101 | If no file is passed to the ``load`` method, try to load a 102 | configuration from these files in order. 103 | 104 | It also has one method: 105 | 106 | ``load`` 107 | Trigger the load method during instantiation. Defaults to False. 108 | 109 | Use plain-text docstring for use as a header when generating a configuration 110 | file. 111 | 112 | Environment variables always take precedence over variables in the configuration files. 113 | 114 | See Pydantic's docs for examples of loading: 115 | 116 | * `Dotenv (.env) files `_ 117 | * `Docker secrets `_ 118 | 119 | 120 | Fields 121 | ^^^^^^ 122 | 123 | Declare configuration values by subclassing ``GoodConf`` and defining class 124 | attributes which are standard Python type definitions or Pydantic ``FieldInfo`` 125 | instances generated by the ``Field`` function. 126 | 127 | Goodconf can use one extra argument provided to the ``Field`` to define an function 128 | which can generate an initial value for the field: 129 | 130 | ``initial`` 131 | Callable to use for initial value when generating a config 132 | 133 | 134 | Django Usage 135 | ------------ 136 | 137 | A helper is provided which monkey-patches Django's management commands to 138 | accept a ``--config`` argument. Replace your ``manage.py`` with the following: 139 | 140 | .. code:: python 141 | 142 | # Define your GoodConf in `myproject/conf.py` 143 | from myproject.conf import config 144 | 145 | if __name__ == '__main__': 146 | config.django_manage() 147 | 148 | 149 | Why? 150 | ---- 151 | 152 | I took inspiration from `logan `__ (used by 153 | Sentry) and `derpconf `__ (used by 154 | Thumbor). Both, however used Python files for configuration. I wanted a safer 155 | format and one that was easier to serialize data into from a configuration 156 | management system. 157 | 158 | Environment Variables 159 | ^^^^^^^^^^^^^^^^^^^^^ 160 | 161 | I don't like working with environment variables. First, there are potential 162 | security issues: 163 | 164 | 1. Accidental leaks via logging or error reporting services. 165 | 2. Child process inheritance (see `ImageTragick `__ 166 | for an idea why this could be bad). 167 | 168 | Second, in practice on deployment environments, environment variables end up 169 | getting written to a number of files (cron, bash profile, service definitions, 170 | web server config, etc.). Not only is it cumbersome, but also increases the 171 | possibility of leaks via incorrect file permissions. 172 | 173 | I prefer a single structured file which is explicitly read by the application. 174 | I also want it to be easy to run my applications on services like Heroku 175 | where environment variables are the preferred configuration method. 176 | 177 | This module let's me do things the way I prefer in environments I control, but 178 | still run them with environment variables on environments I don't control with 179 | minimal fuss. 180 | 181 | 182 | Contribute 183 | ---------- 184 | 185 | Create virtual environment and install package and dependencies. 186 | 187 | .. code:: shell 188 | 189 | pip install -e ".[tests]" 190 | 191 | 192 | Run tests 193 | 194 | .. code:: shell 195 | 196 | pytest 197 | 198 | Releases are done with GitHub Actions whenever a new tag is created. For more information, 199 | see `<./.github/workflows/build.yml>`_ 200 | -------------------------------------------------------------------------------- /goodconf/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Transparently load variables from environment or JSON/YAML file. 3 | """ 4 | 5 | # Note: the following line is included to ensure Python3.9 compatibility. 6 | from __future__ import annotations 7 | 8 | import errno 9 | import json 10 | import logging 11 | import os 12 | import sys 13 | from functools import partial 14 | from io import StringIO 15 | from types import GenericAlias 16 | from typing import TYPE_CHECKING, cast, get_args 17 | 18 | from pydantic._internal._config import config_keys 19 | from pydantic.fields import Field as PydanticField, PydanticUndefined 20 | from pydantic.main import _object_setattr 21 | from pydantic_settings import ( 22 | BaseSettings, 23 | PydanticBaseSettingsSource, 24 | SettingsConfigDict, 25 | ) 26 | 27 | if TYPE_CHECKING: 28 | from typing import Any 29 | 30 | from pydantic.fields import FieldInfo 31 | 32 | 33 | __all__ = ["GoodConf", "GoodConfConfigDict", "Field"] 34 | 35 | log = logging.getLogger(__name__) 36 | 37 | 38 | def Field( 39 | *args, 40 | initial=None, 41 | json_schema_extra=None, 42 | **kwargs, 43 | ): 44 | if initial: 45 | json_schema_extra = json_schema_extra or {} 46 | json_schema_extra["initial"] = initial 47 | 48 | return PydanticField(*args, json_schema_extra=json_schema_extra, **kwargs) 49 | 50 | 51 | class GoodConfConfigDict(SettingsConfigDict): 52 | # configuration file to load 53 | file_env_var: str | None 54 | # if no file is given, try to load a configuration from these files in order 55 | default_files: list[str] | None 56 | 57 | 58 | # Note: code from pydantic-settings/pydantic_settings/main.py: 59 | # Extend `config_keys` by pydantic settings config keys to 60 | # support setting config through class kwargs. 61 | # Pydantic uses `config_keys` in `pydantic._internal._config.ConfigWrapper.for_model` 62 | # to extract config keys from model kwargs, So, by adding pydantic settings keys to 63 | # `config_keys`, they will be considered as valid config keys and will be collected 64 | # by Pydantic. 65 | config_keys |= set(GoodConfConfigDict.__annotations__.keys()) 66 | 67 | 68 | def _load_config(path: str) -> dict[str, Any]: 69 | """ 70 | Given a file path, parse it based on its extension (YAML, TOML or JSON) 71 | and return the values as a Python dictionary. JSON is the default if an 72 | extension can't be determined. 73 | """ 74 | __, ext = os.path.splitext(path) 75 | if ext in [".yaml", ".yml"]: 76 | import ruamel.yaml 77 | 78 | yaml = ruamel.yaml.YAML(typ="safe", pure=True) 79 | loader = yaml.load 80 | elif ext == ".toml": 81 | try: 82 | import tomllib 83 | 84 | def load(stream): 85 | return tomllib.loads(f.read()) 86 | except ImportError: # Fallback for Python < 3.11 87 | import tomlkit 88 | 89 | def load(stream): 90 | return tomlkit.load(f).unwrap() 91 | 92 | loader = load 93 | 94 | else: 95 | loader = json.load 96 | with open(path) as f: 97 | config = loader(f) 98 | return config or {} 99 | 100 | 101 | def _find_file(filename: str, require: bool = True) -> str | None: 102 | if not os.path.exists(filename): 103 | if not require: 104 | return None 105 | raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), filename) 106 | return os.path.abspath(filename) 107 | 108 | 109 | def _fieldinfo_to_str(field_info: FieldInfo) -> str: 110 | """ 111 | Return the string representation of a pydantic.fields.FieldInfo. 112 | """ 113 | if isinstance(field_info.annotation, type) and not isinstance( 114 | field_info.annotation, GenericAlias 115 | ): 116 | # For annotation like , we use its name ("int"). 117 | field_type = field_info.annotation.__name__ 118 | else: 119 | if str(field_info.annotation).startswith("typing."): 120 | # For annotation like typing.Literal['a', 'b'], we use 121 | # its string representation, but without "typing." ("Literal['a', 'b']"). 122 | field_type = str(field_info.annotation)[len("typing.") :] 123 | else: 124 | # For annotation like list[str], we use its string 125 | # representation ("list[str]"). 126 | field_type = field_info.annotation 127 | return field_type 128 | 129 | 130 | def initial_for_field(name: str, field_info: FieldInfo) -> Any: 131 | try: 132 | json_schema_extra = field_info.json_schema_extra or {} 133 | if not callable(json_schema_extra["initial"]): 134 | raise ValueError(f"Initial value for `{name}` must be a callable.") 135 | return field_info.json_schema_extra["initial"]() 136 | except KeyError: 137 | if ( 138 | field_info.default is not PydanticUndefined 139 | and field_info.default is not ... 140 | ): 141 | return field_info.default 142 | if field_info.default_factory is not None: 143 | return field_info.default_factory() 144 | if type(None) in get_args(field_info.annotation): 145 | return None 146 | return "" 147 | 148 | 149 | class FileConfigSettingsSource(PydanticBaseSettingsSource): 150 | """ 151 | Source class for loading values provided during settings class initialization. 152 | """ 153 | 154 | def __init__(self, settings_cls: type[BaseSettings]): 155 | super().__init__(settings_cls) 156 | 157 | def get_field_value( 158 | self, field: FieldInfo, field_name: str 159 | ) -> tuple[Any, str, bool]: 160 | # Nothing to do here. Only implement the return statement to make mypy happy 161 | return None, "", False 162 | 163 | def __call__(self) -> dict[str, Any]: 164 | settings = cast(GoodConf, self.settings_cls) 165 | selected_config_file = None 166 | if cfg_file := self.current_state.get("_config_file"): 167 | selected_config_file = cfg_file 168 | elif (file_env_var := settings.model_config.get("file_env_var")) and ( 169 | cfg_file := os.environ.get(file_env_var) 170 | ): 171 | selected_config_file = _find_file(cfg_file) 172 | else: 173 | for filename in settings.model_config.get("default_files") or []: 174 | selected_config_file = _find_file(filename, require=False) 175 | if selected_config_file: 176 | break 177 | if selected_config_file: 178 | values = _load_config(selected_config_file) 179 | log.info("Loading config from %s", selected_config_file) 180 | else: 181 | values = {} 182 | log.info("No config file specified. Loading with environment variables.") 183 | return values 184 | 185 | def __repr__(self) -> str: 186 | return "FileConfigSettingsSource()" 187 | 188 | 189 | class GoodConf(BaseSettings): 190 | def __init__( 191 | self, load: bool = False, config_file: str | None = None, **kwargs 192 | ) -> None: 193 | """ 194 | :param load: load config file on instantiation [default: False]. 195 | 196 | A docstring defined on the class should be a plain-text description 197 | used as a header when generating a configuration file. 198 | """ 199 | if kwargs or load: # Emulate Pydantic behavior, load immediately 200 | self._load(_init_config_file=config_file, **kwargs) 201 | elif config_file: 202 | _object_setattr( 203 | self, "_load", partial(self._load, _init_config_file=config_file) 204 | ) 205 | 206 | @classmethod 207 | def settings_customise_sources( 208 | cls, 209 | settings_cls: type[BaseSettings], 210 | init_settings: PydanticBaseSettingsSource, 211 | env_settings: PydanticBaseSettingsSource, 212 | dotenv_settings: PydanticBaseSettingsSource, 213 | file_secret_settings: PydanticBaseSettingsSource, 214 | ) -> tuple[PydanticBaseSettingsSource, ...]: 215 | """Load environment variables before init""" 216 | return ( 217 | init_settings, 218 | env_settings, 219 | dotenv_settings, 220 | FileConfigSettingsSource(settings_cls), 221 | file_secret_settings, 222 | ) 223 | 224 | model_config = GoodConfConfigDict() 225 | 226 | def _settings_build_values( 227 | self, 228 | init_kwargs: dict[str, Any], 229 | **kwargs, 230 | ) -> dict[str, Any]: 231 | state = super()._settings_build_values( 232 | init_kwargs, 233 | **kwargs, 234 | ) 235 | state.pop("_config_file", None) 236 | return state 237 | 238 | def _load( 239 | self, 240 | _config_file: str | None = None, 241 | _init_config_file: str | None = None, 242 | **kwargs, 243 | ): 244 | if config_file := _config_file or _init_config_file: 245 | kwargs["_config_file"] = config_file 246 | super().__init__(**kwargs) 247 | 248 | def load(self, filename: str | None = None) -> None: 249 | self._load(_config_file=filename) 250 | 251 | @classmethod 252 | def get_initial(cls, **override) -> dict: 253 | return { 254 | k: override.get(k, initial_for_field(k, v)) 255 | for k, v in cls.model_fields.items() 256 | } 257 | 258 | @classmethod 259 | def generate_yaml(cls, **override) -> str: 260 | """ 261 | Dumps initial config in YAML 262 | """ 263 | import ruamel.yaml 264 | 265 | yaml = ruamel.yaml.YAML() 266 | yaml.representer.add_representer( 267 | type(None), 268 | lambda self, d: self.represent_scalar("tag:yaml.org,2002:null", "~"), 269 | ) 270 | yaml_str = StringIO() 271 | yaml.dump(cls.get_initial(**override), stream=yaml_str) 272 | yaml_str.seek(0) 273 | dict_from_yaml = yaml.load(yaml_str) 274 | if cls.__doc__: 275 | dict_from_yaml.yaml_set_start_comment("\n" + cls.__doc__ + "\n\n") 276 | for k in dict_from_yaml.keys(): 277 | if cls.model_fields[k].description: 278 | description = cast(str, cls.model_fields[k].description) 279 | dict_from_yaml.yaml_set_comment_before_after_key( 280 | k, before="\n" + description 281 | ) 282 | yaml_str = StringIO() 283 | yaml.dump(dict_from_yaml, yaml_str) 284 | yaml_str.seek(0) 285 | return yaml_str.read() 286 | 287 | @classmethod 288 | def generate_json(cls, **override) -> str: 289 | """ 290 | Dumps initial config in JSON 291 | """ 292 | return json.dumps(cls.get_initial(**override), indent=2) 293 | 294 | @classmethod 295 | def generate_toml(cls, **override) -> str: 296 | """ 297 | Dumps initial config in TOML 298 | """ 299 | import tomlkit 300 | from tomlkit.items import Item 301 | 302 | toml_str = tomlkit.dumps(cls.get_initial(**override)) 303 | dict_from_toml = tomlkit.loads(toml_str) 304 | document = tomlkit.document() 305 | if cls.__doc__: 306 | document.add(tomlkit.comment(cls.__doc__)) 307 | for k, v in dict_from_toml.unwrap().items(): 308 | document.add(k, v) 309 | if cls.model_fields[k].description: 310 | description = cast(str, cls.model_fields[k].description) 311 | cast(Item, document[k]).comment(description) 312 | return tomlkit.dumps(document) 313 | 314 | @classmethod 315 | def generate_markdown(cls) -> str: 316 | """ 317 | Documents values in markdown 318 | """ 319 | lines = [] 320 | if cls.__doc__: 321 | lines.extend([f"# {cls.__doc__}", ""]) 322 | 323 | for k, field_info in cls.model_fields.items(): 324 | lines.append(f"* **{k}**") 325 | if field_info.is_required(): 326 | lines[-1] = f"{lines[-1]} _REQUIRED_" 327 | if field_info.description: 328 | lines.append(f" * description: {field_info.description}") 329 | # We want to append a line with the field_info type, and sometimes 330 | # field_info.annotation looks the way we want, like 'list[str]', but 331 | # other times, it includes some extra text, like ''. 332 | # Therefore, we have some logic to make the type show up the way we want. 333 | field_type = _fieldinfo_to_str(field_info) 334 | lines.append(f" * type: `{field_type}`") 335 | if field_info.default not in [None, PydanticUndefined]: 336 | lines.append(f" * default: `{field_info.default}`") 337 | return "\n".join(lines) 338 | 339 | def django_manage(self, args: list[str] | None = None): 340 | args = args or sys.argv 341 | from .contrib.django import execute_from_command_line_with_config 342 | 343 | execute_from_command_line_with_config(self, args) 344 | -------------------------------------------------------------------------------- /goodconf/contrib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lincolnloop/goodconf/41991d16c6edad6e9362b0b7d7b9f4954a1f2cbe/goodconf/contrib/__init__.py -------------------------------------------------------------------------------- /goodconf/contrib/argparse.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | from .. import GoodConf 4 | 5 | 6 | def argparser_add_argument(parser: argparse.ArgumentParser, config: GoodConf): 7 | """Adds argument for config to existing argparser""" 8 | help = "Config file." 9 | cfg = config.model_config 10 | if cfg.get("file_env_var"): 11 | help += ( 12 | "Can also be configured via the environment variable: " 13 | f"{cfg['file_env_var']}" 14 | ) 15 | if cfg.get("default_files"): 16 | files_str = ", ".join(cfg["default_files"]) 17 | help += f" Defaults to the first file that exists from [{files_str}]." 18 | parser.add_argument("-C", "--config", metavar="FILE", help=help) 19 | -------------------------------------------------------------------------------- /goodconf/contrib/django.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from contextlib import contextmanager 3 | from typing import List 4 | from collections.abc import Generator 5 | 6 | from .. import GoodConf 7 | from .argparse import argparser_add_argument 8 | 9 | 10 | @contextmanager 11 | def load_config_from_cli( 12 | config: GoodConf, argv: list[str] 13 | ) -> Generator[list[str], None, None]: 14 | """Loads config, checking CLI arguments for a config file""" 15 | 16 | # Monkey patch Django's command parser 17 | from django.core.management.base import BaseCommand 18 | 19 | original_parser = BaseCommand.create_parser 20 | 21 | def patched_parser(self, prog_name, subcommand): 22 | parser = original_parser(self, prog_name, subcommand) 23 | argparser_add_argument(parser, config) 24 | return parser 25 | 26 | BaseCommand.create_parser = patched_parser 27 | 28 | try: 29 | parser = argparse.ArgumentParser(add_help=False) 30 | argparser_add_argument(parser, config) 31 | 32 | config_arg, default_args = parser.parse_known_args(argv) 33 | config.load(config_arg.config) 34 | yield default_args 35 | finally: 36 | # Put that create_parser back where it came from or so help me! 37 | BaseCommand.create_parser = original_parser 38 | 39 | 40 | def execute_from_command_line_with_config(config: GoodConf, argv: list[str]): 41 | """Load's config then runs Django's execute_from_command_line""" 42 | with load_config_from_cli(config, argv) as args: 43 | from django.core.management import execute_from_command_line 44 | 45 | execute_from_command_line(args) 46 | -------------------------------------------------------------------------------- /goodconf/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lincolnloop/goodconf/41991d16c6edad6e9362b0b7d7b9f4954a1f2cbe/goodconf/py.typed -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "goodconf" 3 | description = "Load configuration variables from a file or environment" 4 | readme = "README.rst" 5 | requires-python = ">=3.9" 6 | authors = [ 7 | {name = "Peter Baumgartner", email = "brett@python.org"} 8 | ] 9 | keywords = ["env", "config", "json", "yaml", "toml"] 10 | license = {file = "LICENSE"} 11 | classifiers = [ 12 | "Development Status :: 5 - Production/Stable", 13 | "Intended Audience :: Developers", 14 | "License :: OSI Approved :: MIT License", 15 | "Operating System :: OS Independent", 16 | "Programming Language :: Python", 17 | "Programming Language :: Python :: 3.9", 18 | "Programming Language :: Python :: 3.10", 19 | "Programming Language :: Python :: 3.11", 20 | "Programming Language :: Python :: 3.12", 21 | ] 22 | dynamic = ["version"] 23 | dependencies = [ 24 | "pydantic>=2.7", 25 | "pydantic-settings>=2.4", 26 | ] 27 | 28 | [project.optional-dependencies] 29 | yaml = ["ruamel.yaml>=0.17.0"] 30 | toml = ["tomlkit>=0.11.6"] 31 | tests = [ 32 | "django>=3.2.0", 33 | "ruamel.yaml>=0.17.0", 34 | "tomlkit>=0.11.6", 35 | "pytest==7.2.*", 36 | "pytest-cov==4.0.*", 37 | "pytest-mock==3.10.*" 38 | ] 39 | 40 | [project.urls] 41 | homepage = "https://github.com/lincolnloop/goodconf/" 42 | changelog = "https://github.com/lincolnloop/goodconf/blob/main/CHANGES.rst" 43 | 44 | [tool.hatch.build.targets.sdist] 45 | exclude = [ 46 | "/.github", 47 | ] 48 | 49 | [tool.hatch.build.hooks.vcs] 50 | version-file = "goodconf/_version.py" 51 | 52 | [tool.hatch.version] 53 | source = "vcs" 54 | 55 | [tool.pytest.ini_options] 56 | addopts = "-s --cov --cov-branch" 57 | 58 | [tool.ruff.lint] 59 | select = ["ALL"] 60 | ignore = [ 61 | "ANN101", # Missing Type Annotation for "self" 62 | "ANN401", # Dynamically typed expressions (typing.Any) are disallowed in `**kwargs`" 63 | "ARG001", # Unused function argument (request, ...) 64 | "ARG002", # Unused method argument (*args, **kwargs) 65 | "D", # Missing or badly formatted docstrings 66 | "E501", # Let the formatter handle long lines 67 | "FBT", # Flake Boolean Trap (don't use arg=True in functions) 68 | "RUF012", # Mutable class attributes https://github.com/astral-sh/ruff/issues/5243 69 | 70 | "COM812", # (ruff format) Checks for the absence of trailing commas 71 | "ISC001", # (ruff format) Checks for implicitly concatenated strings on a single line 72 | ] 73 | 74 | [tool.ruff.lint.extend-per-file-ignores] 75 | # Also ignore `E402` in all `__init__.py` files. 76 | "test_*.py" = [ 77 | "ANN001", # Missing type annotation for function argument 78 | "ANN201", # Missing return type annotation 79 | "S101", # S101 Use of `assert` detected 80 | "PLR2004", # Magic value used in comparison, 81 | ] 82 | 83 | [build-system] 84 | requires = ["hatchling", "hatch-vcs"] 85 | build-backend = "hatchling.build" 86 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lincolnloop/goodconf/41991d16c6edad6e9362b0b7d7b9f4954a1f2cbe/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_django.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest 4 | from pydantic import ConfigDict 5 | 6 | from goodconf import GoodConf 7 | 8 | pytest.importorskip("django") 9 | 10 | 11 | def test_mgmt_command(mocker, tmpdir): 12 | mocked_load_config = mocker.patch("goodconf._load_config") 13 | mocked_dj_execute = mocker.patch("django.core.management.execute_from_command_line") 14 | temp_config = tmpdir.join("config.yml") 15 | temp_config.write("") 16 | 17 | class G(GoodConf): 18 | model_config = ConfigDict() 19 | 20 | c = G() 21 | dj_args = ["manage.py", "diffsettings", "-v", "2"] 22 | c.django_manage(dj_args + ["-C", str(temp_config)]) 23 | mocked_load_config.assert_called_once_with(str(temp_config)) 24 | mocked_dj_execute.assert_called_once_with(dj_args) 25 | 26 | 27 | def test_help(mocker, tmpdir, capsys): 28 | mocker.patch("sys.exit") 29 | mocked_load_config = mocker.patch("goodconf._load_config") 30 | temp_config = tmpdir.join("config.yml") 31 | temp_config.write("") 32 | 33 | class G(GoodConf): 34 | model_config = ConfigDict( 35 | file_env_var="MYAPP_CONF", 36 | default_files=["/etc/myapp.json"], 37 | ) 38 | 39 | c = G() 40 | assert c.model_config.get("file_env_var") == "MYAPP_CONF" 41 | c.django_manage( 42 | [ 43 | "manage.py", 44 | "diffsettings", 45 | "-C", 46 | str(temp_config), 47 | "--settings", 48 | __name__, 49 | "-h", 50 | ] 51 | ) 52 | mocked_load_config.assert_called_once_with(str(temp_config)) 53 | output = capsys.readouterr() 54 | if sys.version_info < (3, 13): 55 | assert "-C FILE, --config FILE" in output.out 56 | else: 57 | assert "-C, --config FILE" in output.out 58 | 59 | assert "MYAPP_CONF" in output.out 60 | assert "/etc/myapp.json" in output.out 61 | 62 | 63 | # This doubles as a Django settings file for the tests 64 | SECRET_KEY = "abc" 65 | -------------------------------------------------------------------------------- /tests/test_file_helpers.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import pytest 4 | 5 | from goodconf import _find_file, _load_config 6 | 7 | 8 | def test_json(tmpdir): 9 | conf = tmpdir.join("conf.json") 10 | conf.write('{"a": "b", "c": 3}') 11 | assert _load_config(str(conf)) == {"a": "b", "c": 3} 12 | 13 | 14 | def test_load_toml(tmpdir): 15 | if sys.version_info < (3, 11): 16 | pytest.importorskip("tomlkit") 17 | conf = tmpdir.join("conf.toml") 18 | conf.write('a = "b"\nc = 3') 19 | assert _load_config(str(conf)) == {"a": "b", "c": 3} 20 | 21 | 22 | def test_load_empty_toml(tmpdir): 23 | if sys.version_info < (3, 11): 24 | pytest.importorskip("tomlkit") 25 | conf = tmpdir.join("conf.toml") 26 | conf.write("") 27 | assert _load_config(str(conf)) == {} 28 | 29 | 30 | def test_yaml(tmpdir): 31 | pytest.importorskip("ruamel.yaml") 32 | conf = tmpdir.join("conf.yaml") 33 | conf.write("a: b\nc: 3") 34 | assert _load_config(str(conf)) == {"a": "b", "c": 3} 35 | 36 | 37 | def test_load_empty_yaml(tmpdir): 38 | pytest.importorskip("ruamel.yaml") 39 | conf = tmpdir.join("conf.yaml") 40 | conf.write("") 41 | assert _load_config(str(conf)) == {} 42 | 43 | 44 | def test_missing(tmpdir): 45 | conf = tmpdir.join("test.yml") 46 | assert _find_file(str(conf), require=False) is None 47 | 48 | 49 | def test_missing_strict(tmpdir): 50 | conf = tmpdir.join("test.yml") 51 | with pytest.raises(FileNotFoundError): 52 | _find_file(str(conf)) 53 | 54 | 55 | def test_abspath(tmpdir): 56 | conf = tmpdir.join("test.yml") 57 | conf.write("") 58 | path = _find_file(str(conf)) 59 | assert path == str(conf) 60 | 61 | 62 | def test_relative(tmpdir): 63 | conf = tmpdir.join("test.yml") 64 | conf.write("") 65 | os.chdir(conf.dirname) 66 | assert _find_file("test.yml") == str(conf) 67 | -------------------------------------------------------------------------------- /tests/test_files.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from goodconf import GoodConf 4 | 5 | from .utils import env_var 6 | 7 | 8 | def test_conf_env_var(mocker, tmpdir): 9 | mocked_load_config = mocker.patch("goodconf._load_config") 10 | path = tmpdir.join("myapp.json") 11 | path.write("") 12 | 13 | class G(GoodConf): 14 | model_config = {"file_env_var": "CONF"} 15 | 16 | with env_var("CONF", str(path)): 17 | g = G() 18 | g.load() 19 | mocked_load_config.assert_called_once_with(str(path)) 20 | 21 | 22 | def test_conflict(tmpdir): 23 | path = tmpdir.join("myapp.json") 24 | path.write(json.dumps({"A": 1, "B": 2})) 25 | 26 | class G(GoodConf): 27 | A: int 28 | B: int 29 | 30 | model_config = {"default_files": [path]} 31 | 32 | with env_var("A", "3"): 33 | g = G() 34 | g.load() 35 | assert g.A == 3 36 | assert g.B == 2 37 | 38 | 39 | def test_all_env_vars(mocker): 40 | mocked_set_values = mocker.patch("goodconf.BaseSettings.__init__") 41 | mocked_load_config = mocker.patch("goodconf._load_config") 42 | 43 | class G(GoodConf): 44 | pass 45 | 46 | g = G() 47 | g.load() 48 | mocked_set_values.assert_called_once_with() 49 | mocked_load_config.assert_not_called() 50 | 51 | 52 | def test_provided_file(mocker, tmpdir): 53 | mocked_load_config = mocker.patch("goodconf._load_config") 54 | path = tmpdir.join("myapp.json") 55 | path.write("") 56 | 57 | class G(GoodConf): 58 | pass 59 | 60 | g = G() 61 | g.load(str(path)) 62 | mocked_load_config.assert_called_once_with(str(path)) 63 | 64 | 65 | def test_provided_file_from_init(mocker, tmpdir): 66 | mocked_load_config = mocker.patch("goodconf._load_config") 67 | path = tmpdir.join("myapp.json") 68 | path.write("") 69 | 70 | class G(GoodConf): 71 | pass 72 | 73 | g = G(config_file=str(path)) 74 | g.load() 75 | mocked_load_config.assert_called_once_with(str(path)) 76 | 77 | 78 | def test_default_files(mocker, tmpdir): 79 | mocked_load_config = mocker.patch("goodconf._load_config") 80 | path = tmpdir.join("myapp.json") 81 | path.write("") 82 | bad_path = tmpdir.join("does-not-exist.json") 83 | 84 | class G(GoodConf): 85 | model_config = {"default_files": [str(bad_path), str(path)]} 86 | 87 | g = G() 88 | g.load() 89 | mocked_load_config.assert_called_once_with(str(path)) 90 | -------------------------------------------------------------------------------- /tests/test_goodconf.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import re 4 | from textwrap import dedent 5 | from typing import Optional, List, Literal 6 | 7 | import pytest 8 | from pydantic import ValidationError 9 | from pydantic.fields import FieldInfo 10 | 11 | from goodconf import Field, GoodConf, FileConfigSettingsSource 12 | from tests.utils import env_var 13 | 14 | 15 | def test_initial(): 16 | class TestConf(GoodConf): 17 | a: bool = Field(initial=lambda: True) 18 | b: bool = Field(default=False) 19 | 20 | initial = TestConf.get_initial() 21 | assert len(initial) == 2 22 | assert initial["a"] is True 23 | assert initial["b"] is False 24 | 25 | 26 | def test_dump_json(): 27 | class TestConf(GoodConf): 28 | a: bool = Field(initial=lambda: True) 29 | 30 | assert TestConf.generate_json() == '{\n "a": true\n}' 31 | assert TestConf.generate_json(not_a_value=True) == '{\n "a": true\n}' 32 | assert TestConf.generate_json(a=False) == '{\n "a": false\n}' 33 | 34 | 35 | def test_dump_toml(): 36 | pytest.importorskip("tomlkit") 37 | 38 | class TestConf(GoodConf): 39 | a: bool = False 40 | b: str = "Happy" 41 | 42 | output = TestConf.generate_toml() 43 | assert "a = false" in output 44 | assert 'b = "Happy"' in output 45 | 46 | class TestConf(GoodConf): 47 | "Configuration for My App" 48 | 49 | a: str = Field(description="this is a") 50 | b: str 51 | 52 | output = TestConf.generate_toml() 53 | 54 | assert "# Configuration for My App\n" in output 55 | assert 'a = "" # this is a' in output 56 | assert 'b = ""' in output 57 | 58 | 59 | def test_dump_yaml(): 60 | pytest.importorskip("ruamel.yaml") 61 | 62 | class TestConf(GoodConf): 63 | "Configuration for My App" 64 | 65 | a: str = Field(description="this is a") 66 | b: str 67 | 68 | output = TestConf.generate_yaml() 69 | output = re.sub(r" +\n", "\n", output) 70 | assert "\n# Configuration for My App\n" in output 71 | assert ( 72 | dedent( 73 | """\ 74 | # this is a 75 | a: '' 76 | """ 77 | ) 78 | in output 79 | ) 80 | assert "b: ''" in output 81 | 82 | output_override = TestConf.generate_yaml(b="yes") 83 | assert "a: ''" in output_override 84 | assert "b: yes" in output_override 85 | 86 | 87 | def test_dump_yaml_no_docstring(): 88 | pytest.importorskip("ruamel.yaml") 89 | 90 | class TestConf(GoodConf): 91 | a: str = Field(description="this is a") 92 | 93 | output = TestConf.generate_yaml() 94 | output = re.sub(r" +\n", "\n", output) 95 | assert output == dedent( 96 | """ 97 | # this is a 98 | a: '' 99 | """ 100 | ) 101 | 102 | 103 | def test_dump_yaml_none(): 104 | pytest.importorskip("ruamel.yaml") 105 | 106 | class TestConf(GoodConf): 107 | a: Optional[str] 108 | 109 | output = TestConf.generate_yaml() 110 | assert output.strip() == "a: ~" 111 | 112 | 113 | def test_generate_markdown(): 114 | help_ = "this is a" 115 | 116 | class TestConf(GoodConf): 117 | "Configuration for My App" 118 | 119 | a: int = Field(description=help_, default=None) 120 | b: int = Field(description=help_, default=5) 121 | c: str 122 | 123 | mkdn = TestConf.generate_markdown() 124 | # Not sure on final format, just do some basic smoke tests 125 | assert TestConf.__doc__ in mkdn 126 | assert help_ in mkdn 127 | 128 | 129 | def test_generate_markdown_no_docstring(): 130 | help_ = "this is a" 131 | 132 | class TestConf(GoodConf): 133 | a: int = Field(description=help_, default=5) 134 | b: str 135 | 136 | mkdn = TestConf.generate_markdown() 137 | # Not sure on final format, just do some basic smoke tests 138 | assert f" * description: {help_}" in mkdn.splitlines() 139 | 140 | 141 | def test_generate_markdown_default_false(): 142 | class TestConf(GoodConf): 143 | a: bool = Field(default=False) 144 | 145 | lines = TestConf.generate_markdown().splitlines() 146 | assert " * type: `bool`" in lines 147 | assert " * default: `False`" in lines 148 | 149 | 150 | def test_generate_markdown_types(): 151 | class TestConf(GoodConf): 152 | a: Literal["a", "b"] = Field(default="a") 153 | b: list[str] = Field() 154 | c: None 155 | 156 | lines = TestConf.generate_markdown().splitlines() 157 | assert " * type: `Literal['a', 'b']`" in lines 158 | assert " * type: `list[str]`" in lines 159 | assert "default: `PydanticUndefined`" not in str(lines) 160 | 161 | 162 | def test_generate_markdown_required(): 163 | class TestConf(GoodConf): 164 | a: str 165 | 166 | lines = TestConf.generate_markdown().splitlines() 167 | assert "* **a** _REQUIRED_" in lines 168 | 169 | 170 | def test_undefined(): 171 | c = GoodConf() 172 | with pytest.raises(AttributeError): 173 | c.UNDEFINED 174 | 175 | 176 | def test_required_missing(): 177 | class TestConf(GoodConf): 178 | a: str = Field() 179 | 180 | c = TestConf() 181 | 182 | with pytest.raises(ValidationError): 183 | c.load() 184 | 185 | with pytest.raises(ValidationError): 186 | TestConf(load=True) 187 | 188 | 189 | def test_default_values_are_used(monkeypatch): 190 | """ 191 | Covers regression in: https://github.com/lincolnloop/goodconf/pull/51 192 | 193 | Requires more than one defined field to reproduce. 194 | """ 195 | monkeypatch.delenv("a", raising=False) 196 | monkeypatch.setenv("b", "value_from_env") 197 | monkeypatch.delenv("c", raising=False) 198 | 199 | class TestConf(GoodConf): 200 | a: str = Field(default="default_for_a") 201 | b: str = Field(initial=lambda: "1234") 202 | c: str = Field("default_for_c") 203 | 204 | c = TestConf() 205 | c.load() 206 | 207 | assert c.a == "default_for_a" 208 | assert c.b == "value_from_env" 209 | assert c.c == "default_for_c" 210 | 211 | 212 | def test_set_on_init(): 213 | class TestConf(GoodConf): 214 | a: str = Field() 215 | 216 | val = "test" 217 | c = TestConf(a=val) 218 | assert c.a == val 219 | 220 | 221 | def test_env_prefix(): 222 | class TestConf(GoodConf): 223 | a: bool = False 224 | 225 | model_config = {"env_prefix": "PREFIX_"} 226 | 227 | with env_var("PREFIX_A", "True"): 228 | c = TestConf(load=True) 229 | 230 | assert c.a 231 | 232 | 233 | def test_precedence(tmpdir): 234 | path = tmpdir.join("myapp.json") 235 | path.write(json.dumps({"init": "file", "env": "file", "file": "file"})) 236 | 237 | class TestConf(GoodConf, default_files=[path]): 238 | init: str = "" 239 | env: str = "" 240 | file: str = "" 241 | 242 | os.environ["INIT"] = "env" 243 | os.environ["ENV"] = "env" 244 | try: 245 | c = TestConf(init="init") 246 | assert c.init == "init" 247 | assert c.env == "env" 248 | assert c.file == "file" 249 | finally: 250 | del os.environ["INIT"] 251 | del os.environ["ENV"] 252 | 253 | 254 | def test_fileconfigsettingssource_repr(): 255 | class SettingsClass: 256 | model_config = {} 257 | 258 | fileconfigsettingssource = FileConfigSettingsSource(SettingsClass) 259 | 260 | assert repr(fileconfigsettingssource) == "FileConfigSettingsSource()" 261 | 262 | field = FieldInfo(title="testfield") 263 | 264 | 265 | def test_fileconfigsettingssource_get_field_value(): 266 | class SettingsClass: 267 | model_config = {} 268 | 269 | fileconfigsettingssource = FileConfigSettingsSource(SettingsClass) 270 | field = FieldInfo(title="testfield") 271 | assert fileconfigsettingssource.get_field_value(field, "testfield") == ( 272 | None, 273 | "", 274 | False, 275 | ) 276 | assert fileconfigsettingssource.get_field_value(None, "a") == (None, "", False) 277 | -------------------------------------------------------------------------------- /tests/test_initial.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import pytest 4 | 5 | from goodconf import Field, GoodConf, initial_for_field 6 | 7 | from .utils import KEY 8 | 9 | 10 | def test_initial(): 11 | class C(GoodConf): 12 | f: str = Field(initial=lambda: "x") 13 | 14 | assert initial_for_field(KEY, C.model_fields["f"]) == "x" 15 | 16 | 17 | def test_initial_bad(): 18 | class C(GoodConf): 19 | f: str = Field(initial="x") 20 | 21 | with pytest.raises(ValueError): 22 | initial_for_field(KEY, C.model_fields["f"]) 23 | 24 | 25 | def test_initial_default(): 26 | class C(GoodConf): 27 | f: str = Field("x") 28 | 29 | assert initial_for_field(KEY, C.model_fields["f"]) == "x" 30 | 31 | 32 | def test_initial_default_factory(): 33 | class C(GoodConf): 34 | f: str = Field(default_factory=lambda: "y") 35 | 36 | assert initial_for_field(KEY, C.model_fields["f"]) == "y" 37 | 38 | 39 | def test_no_initial(): 40 | class C(GoodConf): 41 | f: str = Field() 42 | 43 | assert initial_for_field(KEY, C.model_fields["f"]) == "" 44 | 45 | 46 | def test_default_initial(): 47 | """Can get initial when Field is not used""" 48 | 49 | class G(GoodConf): 50 | a: str = "test" 51 | 52 | initial = G().get_initial() 53 | assert initial["a"] == "test" 54 | 55 | 56 | def test_optional_initial(): 57 | class G(GoodConf): 58 | a: Optional[str] 59 | 60 | initial = G().get_initial() 61 | assert initial["a"] is None 62 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | from contextlib import contextmanager 3 | 4 | KEY = "GOODCONF_TEST" 5 | 6 | 7 | @contextmanager 8 | def env_var(key, value): 9 | os.environ[key] = value 10 | try: 11 | yield 12 | finally: 13 | del os.environ[key] 14 | --------------------------------------------------------------------------------