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]