├── .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 |
--------------------------------------------------------------------------------