Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
58be829
fix: resolve deferred ruff violations (PTH, TID252, TC002, TRY003/4, …
marcgibbons Mar 10, 2026
d4d9b4b
fix: resolve mypy errors and remove ignore_errors = true
marcgibbons Mar 10, 2026
e6874a7
feat: add type annotations throughout goodconf and remove ANN suppres…
marcgibbons Mar 10, 2026
dcecfb0
fix: enable mypy strict mode
marcgibbons Mar 10, 2026
e5138d0
fix: extend mypy to cover tests/
marcgibbons Mar 10, 2026
3a7ec25
refactor: use getattr in test_undefined to avoid type: ignore
marcgibbons Mar 10, 2026
61c7e84
docs: add docstring to test_undefined explaining pydantic delegation
marcgibbons Mar 10, 2026
388f90a
docs: document test_undefined history and deferred TODO for error mes…
marcgibbons Mar 10, 2026
2035d79
refactor: use hasattr assertion in test_undefined, remove TODO
marcgibbons Mar 10, 2026
b9d4382
configure pre-commit: uv-based mypy, prettier config, GitHub Actions …
marcgibbons Mar 10, 2026
4b6523d
fix: mypy hook passes filenames like django-layout (uv run mypy, pass…
marcgibbons Mar 10, 2026
14158f0
style: remove pass_filenames default from mypy hook
marcgibbons Mar 10, 2026
c40c468
fix: add -> None return annotations to test functions (test_initial, …
marcgibbons Mar 10, 2026
10d5921
fix: migrate tmpdir -> tmp_path in test_file_helpers
marcgibbons Mar 10, 2026
601f4ce
fix: migrate tmpdir -> tmp_path, annotate mocker in test_files
marcgibbons Mar 10, 2026
fb14601
fix: migrate tmpdir -> tmp_path in test_django and test_goodconf, ann…
marcgibbons Mar 10, 2026
1977abc
fix: N802 — noqa on Field, intentional public API name
marcgibbons Mar 10, 2026
f1428cf
fix: ARG002 — noqa on get_field_value, args required by pydantic-sett…
marcgibbons Mar 10, 2026
10f5129
fix: ARG001 — noqa on stream shims and patched_parser, args required …
marcgibbons Mar 10, 2026
8b62075
fix: A001 — rename help -> help_text in argparser_add_argument
marcgibbons Mar 10, 2026
55e4bbd
fix: E501 — remove ignore, formatter handles line length
marcgibbons Mar 10, 2026
0713856
fix: ISC001 — remove ignore, ruff format handles implicit string conc…
marcgibbons Mar 10, 2026
8d67993
fix: PLC0415 — noqa on lazy imports for optional deps (yaml, toml) an…
marcgibbons Mar 10, 2026
9d2ced9
fix: remove ANN401 global suppress, inline noqa on dynamic Any
marcgibbons Mar 10, 2026
b517609
fix: simplify mypy CI step to uv run mypy .
marcgibbons Mar 10, 2026
8373160
refactor: replace from-typing imports with import typing as t
marcgibbons Mar 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,20 @@ jobs:
- name: Test
run: uv run pytest --cov-report=xml --cov-report=term --junitxml=junit.xml

- name: mypy
if: matrix.python == '3.14'
run: uv run mypy .

- name: Upload coverage to Codecov
if: ${{ (success() || failure()) && matrix.python == '3.14' }}
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
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
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
with:
token: ${{ secrets.CODECOV_TOKEN }}
report_type: test_results
Expand Down
9 changes: 5 additions & 4 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
---
ci:
skip: [mypy]

repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
Expand Down Expand Up @@ -56,9 +59,7 @@ repos:
hooks:
- id: mypy
name: mypy
language: python
entry: mypy goodconf
language: system
entry: uv run mypy
types: [python]
require_serial: true
pass_filenames: false
additional_dependencies: [mypy]
11 changes: 11 additions & 0 deletions .prettierrc.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
printWidth = 88
semi = true
singleQuote = false
tabWidth = 2
trailingComma = "all"

[[overrides]]
files = ["*.md"]

[overrides.options]
proseWrap = "always"
129 changes: 71 additions & 58 deletions goodconf/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,44 +7,53 @@
import logging
import os
import sys
import typing as t
from collections.abc import Callable
from functools import partial
from io import StringIO
from pathlib import Path
from types import GenericAlias
from typing import Any, cast, get_args

if t.TYPE_CHECKING:
from tomlkit.items import Item

from pydantic._internal._config import config_keys
from pydantic.fields import Field as PydanticField
from pydantic.fields import FieldInfo, PydanticUndefined
from pydantic.fields import FieldInfo
from pydantic.main import _object_setattr
from pydantic_core import PydanticUndefined
from pydantic_settings import (
BaseSettings,
PydanticBaseSettingsSource,
SettingsConfigDict,
)
from typing_extensions import NotRequired

__all__ = ["Field", "GoodConf", "GoodConfConfigDict"]

log = logging.getLogger(__name__)


def Field(
*args,
initial=None,
json_schema_extra=None,
**kwargs,
):
def Field( # noqa: N802
*args: t.Any, # noqa: ANN401
initial: Callable[[], t.Any] | None = None,
json_schema_extra: dict[str, t.Any] | None = None,
**kwargs: t.Any, # noqa: ANN401
) -> FieldInfo:
if initial:
json_schema_extra = json_schema_extra or {}
json_schema_extra["initial"] = initial

return PydanticField(*args, json_schema_extra=json_schema_extra, **kwargs)
return t.cast(
"FieldInfo", PydanticField(*args, json_schema_extra=json_schema_extra, **kwargs)
)


class GoodConfConfigDict(SettingsConfigDict):
# configuration file to load
file_env_var: str | None
file_env_var: NotRequired[str | None]
# if no file is given, try to load a configuration from these files in order
default_files: list[str] | None
default_files: NotRequired[list[str] | None]


# Note: code from pydantic-settings/pydantic_settings/main.py:
Expand All @@ -57,45 +66,46 @@ class GoodConfConfigDict(SettingsConfigDict):
config_keys |= set(GoodConfConfigDict.__annotations__.keys())


def _load_config(path: str) -> dict[str, Any]:
def _load_config(path: str) -> dict[str, t.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)
loader: Callable[..., t.Any]
ext = Path(path).suffix
if ext in [".yaml", ".yml"]:
import ruamel.yaml
import ruamel.yaml # noqa: PLC0415

yaml = ruamel.yaml.YAML(typ="safe", pure=True)
loader = yaml.load
elif ext == ".toml":
try:
import tomllib
import tomllib # noqa: PLC0415

def load(stream):
def load(stream: object) -> dict[str, t.Any]: # noqa: ARG001
return tomllib.loads(f.read())
except ImportError: # Fallback for Python < 3.11
import tomlkit
import tomlkit # noqa: PLC0415

def load(stream):
def load(stream: object) -> dict[str, t.Any]: # noqa: ARG001
return tomlkit.load(f).unwrap()

loader = load

else:
loader = json.load
with open(path) as f:
with Path(path).open() 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 Path(filename).exists():
if not require:
return None
raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), filename)
return os.path.abspath(filename)
return str(Path(filename).resolve())


def _fieldinfo_to_str(field_info: FieldInfo) -> str:
Expand All @@ -114,25 +124,22 @@ def _fieldinfo_to_str(field_info: FieldInfo) -> str:
else:
# For annotation like list[str], we use its string
# representation ("list[str]").
field_type = field_info.annotation
field_type = str(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 {}
def initial_for_field(name: str, field_info: FieldInfo) -> t.Any: # noqa: ANN401
json_schema_extra = field_info.json_schema_extra
if isinstance(json_schema_extra, dict) and "initial" in json_schema_extra:
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):
msg = f"Initial value for `{name}` must be a callable."
raise TypeError(msg)
return json_schema_extra["initial"]()
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() # type: ignore[call-arg]
if type(None) in t.get_args(field_info.annotation):
return None
return ""

Expand All @@ -142,17 +149,19 @@ class FileConfigSettingsSource(PydanticBaseSettingsSource):
Source class for loading values provided during settings class initialization.
"""

def __init__(self, settings_cls: type[BaseSettings]):
def __init__(self, settings_cls: type[BaseSettings]) -> None:
super().__init__(settings_cls)

def get_field_value(
self, field: FieldInfo, field_name: str
) -> tuple[Any, str, bool]:
self,
field: FieldInfo, # noqa: ARG002
field_name: str, # noqa: ARG002
) -> tuple[t.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)
def __call__(self) -> dict[str, t.Any]:
settings = t.cast("GoodConf", self.settings_cls)
selected_config_file = None
if cfg_file := self.current_state.get("_config_file"):
selected_config_file = cfg_file
Expand All @@ -179,7 +188,10 @@ def __repr__(self) -> str:

class GoodConf(BaseSettings):
def __init__(
self, load: bool = False, config_file: str | None = None, **kwargs
self,
load: bool = False,
config_file: str | None = None,
**kwargs: t.Any, # noqa: ANN401
) -> None:
"""
:param load: load config file on instantiation [default: False].
Expand Down Expand Up @@ -218,8 +230,8 @@ def settings_customise_sources(
def _settings_build_values(
cls,
sources: tuple[PydanticBaseSettingsSource, ...],
init_kwargs: dict[str, Any],
) -> dict[str, Any]:
init_kwargs: dict[str, t.Any],
) -> dict[str, t.Any]:
state = super()._settings_build_values(
sources,
init_kwargs,
Expand All @@ -231,8 +243,8 @@ def _load(
self,
_config_file: str | None = None,
_init_config_file: str | None = None,
**kwargs,
):
**kwargs: t.Any, # noqa: ANN401
) -> None:
if config_file := _config_file or _init_config_file:
kwargs["_config_file"] = config_file
super().__init__(**kwargs)
Expand All @@ -241,18 +253,18 @@ def load(self, filename: str | None = None) -> None:
self._load(_config_file=filename)

@classmethod
def get_initial(cls, **override) -> dict:
def get_initial(cls, **override: t.Any) -> dict[str, t.Any]: # noqa: ANN401
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:
def generate_yaml(cls, **override: t.Any) -> str: # noqa: ANN401
"""
Dumps initial config in YAML
"""
import ruamel.yaml
import ruamel.yaml # noqa: PLC0415

yaml = ruamel.yaml.YAML()
yaml.representer.add_representer(
Expand All @@ -267,7 +279,7 @@ def generate_yaml(cls, **override) -> str:
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)
description = t.cast("str", cls.model_fields[k].description)
dict_from_yaml.yaml_set_comment_before_after_key(
k, before="\n" + description
)
Expand All @@ -277,19 +289,18 @@ def generate_yaml(cls, **override) -> str:
return yaml_str.read()

@classmethod
def generate_json(cls, **override) -> str:
def generate_json(cls, **override: t.Any) -> str: # noqa: ANN401
"""
Dumps initial config in JSON
"""
return json.dumps(cls.get_initial(**override), indent=2)

@classmethod
def generate_toml(cls, **override) -> str:
def generate_toml(cls, **override: t.Any) -> str: # noqa: ANN401
"""
Dumps initial config in TOML
"""
import tomlkit
from tomlkit.items import Item
import tomlkit # noqa: PLC0415

toml_str = tomlkit.dumps(cls.get_initial(**override))
dict_from_toml = tomlkit.loads(toml_str)
Expand All @@ -299,8 +310,8 @@ def generate_toml(cls, **override) -> str:
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)
description = t.cast("str", cls.model_fields[k].description)
t.cast("Item", document[k]).comment(description)
return tomlkit.dumps(document)

