Repository: lincolnloop/goodconf
Branch: main
Commit: 235cc2882bfb
Files: 22
Total size: 48.7 KB
Directory structure:
gitextract_x6obglxq/
├── .editorconfig
├── .github/
│ └── workflows/
│ ├── build.yml
│ └── test.yml
├── .gitignore
├── .pre-commit-config.yaml
├── .yamllint.yml
├── 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
================================================
FILE CONTENTS
================================================
================================================
FILE: .editorconfig
================================================
# https://editorconfig.org/
root = true
[*]
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
charset = utf-8
max_line_length = 88
[*.py]
indent_size = 4
[*.md]
indent_size = 4
[Makefile]
indent_style = tab
================================================
FILE: .github/workflows/build.yml
================================================
---
name: Publish to PyPI
"on":
push:
branches: [main, test-publish]
tags: ["*"]
pull_request:
jobs:
build:
name: Build distribution
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install hatch
run: pip install hatch
- name: Build a binary wheel and a source tarball
run: hatch build
- name: Store the distribution packages
uses: actions/upload-artifact@v4
with:
name: python-package-distributions
path: dist/
pypi-publish:
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
needs: [build]
name: Upload release to PyPI
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/goodconf
permissions:
id-token: write
steps:
- name: Download all the dists
uses: actions/download-artifact@v4
with:
name: python-package-distributions
path: dist/
- name: Publish distribution to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
github-release:
name: >-
Sign the Python 🐍 distribution 📦 with Sigstore
and upload them to GitHub Release
needs:
- pypi-publish
runs-on: ubuntu-latest
permissions:
contents: write # IMPORTANT: mandatory for making GitHub Releases
id-token: write # IMPORTANT: mandatory for sigstore
steps:
- name: Download all the dists
uses: actions/download-artifact@v4
with:
name: python-package-distributions
path: dist/
- name: Sign the dists with Sigstore
uses: sigstore/gh-action-sigstore-python@v3.0.0
with:
inputs: >-
./dist/*.tar.gz
./dist/*.whl
- name: Create GitHub Release
env:
GITHUB_TOKEN: ${{ github.token }}
run: >-
gh release create
'${{ github.ref_name }}'
--repo '${{ github.repository }}'
--generate-notes
- name: Upload artifact signatures to GitHub Release
env:
GITHUB_TOKEN: ${{ github.token }}
# Upload to GitHub Release using the `gh` CLI.
# `dist/` contains the built packages, and the
# sigstore-produced signatures and certificates.
run: >-
gh release upload
'${{ github.ref_name }}' dist/**
--repo '${{ github.repository }}'
================================================
FILE: .github/workflows/test.yml
================================================
---
name: test
"on":
pull_request:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read # to fetch code (actions/checkout)
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python: ["3.10", "3.11", "3.12", "3.13", "3.14"]
env:
PYTHON: ${{ matrix.python }}
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
- name: Set up Python ${{ matrix.python }}
run: uv python install ${{ matrix.python }}
- name: Install dependencies
run: uv sync --group dev --python ${{ matrix.python }}
- name: Test
run: uv run pytest --cov-report=xml --cov-report=term --junitxml=junit.xml
- name: Upload coverage to Codecov
if: ${{ (success() || failure()) && matrix.python == '3.14' }}
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: true
- name: Upload test results to Codecov
if: ${{ (success() || failure()) && matrix.python == '3.14' }}
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
report_type: test_results
fail_ci_if_error: true
================================================
FILE: .gitignore
================================================
*.egg-info
.coverage
.pytest_cache
/build
/dist
/goodconf/_version.py
__pycache__
env
htmlcov
junit.xml
uv.lock
venv
================================================
FILE: .pre-commit-config.yaml
================================================
---
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: check-yaml
- id: end-of-file-fixer
- id: mixed-line-ending
- id: trailing-whitespace
- id: file-contents-sorter
files: (.dockerignore|.gitignore)
- repo: https://github.com/adrienverge/yamllint.git
rev: v1.38.0
hooks:
- id: yamllint
args: [--strict]
- repo: https://github.com/python-jsonschema/check-jsonschema
rev: 0.37.0
hooks:
- id: check-github-workflows
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.5
hooks:
- id: ruff-check
args: [--fix]
- id: ruff-format
- repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks
rev: v2.16.0
hooks:
- id: pretty-format-toml
args: [--autofix, --trailing-commas]
types: [toml]
files: \.toml
- repo: https://github.com/ninoseki/uv-sort
rev: v0.7.0
hooks:
- id: uv-sort
- repo: local
hooks:
- id: prettier
name: prettier
language: node
entry: prettier
args: [--write, --ignore-unknown]
additional_dependencies: [prettier@3]
exclude_types: [python]
pass_filenames: true
- repo: local
hooks:
- id: mypy
name: mypy
language: python
entry: mypy goodconf
types: [python]
require_serial: true
pass_filenames: false
additional_dependencies: [mypy]
================================================
FILE: .yamllint.yml
================================================
---
extends: default
rules:
comments:
min-spaces-from-content: 1
line-length:
max: 88
================================================
FILE: CHANGES.rst
================================================
==========
Change Log
==========
7.0.0 (3 March 2026)
========================
- **Backwards Incompatible Release**
- Removed official support for Python 3.9
- Added official support for Python 3.13 and 3.14
- Updated pydantic-settings requirement to >=2.13 (was >=2.4)
6.0.0 (8 October 2024)
========================
- **Backwards Incompatible Release**
- Removed the ``_config_file`` attribute from ``GoodConf``.
If you previously set this attribute, you are no longer be able to do so.
- Support reading TOML files via ``tomllib`` on Python 3.11+
- Update Markdown generation, so that output matches v4 output
5.0.0 (13 August 2024)
========================
- **Backwards Incompatible Release**
- Removed official support for Python 3.8
- upgraded to pydantic2
To upgrade from goodconf v4 to goodconf v5:
- If subclassing ``GoodConf``, replace uses of ``class Config`` with ``model_config``.
For example goodconf v4 code like this:
.. code:: python
from goodconf import GoodConf
class AppConfig(GoodConf):
"Configuration for My App"
DATABASE_URL: PostgresDsn = "postgres://localhost:5432/mydb"
class Config:
default_files = ["/etc/myproject/myproject.yaml", "myproject.yaml"]
config = AppConfig()
should be replaced in goodconf v5 with:
.. code:: python
from goodconf import GoodConf
class AppConfig(GoodConf):
"Configuration for My App"
DATABASE_URL: PostgresDsn = "postgres://localhost:5432/mydb"
model_config = {"default_files": ["/etc/myproject/myproject.yaml", "myproject.yaml"]}
config = AppConfig()
4.0.3 (13 August 2024)
========================
- Release from GitHub Actions
4.0.2 (11 February 2024)
========================
- Another markdown output fix
- Fix for markdown generation generation on Python 3.8 & 3.9
4.0.1 (10 February 2024)
========================
- Fix trailing whitespace in markdown output
4.0.0 (10 February 2024)
========================
- Removed errant print statement
- Removed official support for Python 3.7
- Added support for Python 3.12
3.1.0 (10 February 2024)
========================
- Fixed type display in Markdown generation
- Changed markdown output format (trailing spaces were problematic).
3.0.1 (30 June 2023)
====================
- pin to pydantic < 2 due to breaking changes in 2.0
3.0.0 (17 January 2023)
==================
- TOML files are now supported as configuration source
- Python 3.11 and 3.10 are now officially supported
- Python 3.6 is no longer officially supported
- Requires Pydantic 1.7+
- Variables can now be set during class initialization
2.0.1 (15 June 2021)
====================
- Change to newer syntax for safe loading yaml
2.0.0 (13 May 2021)
===================
- **Backwards Incompatible Release**
Internals replaced with `pydantic `_. Users can either pin to ``1.0.0`` or update their code as follows:
- Replace ``goodconf.Value`` with ``goodconf.Field``.
- Replace ``help`` keyword argument with ``description`` in ``Field`` (previously ``Value``).
- Remove ``cast_as`` keyword argument from ``Field`` (previously ``Value``). Standard Python type annotations are now used.
- Move ``file_env_var`` and ``default_files`` keyword arguments used in class initialization to a sub-class named ``Config``
Given a version ``1`` class that looks like this:
.. code:: python
from goodconf import GoodConf, Value
class AppConfig(GoodConf):
"Configuration for My App"
DEBUG = Value(default=False, help="Toggle debugging.")
MAX_REQUESTS = Value(cast_as=int)
config = AppConfig(default_files=["config.yml"])
A class updated for version `2` would be:
.. code:: python
from goodconf import GoodConf, Field
class AppConfig(GoodConf):
"Configuration for My App"
DEBUG: bool = Field(default=False, description="Toggle debugging.")
MAX_REQUESTS: int
class Config:
default_files=["config.yml"]
config = AppConfig()
2.0b3 (15 April 2021)
=====================
- Environment variables take precedence over configuration files in the event of a conflict
2.0b2 (12 March 2021)
=====================
- Use null value for initial if allowed
- Store the config file parsed as ``GoodConf.Config._config_file``
2.0b1 (11 March 2021)
=====================
- Backwards Incompatible: Migrated backend to ``pydantic``.
- ``Value`` is replaced by the `Field function `__.
- ``help`` keyword arg is now ``description``
- ``GoodConf`` is now backed by `BaseSettings `__
Instead of passing keyword args when instantiating the class, they are now defined on a ``Config`` class on the object
1.0.0 (18 July 2018)
====================
- Allow overriding of values in the generate_* methods
- Python 3.7 supported
0.9.1 (10 April 2018)
=====================
- Explicit ``load`` method
- ``django_manage`` method helper on ``GoodConf``
- Fixed a few minor bugs
0.9.0 (8 April 2018)
====================
- Use a declarative class to define GoodConf's values.
- Change description to a docstring of the class.
- Remove the redundant ``required`` argument from ``Values``. To make
an value optional, give it a default.
- Changed implicit loading to happen during instanciation rather than first
access. Instanciate with ``load=False`` to avoid loading config initially.
0.8.3 (28 Mar 2018)
===================
- Implicitly load config if not loaded by first access.
0.8.2 (28 Mar 2018)
===================
- ``-c`` is used by Django's ``collectstatic``. Using ``-C`` instead.
0.8.1 (28 Mar 2018)
===================
- Adds ``goodconf.contrib.argparse`` to add a config argument to an existing
parser.
0.8.0 (27 Mar 2018)
===================
- Major refactor from ``file-or-env`` to ``goodconf``
0.6.1 (16 Mar 2018)
================
- Fixed packaging issue.
0.6.0 (16 Mar 2018)
================
- Fixes bug in stack traversal to find calling file.
0.5.1 (15 March 2018)
==================
- Initial release
================================================
FILE: LICENSE
================================================
Copyright (c) 2018 Lincoln Loop
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
================================================
FILE: README.rst
================================================
Goodconf
========
.. image:: https://github.com/lincolnloop/goodconf/actions/workflows/test.yml/badge.svg?branch=main&event=push
:target: https://github.com/lincolnloop/goodconf/actions/workflows/test.yml?query=branch%3Amain+event%3Apush
.. image:: https://results.pre-commit.ci/badge/github/lincolnloop/goodconf/main.svg
:target: https://results.pre-commit.ci/latest/github/lincolnloop/goodconf/main
:alt: pre-commit.ci status
.. image:: https://img.shields.io/codecov/c/github/lincolnloop/goodconf.svg
:target: https://codecov.io/gh/lincolnloop/goodconf
.. image:: https://img.shields.io/pypi/v/goodconf.svg
:target: https://pypi.python.org/pypi/goodconf
.. image:: https://img.shields.io/pypi/pyversions/goodconf.svg
:target: https://pypi.python.org/pypi/goodconf
A thin wrapper over `Pydantic's settings management `__.
Allows you to define configuration variables and load them from environment or JSON/YAML/TOML
file. Also generates initial configuration files and documentation for your
defined configuration.
Installation
------------
``pip install goodconf`` or ``pip install goodconf[yaml]`` /
``pip install goodconf[toml]`` if parsing/generating YAML/TOML
files is required. When running on Python 3.11+ the ``[toml]``
extra is only required for generating TOML files as parsing
is supported natively.
Quick Start
-----------
Let's use configurable Django settings as an example.
First, create a ``conf.py`` file in your project's directory, next to
``settings.py``:
.. code:: python
import base64
import os
from goodconf import GoodConf, Field
from pydantic import PostgresDsn
class AppConfig(GoodConf):
"Configuration for My App"
DEBUG: bool
DATABASE_URL: PostgresDsn = "postgres://localhost:5432/mydb"
SECRET_KEY: str = Field(
initial=lambda: base64.b64encode(os.urandom(60)).decode(),
description="Used for cryptographic signing. "
"https://docs.djangoproject.com/en/2.0/ref/settings/#secret-key")
model_config = {"default_files": ["/etc/myproject/myproject.yaml", "myproject.yaml"]}
config = AppConfig()
Next, use the config in your ``settings.py`` file:
.. code:: python
import dj_database_url
from .conf import config
config.load()
DEBUG = config.DEBUG
SECRET_KEY = config.SECRET_KEY
DATABASES = {"default": dj_database_url.parse(config.DATABASE_URL)}
In your initial developer installation instructions, give some advice such as:
.. code:: shell
python -c "import myproject; print(myproject.conf.config.generate_yaml(DEBUG=True))" > myproject.yaml
Better yet, make it a function and `entry point `__ so you can install
your project and run something like ``generate-config > myproject.yaml``.
Usage
-----
``GoodConf``
^^^^^^^^^^^^
Your subclassed ``GoodConf`` object can include a ``model_config`` dictionary with the following
attributes:
``file_env_var``
The name of an environment variable which can be used for
the name of the configuration file to load.
``default_files``
If no file is passed to the ``load`` method, try to load a
configuration from these files in order.
It also has one method:
``load``
Trigger the load method during instantiation. Defaults to False.
Use plain-text docstring for use as a header when generating a configuration
file.
Environment variables always take precedence over variables in the configuration files.
See Pydantic's docs for examples of loading:
* `Dotenv (.env) files `_
* `Docker secrets `_
Fields
^^^^^^
Declare configuration values by subclassing ``GoodConf`` and defining class
attributes which are standard Python type definitions or Pydantic ``FieldInfo``
instances generated by the ``Field`` function.
Goodconf can use one extra argument provided to the ``Field`` to define an function
which can generate an initial value for the field:
``initial``
Callable to use for initial value when generating a config
Django Usage
------------
A helper is provided which monkey-patches Django's management commands to
accept a ``--config`` argument. Replace your ``manage.py`` with the following:
.. code:: python
# Define your GoodConf in `myproject/conf.py`
from myproject.conf import config
if __name__ == '__main__':
config.django_manage()
Why?
----
I took inspiration from `logan `__ (used by
Sentry) and `derpconf `__ (used by
Thumbor). Both, however used Python files for configuration. I wanted a safer
format and one that was easier to serialize data into from a configuration
management system.
Environment Variables
^^^^^^^^^^^^^^^^^^^^^
I don't like working with environment variables. First, there are potential
security issues:
1. Accidental leaks via logging or error reporting services.
2. Child process inheritance (see `ImageTragick `__
for an idea why this could be bad).
Second, in practice on deployment environments, environment variables end up
getting written to a number of files (cron, bash profile, service definitions,
web server config, etc.). Not only is it cumbersome, but also increases the
possibility of leaks via incorrect file permissions.
I prefer a single structured file which is explicitly read by the application.
I also want it to be easy to run my applications on services like Heroku
where environment variables are the preferred configuration method.
This module let's me do things the way I prefer in environments I control, but
still run them with environment variables on environments I don't control with
minimal fuss.
Contribute
----------
Install dependencies.
.. code:: shell
uv sync
Run tests
.. code:: shell
uv run pytest
Releases are done with GitHub Actions whenever a new tag is created. For more information,
see `<./.github/workflows/build.yml>`_
================================================
FILE: goodconf/__init__.py
================================================
"""
Transparently load variables from environment or JSON/YAML file.
"""
import errno
import json
import logging
import os
import sys
from functools import partial
from io import StringIO
from types import GenericAlias
from typing import Any, cast, get_args
from pydantic._internal._config import config_keys
from pydantic.fields import Field as PydanticField
from pydantic.fields import FieldInfo, PydanticUndefined
from pydantic.main import _object_setattr
from pydantic_settings import (
BaseSettings,
PydanticBaseSettingsSource,
SettingsConfigDict,
)
__all__ = ["Field", "GoodConf", "GoodConfConfigDict"]
log = logging.getLogger(__name__)
def Field(
*args,
initial=None,
json_schema_extra=None,
**kwargs,
):
if initial:
json_schema_extra = json_schema_extra or {}
json_schema_extra["initial"] = initial
return PydanticField(*args, json_schema_extra=json_schema_extra, **kwargs)
class GoodConfConfigDict(SettingsConfigDict):
# configuration file to load
file_env_var: str | None
# if no file is given, try to load a configuration from these files in order
default_files: list[str] | None
# Note: code from pydantic-settings/pydantic_settings/main.py:
# Extend `config_keys` by pydantic settings config keys to
# support setting config through class kwargs.
# Pydantic uses `config_keys` in `pydantic._internal._config.ConfigWrapper.for_model`
# to extract config keys from model kwargs, So, by adding pydantic settings keys to
# `config_keys`, they will be considered as valid config keys and will be collected
# by Pydantic.
config_keys |= set(GoodConfConfigDict.__annotations__.keys())
def _load_config(path: str) -> dict[str, Any]:
"""
Given a file path, parse it based on its extension (YAML, TOML or JSON)
and return the values as a Python dictionary. JSON is the default if an
extension can't be determined.
"""
__, ext = os.path.splitext(path)
if ext in [".yaml", ".yml"]:
import ruamel.yaml
yaml = ruamel.yaml.YAML(typ="safe", pure=True)
loader = yaml.load
elif ext == ".toml":
try:
import tomllib
def load(stream):
return tomllib.loads(f.read())
except ImportError: # Fallback for Python < 3.11
import tomlkit
def load(stream):
return tomlkit.load(f).unwrap()
loader = load
else:
loader = json.load
with open(path) as f:
config = loader(f)
return config or {}
def _find_file(filename: str, require: bool = True) -> str | None:
if not os.path.exists(filename):
if not require:
return None
raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), filename)
return os.path.abspath(filename)
def _fieldinfo_to_str(field_info: FieldInfo) -> str:
"""
Return the string representation of a pydantic.fields.FieldInfo.
"""
if isinstance(field_info.annotation, type) and not isinstance(
field_info.annotation, GenericAlias
):
# For annotation like , we use its name ("int").
field_type = field_info.annotation.__name__
elif str(field_info.annotation).startswith("typing."):
# For annotation like typing.Literal['a', 'b'], we use
# its string representation, but without "typing." ("Literal['a', 'b']").
field_type = str(field_info.annotation)[len("typing.") :]
else:
# For annotation like list[str], we use its string
# representation ("list[str]").
field_type = field_info.annotation
return field_type
def initial_for_field(name: str, field_info: FieldInfo) -> Any:
try:
json_schema_extra = field_info.json_schema_extra or {}
if not callable(json_schema_extra["initial"]):
raise ValueError(f"Initial value for `{name}` must be a callable.")
return field_info.json_schema_extra["initial"]()
except KeyError:
if (
field_info.default is not PydanticUndefined
and field_info.default is not ...
):
return field_info.default
if field_info.default_factory is not None:
return field_info.default_factory()
if type(None) in get_args(field_info.annotation):
return None
return ""
class FileConfigSettingsSource(PydanticBaseSettingsSource):
"""
Source class for loading values provided during settings class initialization.
"""
def __init__(self, settings_cls: type[BaseSettings]):
super().__init__(settings_cls)
def get_field_value(
self, field: FieldInfo, field_name: str
) -> tuple[Any, str, bool]:
# Nothing to do here. Only implement the return statement to make mypy happy
return None, "", False
def __call__(self) -> dict[str, Any]:
settings = cast("GoodConf", self.settings_cls)
selected_config_file = None
if cfg_file := self.current_state.get("_config_file"):
selected_config_file = cfg_file
elif (file_env_var := settings.model_config.get("file_env_var")) and (
cfg_file := os.environ.get(file_env_var)
):
selected_config_file = _find_file(cfg_file)
else:
for filename in settings.model_config.get("default_files") or []:
selected_config_file = _find_file(filename, require=False)
if selected_config_file:
break
if selected_config_file:
values = _load_config(selected_config_file)
log.info("Loading config from %s", selected_config_file)
else:
values = {}
log.info("No config file specified. Loading with environment variables.")
return values
def __repr__(self) -> str:
return "FileConfigSettingsSource()"
class GoodConf(BaseSettings):
def __init__(
self, load: bool = False, config_file: str | None = None, **kwargs
) -> None:
"""
:param load: load config file on instantiation [default: False].
A docstring defined on the class should be a plain-text description
used as a header when generating a configuration file.
"""
if kwargs or load: # Emulate Pydantic behavior, load immediately
self._load(_init_config_file=config_file, **kwargs)
elif config_file:
_object_setattr(
self, "_load", partial(self._load, _init_config_file=config_file)
)
@classmethod
def settings_customise_sources(
cls,
settings_cls: type[BaseSettings],
init_settings: PydanticBaseSettingsSource,
env_settings: PydanticBaseSettingsSource,
dotenv_settings: PydanticBaseSettingsSource,
file_secret_settings: PydanticBaseSettingsSource,
) -> tuple[PydanticBaseSettingsSource, ...]:
"""Load environment variables before init"""
return (
init_settings,
env_settings,
dotenv_settings,
FileConfigSettingsSource(settings_cls),
file_secret_settings,
)
model_config = GoodConfConfigDict()
@classmethod
def _settings_build_values(
cls,
sources: tuple[PydanticBaseSettingsSource, ...],
init_kwargs: dict[str, Any],
) -> dict[str, Any]:
state = super()._settings_build_values(
sources,
init_kwargs,
)
state.pop("_config_file", None)
return state
def _load(
self,
_config_file: str | None = None,
_init_config_file: str | None = None,
**kwargs,
):
if config_file := _config_file or _init_config_file:
kwargs["_config_file"] = config_file
super().__init__(**kwargs)
def load(self, filename: str | None = None) -> None:
self._load(_config_file=filename)
@classmethod
def get_initial(cls, **override) -> dict:
return {
k: override.get(k, initial_for_field(k, v))
for k, v in cls.model_fields.items()
}
@classmethod
def generate_yaml(cls, **override) -> str:
"""
Dumps initial config in YAML
"""
import ruamel.yaml
yaml = ruamel.yaml.YAML()
yaml.representer.add_representer(
type(None),
lambda self, d: self.represent_scalar("tag:yaml.org,2002:null", "~"), # noqa: ARG005
)
yaml_str = StringIO()
yaml.dump(cls.get_initial(**override), stream=yaml_str)
yaml_str.seek(0)
dict_from_yaml = yaml.load(yaml_str)
if cls.__doc__:
dict_from_yaml.yaml_set_start_comment("\n" + cls.__doc__ + "\n\n")
for k in dict_from_yaml:
if cls.model_fields[k].description:
description = cast("str", cls.model_fields[k].description)
dict_from_yaml.yaml_set_comment_before_after_key(
k, before="\n" + description
)
yaml_str = StringIO()
yaml.dump(dict_from_yaml, yaml_str)
yaml_str.seek(0)
return yaml_str.read()
@classmethod
def generate_json(cls, **override) -> str:
"""
Dumps initial config in JSON
"""
return json.dumps(cls.get_initial(**override), indent=2)
@classmethod
def generate_toml(cls, **override) -> str:
"""
Dumps initial config in TOML
"""
import tomlkit
from tomlkit.items import Item
toml_str = tomlkit.dumps(cls.get_initial(**override))
dict_from_toml = tomlkit.loads(toml_str)
document = tomlkit.document()
if cls.__doc__:
document.add(tomlkit.comment(cls.__doc__))
for k, v in dict_from_toml.unwrap().items():
document.add(k, v)
if cls.model_fields[k].description:
description = cast("str", cls.model_fields[k].description)
cast("Item", document[k]).comment(description)
return tomlkit.dumps(document)
@classmethod
def generate_markdown(cls) -> str:
"""
Documents values in markdown
"""
lines = []
if cls.__doc__:
lines.extend([f"# {cls.__doc__}", ""])
for k, field_info in cls.model_fields.items():
lines.append(f"* **{k}**")
if field_info.is_required():
lines[-1] = f"{lines[-1]} _REQUIRED_"
if field_info.description:
lines.append(f" * description: {field_info.description}")
# We want to append a line with the field_info type, and sometimes
# field_info.annotation looks the way we want, like 'list[str]', but
# other times, it includes some extra text, like ''.
# Therefore, we have some logic to make the type show up the way we want.
field_type = _fieldinfo_to_str(field_info)
lines.append(f" * type: `{field_type}`")
if field_info.default not in [None, PydanticUndefined]:
lines.append(f" * default: `{field_info.default}`")
return "\n".join(lines)
def django_manage(self, args: list[str] | None = None):
args = args or sys.argv
from .contrib.django import execute_from_command_line_with_config
execute_from_command_line_with_config(self, args)
================================================
FILE: goodconf/contrib/__init__.py
================================================
================================================
FILE: goodconf/contrib/argparse.py
================================================
import argparse
from .. import GoodConf
def argparser_add_argument(parser: argparse.ArgumentParser, config: GoodConf):
"""Adds argument for config to existing argparser"""
help = "Config file."
cfg = config.model_config
if cfg.get("file_env_var"):
help += (
"Can also be configured via the environment variable: "
f"{cfg['file_env_var']}"
)
if cfg.get("default_files"):
files_str = ", ".join(cfg["default_files"])
help += f" Defaults to the first file that exists from [{files_str}]."
parser.add_argument("-C", "--config", metavar="FILE", help=help)
================================================
FILE: goodconf/contrib/django.py
================================================
import argparse
from collections.abc import Generator
from contextlib import contextmanager
from .. import GoodConf
from .argparse import argparser_add_argument
@contextmanager
def load_config_from_cli(
config: GoodConf, argv: list[str]
) -> Generator[list[str], None, None]:
"""Loads config, checking CLI arguments for a config file"""
# Monkey patch Django's command parser
from django.core.management.base import BaseCommand
original_parser = BaseCommand.create_parser
def patched_parser(self, prog_name, subcommand):
parser = original_parser(self, prog_name, subcommand)
argparser_add_argument(parser, config)
return parser
BaseCommand.create_parser = patched_parser
try:
parser = argparse.ArgumentParser(add_help=False)
argparser_add_argument(parser, config)
config_arg, default_args = parser.parse_known_args(argv)
config.load(config_arg.config)
yield default_args
finally:
# Put that create_parser back where it came from or so help me!
BaseCommand.create_parser = original_parser
def execute_from_command_line_with_config(config: GoodConf, argv: list[str]):
"""Load's config then runs Django's execute_from_command_line"""
with load_config_from_cli(config, argv) as args:
from django.core.management import execute_from_command_line
execute_from_command_line(args)
================================================
FILE: goodconf/py.typed
================================================
================================================
FILE: pyproject.toml
================================================
[build-system]
build-backend = "hatchling.build"
requires = ["hatchling", "hatch-vcs"]
[dependency-groups]
dev = [
"django>=3.2.0",
"mypy>=1.19.1",
"pytest-cov==7.0.*",
"pytest-mock==3.15.*",
"pytest==9.0.*",
"ruamel.yaml>=0.17.0",
"tomlkit>=0.11.6",
]
[project]
authors = [
{name = "Peter Baumgartner", email = "brett@python.org"},
]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
]
dependencies = [
"pydantic-settings>=2.13",
"pydantic>=2.7",
]
description = "Load configuration variables from a file or environment"
dynamic = ["version"]
keywords = ["env", "config", "json", "yaml", "toml"]
license = {file = "LICENSE"}
name = "goodconf"
readme = "README.rst"
requires-python = ">=3.10"
[project.optional-dependencies]
toml = ["tomlkit>=0.11.6"]
yaml = ["ruamel.yaml>=0.17.0"]
[project.urls]
changelog = "https://github.com/lincolnloop/goodconf/blob/main/CHANGES.rst"
homepage = "https://github.com/lincolnloop/goodconf/"
[tool.coverage.report]
exclude_lines = [
"if TYPE_CHECKING:",
"pragma: no cover",
"raise NotImplementedError",
]
skip_covered = true
[tool.coverage.run]
branch = true
disable_warnings = [
"module-not-imported",
"module-not-measured",
"no-data-collected",
]
source = ["goodconf", "tests"]
[tool.hatch.build.hooks.vcs]
version-file = "goodconf/_version.py"
[tool.hatch.build.targets.sdist]
exclude = [
"/.github",
]
[tool.hatch.version]
source = "vcs"
[tool.mypy]
ignore_errors = true
[tool.pytest.ini_options]
addopts = ["--cov", "--strict-markers"]
[tool.ruff.lint]
ignore = [
# Permanently suppressed
"A001", # Variable shadowing builtin (intentional: `help` in argparse)
"ANN401", # Dynamically typed expressions (typing.Any) are disallowed in `**kwargs`
"ARG001", # Unused function argument
"ARG002", # Unused method argument
"COM812", # (ruff format) Checks for the absence of trailing commas
"D", # Missing or badly formatted docstrings
"E501", # Let the formatter handle long lines
"FBT", # Flake Boolean Trap (don't use arg=True in functions)
"ISC001", # (ruff format) Checks for implicitly concatenated strings on a single line
"N802", # Function name should be lowercase (Field is a public API name)
"PLC0415", # Import not at top level (intentional: lazy imports for optional deps)
"RUF012", # Mutable class attributes https://github.com/astral-sh/ruff/issues/5243
# Temporary — see docs/plans/ruff-cleanup.md
"ANN", # Missing type annotations (annotation work deferred)
"EM102", # Exception message in f-string
"PTH", # Use pathlib instead of os.path (refactoring deferred)
"TC002", # Move import to TYPE_CHECKING block
"TID252", # Relative imports
"TRY003", # Long exception messages
"TRY004", # Prefer TypeError over AttributeError
]
select = ["ALL"]
[tool.ruff.lint.extend-per-file-ignores]
"test_*.py" = [
"ANN001", # Missing type annotation for function argument
"ANN201", # Missing return type annotation
"PLR2004", # Magic value used in comparison
"S101", # Use of `assert` detected
"S105", # Hardcoded password (test fixtures)
]
================================================
FILE: tests/__init__.py
================================================
================================================
FILE: tests/test_django.py
================================================
import sys
import pytest
from pydantic import ConfigDict
from goodconf import GoodConf
pytest.importorskip("django")
def test_mgmt_command(mocker, tmpdir):
mocked_load_config = mocker.patch("goodconf._load_config")
mocked_dj_execute = mocker.patch("django.core.management.execute_from_command_line")
temp_config = tmpdir.join("config.yml")
temp_config.write("")
class G(GoodConf):
model_config = ConfigDict()
c = G()
dj_args = ["manage.py", "diffsettings", "-v", "2"]
c.django_manage([*dj_args, "-C", str(temp_config)])
mocked_load_config.assert_called_once_with(str(temp_config))
mocked_dj_execute.assert_called_once_with(dj_args)
def test_help(mocker, tmpdir, capsys):
mocker.patch("sys.exit")
mocked_load_config = mocker.patch("goodconf._load_config")
temp_config = tmpdir.join("config.yml")
temp_config.write("")
class G(GoodConf):
model_config = ConfigDict(
file_env_var="MYAPP_CONF",
default_files=["/etc/myapp.json"],
)
c = G()
assert c.model_config.get("file_env_var") == "MYAPP_CONF"
c.django_manage(
[
"manage.py",
"diffsettings",
"-C",
str(temp_config),
"--settings",
__name__,
"-h",
]
)
mocked_load_config.assert_called_once_with(str(temp_config))
output = capsys.readouterr()
if sys.version_info < (3, 13):
assert "-C FILE, --config FILE" in output.out
else:
assert "-C, --config FILE" in output.out
assert "MYAPP_CONF" in output.out
assert "/etc/myapp.json" in output.out
# This doubles as a Django settings file for the tests
SECRET_KEY = "abc"
================================================
FILE: tests/test_file_helpers.py
================================================
import os
import sys
import pytest
from goodconf import _find_file, _load_config
def test_json(tmpdir):
conf = tmpdir.join("conf.json")
conf.write('{"a": "b", "c": 3}')
assert _load_config(str(conf)) == {"a": "b", "c": 3}
def test_load_toml(tmpdir):
if sys.version_info < (3, 11):
pytest.importorskip("tomlkit")
conf = tmpdir.join("conf.toml")
conf.write('a = "b"\nc = 3')
assert _load_config(str(conf)) == {"a": "b", "c": 3}
def test_load_empty_toml(tmpdir):
if sys.version_info < (3, 11):
pytest.importorskip("tomlkit")
conf = tmpdir.join("conf.toml")
conf.write("")
assert _load_config(str(conf)) == {}
def test_yaml(tmpdir):
pytest.importorskip("ruamel.yaml")
conf = tmpdir.join("conf.yaml")
conf.write("a: b\nc: 3")
assert _load_config(str(conf)) == {"a": "b", "c": 3}
def test_load_empty_yaml(tmpdir):
pytest.importorskip("ruamel.yaml")
conf = tmpdir.join("conf.yaml")
conf.write("")
assert _load_config(str(conf)) == {}
def test_missing(tmpdir):
conf = tmpdir.join("test.yml")
assert _find_file(str(conf), require=False) is None
def test_missing_strict(tmpdir):
conf = tmpdir.join("test.yml")
with pytest.raises(FileNotFoundError):
_find_file(str(conf))
def test_abspath(tmpdir):
conf = tmpdir.join("test.yml")
conf.write("")
path = _find_file(str(conf))
assert path == str(conf)
def test_relative(tmpdir):
conf = tmpdir.join("test.yml")
conf.write("")
os.chdir(conf.dirname)
assert _find_file("test.yml") == str(conf)
================================================
FILE: tests/test_files.py
================================================
import json
from goodconf import GoodConf
from .utils import env_var
def test_conf_env_var(mocker, tmpdir):
mocked_load_config = mocker.patch("goodconf._load_config")
path = tmpdir.join("myapp.json")
path.write("")
class G(GoodConf):
model_config = {"file_env_var": "CONF"}
with env_var("CONF", str(path)):
g = G()
g.load()
mocked_load_config.assert_called_once_with(str(path))
def test_conflict(tmpdir):
path = tmpdir.join("myapp.json")
path.write(json.dumps({"A": 1, "B": 2}))
class G(GoodConf):
A: int
B: int
model_config = {"default_files": [path]}
with env_var("A", "3"):
g = G()
g.load()
assert g.A == 3
assert g.B == 2
def test_all_env_vars(mocker):
mocked_set_values = mocker.patch("goodconf.BaseSettings.__init__")
mocked_load_config = mocker.patch("goodconf._load_config")
class G(GoodConf):
pass
g = G()
g.load()
mocked_set_values.assert_called_once_with()
mocked_load_config.assert_not_called()
def test_provided_file(mocker, tmpdir):
mocked_load_config = mocker.patch("goodconf._load_config")
path = tmpdir.join("myapp.json")
path.write("")
class G(GoodConf):
pass
g = G()
g.load(str(path))
mocked_load_config.assert_called_once_with(str(path))
def test_provided_file_from_init(mocker, tmpdir):
mocked_load_config = mocker.patch("goodconf._load_config")
path = tmpdir.join("myapp.json")
path.write("")
class G(GoodConf):
pass
g = G(config_file=str(path))
g.load()
mocked_load_config.assert_called_once_with(str(path))
def test_default_files(mocker, tmpdir):
mocked_load_config = mocker.patch("goodconf._load_config")
path = tmpdir.join("myapp.json")
path.write("")
bad_path = tmpdir.join("does-not-exist.json")
class G(GoodConf):
model_config = {"default_files": [str(bad_path), str(path)]}
g = G()
g.load()
mocked_load_config.assert_called_once_with(str(path))
================================================
FILE: tests/test_goodconf.py
================================================
import json
import os
import re
from textwrap import dedent
from typing import Literal
import pytest
from pydantic import ValidationError
from pydantic.fields import FieldInfo
from goodconf import Field, FileConfigSettingsSource, GoodConf
from tests.utils import env_var
def test_initial():
class TestConf(GoodConf):
a: bool = Field(initial=lambda: True)
b: bool = Field(default=False)
initial = TestConf.get_initial()
assert len(initial) == 2
assert initial["a"] is True
assert initial["b"] is False
def test_dump_json():
class TestConf(GoodConf):
a: bool = Field(initial=lambda: True)
assert TestConf.generate_json() == '{\n "a": true\n}'
assert TestConf.generate_json(not_a_value=True) == '{\n "a": true\n}'
assert TestConf.generate_json(a=False) == '{\n "a": false\n}'
def test_dump_toml():
pytest.importorskip("tomlkit")
class TestConf(GoodConf):
a: bool = False
b: str = "Happy"
output = TestConf.generate_toml()
assert "a = false" in output
assert 'b = "Happy"' in output
class TestConf(GoodConf):
"Configuration for My App"
a: str = Field(description="this is a")
b: str
output = TestConf.generate_toml()
assert "# Configuration for My App\n" in output
assert 'a = "" # this is a' in output
assert 'b = ""' in output
def test_dump_yaml():
pytest.importorskip("ruamel.yaml")
class TestConf(GoodConf):
"Configuration for My App"
a: str = Field(description="this is a")
b: str
output = TestConf.generate_yaml()
output = re.sub(r" +\n", "\n", output)
assert "\n# Configuration for My App\n" in output
assert (
dedent(
"""\
# this is a
a: ''
"""
)
in output
)
assert "b: ''" in output
output_override = TestConf.generate_yaml(b="yes")
assert "a: ''" in output_override
assert "b: yes" in output_override
def test_dump_yaml_no_docstring():
pytest.importorskip("ruamel.yaml")
class TestConf(GoodConf):
a: str = Field(description="this is a")
output = TestConf.generate_yaml()
output = re.sub(r" +\n", "\n", output)
assert output == dedent(
"""
# this is a
a: ''
"""
)
def test_dump_yaml_none():
pytest.importorskip("ruamel.yaml")
class TestConf(GoodConf):
a: str | None
output = TestConf.generate_yaml()
assert output.strip() == "a: ~"
def test_generate_markdown():
help_ = "this is a"
class TestConf(GoodConf):
"Configuration for My App"
a: int = Field(description=help_, default=None)
b: int = Field(description=help_, default=5)
c: str
mkdn = TestConf.generate_markdown()
# Not sure on final format, just do some basic smoke tests
assert TestConf.__doc__ in mkdn
assert help_ in mkdn
def test_generate_markdown_no_docstring():
help_ = "this is a"
class TestConf(GoodConf):
a: int = Field(description=help_, default=5)
b: str
mkdn = TestConf.generate_markdown()
# Not sure on final format, just do some basic smoke tests
assert f" * description: {help_}" in mkdn.splitlines()
def test_generate_markdown_default_false():
class TestConf(GoodConf):
a: bool = Field(default=False)
lines = TestConf.generate_markdown().splitlines()
assert " * type: `bool`" in lines
assert " * default: `False`" in lines
def test_generate_markdown_types():
class TestConf(GoodConf):
a: Literal["a", "b"] = Field(default="a")
b: list[str] = Field()
c: None
lines = TestConf.generate_markdown().splitlines()
assert " * type: `Literal['a', 'b']`" in lines
assert " * type: `list[str]`" in lines
assert "default: `PydanticUndefined`" not in str(lines)
def test_generate_markdown_required():
class TestConf(GoodConf):
a: str
lines = TestConf.generate_markdown().splitlines()
assert "* **a** _REQUIRED_" in lines
def test_undefined():
c = GoodConf()
with pytest.raises(AttributeError):
c.UNDEFINED # noqa: B018
def test_required_missing():
class TestConf(GoodConf):
a: str = Field()
c = TestConf()
with pytest.raises(ValidationError):
c.load()
with pytest.raises(ValidationError):
TestConf(load=True)
def test_default_values_are_used(monkeypatch):
"""
Covers regression in: https://github.com/lincolnloop/goodconf/pull/51
Requires more than one defined field to reproduce.
"""
monkeypatch.delenv("a", raising=False)
monkeypatch.setenv("b", "value_from_env")
monkeypatch.delenv("c", raising=False)
class TestConf(GoodConf):
a: str = Field(default="default_for_a")
b: str = Field(initial=lambda: "1234")
c: str = Field("default_for_c")
c = TestConf()
c.load()
assert c.a == "default_for_a"
assert c.b == "value_from_env"
assert c.c == "default_for_c"
def test_set_on_init():
class TestConf(GoodConf):
a: str = Field()
val = "test"
c = TestConf(a=val)
assert c.a == val
def test_env_prefix():
class TestConf(GoodConf):
a: bool = False
model_config = {"env_prefix": "PREFIX_"}
with env_var("PREFIX_A", "True"):
c = TestConf(load=True)
assert c.a
def test_precedence(tmpdir):
path = tmpdir.join("myapp.json")
path.write(json.dumps({"init": "file", "env": "file", "file": "file"}))
class TestConf(GoodConf, default_files=[path]):
init: str = ""
env: str = ""
file: str = ""
os.environ["INIT"] = "env"
os.environ["ENV"] = "env"
try:
c = TestConf(init="init")
assert c.init == "init"
assert c.env == "env"
assert c.file == "file"
finally:
del os.environ["INIT"]
del os.environ["ENV"]
def test_fileconfigsettingssource_repr():
class SettingsClass:
model_config = {}
fileconfigsettingssource = FileConfigSettingsSource(SettingsClass)
assert repr(fileconfigsettingssource) == "FileConfigSettingsSource()"
def test_fileconfigsettingssource_get_field_value():
class SettingsClass:
model_config = {}
fileconfigsettingssource = FileConfigSettingsSource(SettingsClass)
field = FieldInfo(title="testfield")
assert fileconfigsettingssource.get_field_value(field, "testfield") == (
None,
"",
False,
)
assert fileconfigsettingssource.get_field_value(None, "a") == (None, "", False)
================================================
FILE: tests/test_initial.py
================================================
import pytest
from goodconf import Field, GoodConf, initial_for_field
from .utils import KEY
def test_initial():
class C(GoodConf):
f: str = Field(initial=lambda: "x")
assert initial_for_field(KEY, C.model_fields["f"]) == "x"
def test_initial_bad():
class C(GoodConf):
f: str = Field(initial="x")
with pytest.raises(ValueError, match="callable"):
initial_for_field(KEY, C.model_fields["f"])
def test_initial_default():
class C(GoodConf):
f: str = Field("x")
assert initial_for_field(KEY, C.model_fields["f"]) == "x"
def test_initial_default_factory():
class C(GoodConf):
f: str = Field(default_factory=lambda: "y")
assert initial_for_field(KEY, C.model_fields["f"]) == "y"
def test_no_initial():
class C(GoodConf):
f: str = Field()
assert initial_for_field(KEY, C.model_fields["f"]) == ""
def test_default_initial():
"""Can get initial when Field is not used"""
class G(GoodConf):
a: str = "test"
initial = G().get_initial()
assert initial["a"] == "test"
def test_optional_initial():
class G(GoodConf):
a: str | None
initial = G().get_initial()
assert initial["a"] is None
================================================
FILE: tests/utils.py
================================================
import os
from contextlib import contextmanager
KEY = "GOODCONF_TEST"
@contextmanager
def env_var(key, value):
os.environ[key] = value
try:
yield
finally:
del os.environ[key]