@classmethod
Expand Down Expand Up @@ -328,8 +339,10 @@ def generate_markdown(cls) -> str:
lines.append(f" * default: `{field_info.default}`")
return "\n".join(lines)

def django_manage(self, args: list[str] | None = None):
def django_manage(self, args: list[str] | None = None) -> None:
args = args or sys.argv
from .contrib.django import execute_from_command_line_with_config
from .contrib.django import ( # noqa: PLC0415
execute_from_command_line_with_config,
)

execute_from_command_line_with_config(self, args)
16 changes: 8 additions & 8 deletions goodconf/contrib/argparse.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
import argparse

from .. import GoodConf
from goodconf import GoodConf


def argparser_add_argument(parser: argparse.ArgumentParser, config: GoodConf):
def argparser_add_argument(parser: argparse.ArgumentParser, config: GoodConf) -> None:
"""Adds argument for config to existing argparser"""
help = "Config file."
help_text = "Config file."
cfg = config.model_config
if cfg.get("file_env_var"):
help += (
help_text += (
"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)
if default_files := cfg.get("default_files"):
files_str = ", ".join(default_files)
help_text += f" Defaults to the first file that exists from [{files_str}]."
parser.add_argument("-C", "--config", metavar="FILE", help=help_text)
Loading
Loading