From 58be82911ff4bb3128481f23b3ebc4c2afc2ea24 Mon Sep 17 00:00:00 2001 From: Marc Gibbons <1726961+marcgibbons@users.noreply.github.com> Date: Tue, 10 Mar 2026 07:03:26 -0400 Subject: [PATCH 01/26] fix: resolve deferred ruff violations (PTH, TID252, TC002, TRY003/4, EM102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace os.path calls with pathlib.Path in _load_config and _find_file - Move tomlkit.items.Item import into TYPE_CHECKING block - Use absolute imports in goodconf.contrib modules - Extract exception message variable and change ValueError → TypeError for invalid callable check in initial_for_field - Exclude auto-generated _version.py from ruff - Remove PTH, TID252, TC002, TRY003, TRY004, EM102 from ruff ignore list --- goodconf/__init__.py | 18 +++++++++++------- goodconf/contrib/argparse.py | 2 +- goodconf/contrib/django.py | 4 ++-- pyproject.toml | 9 +++------ tests/test_initial.py | 2 +- 5 files changed, 18 insertions(+), 17 deletions(-) diff --git a/goodconf/__init__.py b/goodconf/__init__.py index 424b112..273bbbb 100644 --- a/goodconf/__init__.py +++ b/goodconf/__init__.py @@ -9,8 +9,12 @@ import sys from functools import partial from io import StringIO +from pathlib import Path from types import GenericAlias -from typing import Any, cast, get_args +from typing import TYPE_CHECKING, Any, cast, get_args + +if TYPE_CHECKING: + from tomlkit.items import Item from pydantic._internal._config import config_keys from pydantic.fields import Field as PydanticField @@ -63,7 +67,7 @@ def _load_config(path: str) -> dict[str, Any]: and return the values as a Python dictionary. JSON is the default if an extension can't be determined. """ - __, ext = os.path.splitext(path) + ext = Path(path).suffix if ext in [".yaml", ".yml"]: import ruamel.yaml @@ -85,17 +89,17 @@ def load(stream): 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: @@ -122,7 +126,8 @@ 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.") + msg = f"Initial value for `{name}` must be a callable." + raise TypeError(msg) return field_info.json_schema_extra["initial"]() except KeyError: if ( @@ -289,7 +294,6 @@ 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) diff --git a/goodconf/contrib/argparse.py b/goodconf/contrib/argparse.py index 8aa1f9f..a67de35 100644 --- a/goodconf/contrib/argparse.py +++ b/goodconf/contrib/argparse.py @@ -1,6 +1,6 @@ import argparse -from .. import GoodConf +from goodconf import GoodConf def argparser_add_argument(parser: argparse.ArgumentParser, config: GoodConf): diff --git a/goodconf/contrib/django.py b/goodconf/contrib/django.py index c2b3f70..f671677 100644 --- a/goodconf/contrib/django.py +++ b/goodconf/contrib/django.py @@ -2,8 +2,8 @@ from collections.abc import Generator from contextlib import contextmanager -from .. import GoodConf -from .argparse import argparser_add_argument +from goodconf import GoodConf +from goodconf.contrib.argparse import argparser_add_argument @contextmanager diff --git a/pyproject.toml b/pyproject.toml index 4751b17..7a512d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,6 +83,9 @@ ignore_errors = true [tool.pytest.ini_options] addopts = ["--cov", "--strict-markers"] +[tool.ruff] +exclude = ["goodconf/_version.py"] + [tool.ruff.lint] ignore = [ # Permanently suppressed @@ -100,12 +103,6 @@ ignore = [ "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"] diff --git a/tests/test_initial.py b/tests/test_initial.py index 81e0ec2..ed469f4 100644 --- a/tests/test_initial.py +++ b/tests/test_initial.py @@ -16,7 +16,7 @@ def test_initial_bad(): class C(GoodConf): f: str = Field(initial="x") - with pytest.raises(ValueError, match="callable"): + with pytest.raises(TypeError, match="callable"): initial_for_field(KEY, C.model_fields["f"]) From d4d9b4bf41c1ee255e660e199bbbc9783c856b7f Mon Sep 17 00:00:00 2001 From: Marc Gibbons <1726961+marcgibbons@users.noreply.github.com> Date: Tue, 10 Mar 2026 07:06:35 -0400 Subject: [PATCH 02/26] fix: resolve mypy errors and remove ignore_errors = true - Add NotRequired to GoodConfConfigDict fields (file_env_var, default_files) - Annotate loader variable in _load_config as Callable[..., Any] - Fix _fieldinfo_to_str else branch to return str(annotation) - Restructure initial_for_field to use isinstance dict check - Add type: ignore[call-arg] for pydantic default_factory() - Add type: ignore[assignment] for intentional BaseCommand monkey-patch - Use walrus operator in argparse.py to narrow default_files type - Add django-stubs to dev deps for Django type coverage - Switch mypy pre-commit hook to language: system using uv run --- .pre-commit-config.yaml | 5 ++--- goodconf/__init__.py | 32 ++++++++++++++++---------------- goodconf/contrib/argparse.py | 4 ++-- goodconf/contrib/django.py | 4 ++-- pyproject.toml | 2 +- 5 files changed, 23 insertions(+), 24 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 14a15ca..7a93a77 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -56,9 +56,8 @@ repos: hooks: - id: mypy name: mypy - language: python - entry: mypy goodconf + language: system + entry: uv run mypy goodconf types: [python] require_serial: true pass_filenames: false - additional_dependencies: [mypy] diff --git a/goodconf/__init__.py b/goodconf/__init__.py index 273bbbb..3902ba1 100644 --- a/goodconf/__init__.py +++ b/goodconf/__init__.py @@ -14,6 +14,8 @@ from typing import TYPE_CHECKING, Any, cast, get_args if TYPE_CHECKING: + from collections.abc import Callable + from tomlkit.items import Item from pydantic._internal._config import config_keys @@ -25,6 +27,7 @@ PydanticBaseSettingsSource, SettingsConfigDict, ) +from typing_extensions import NotRequired __all__ = ["Field", "GoodConf", "GoodConfConfigDict"] @@ -46,9 +49,9 @@ def Field( 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: @@ -67,6 +70,7 @@ def _load_config(path: str) -> dict[str, Any]: and return the values as a Python dictionary. JSON is the default if an extension can't be determined. """ + loader: Callable[..., Any] ext = Path(path).suffix if ext in [".yaml", ".yml"]: import ruamel.yaml @@ -77,12 +81,12 @@ def _load_config(path: str) -> dict[str, Any]: try: import tomllib - def load(stream): + def load(stream: Any) -> Any: return tomllib.loads(f.read()) except ImportError: # Fallback for Python < 3.11 import tomlkit - def load(stream): + def load(stream: Any) -> Any: return tomlkit.load(f).unwrap() loader = load @@ -118,25 +122,21 @@ 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 {} + 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"]): msg = f"Initial value for `{name}` must be a callable." raise TypeError(msg) - 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() + 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 get_args(field_info.annotation): return None return "" diff --git a/goodconf/contrib/argparse.py b/goodconf/contrib/argparse.py index a67de35..071bf07 100644 --- a/goodconf/contrib/argparse.py +++ b/goodconf/contrib/argparse.py @@ -12,7 +12,7 @@ def argparser_add_argument(parser: argparse.ArgumentParser, config: GoodConf): "Can also be configured via the environment variable: " f"{cfg['file_env_var']}" ) - if cfg.get("default_files"): - files_str = ", ".join(cfg["default_files"]) + if default_files := cfg.get("default_files"): + files_str = ", ".join(default_files) help += f" Defaults to the first file that exists from [{files_str}]." parser.add_argument("-C", "--config", metavar="FILE", help=help) diff --git a/goodconf/contrib/django.py b/goodconf/contrib/django.py index f671677..19142b3 100644 --- a/goodconf/contrib/django.py +++ b/goodconf/contrib/django.py @@ -22,7 +22,7 @@ def patched_parser(self, prog_name, subcommand): argparser_add_argument(parser, config) return parser - BaseCommand.create_parser = patched_parser + BaseCommand.create_parser = patched_parser # type: ignore[assignment] try: parser = argparse.ArgumentParser(add_help=False) @@ -33,7 +33,7 @@ def patched_parser(self, prog_name, subcommand): yield default_args finally: # Put that create_parser back where it came from or so help me! - BaseCommand.create_parser = original_parser + BaseCommand.create_parser = original_parser # type: ignore[method-assign] def execute_from_command_line_with_config(config: GoodConf, argv: list[str]): diff --git a/pyproject.toml b/pyproject.toml index 7a512d1..a656cb9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,6 +4,7 @@ requires = ["hatchling", "hatch-vcs"] [dependency-groups] dev = [ + "django-stubs>=5.2.9", "django>=3.2.0", "mypy>=1.19.1", "pytest-cov==7.0.*", @@ -78,7 +79,6 @@ exclude = [ source = "vcs" [tool.mypy] -ignore_errors = true [tool.pytest.ini_options] addopts = ["--cov", "--strict-markers"] From e6874a7c5fa5fdf5492bab8eaadaf4831707986c Mon Sep 17 00:00:00 2001 From: Marc Gibbons <1726961+marcgibbons@users.noreply.github.com> Date: Tue, 10 Mar 2026 07:11:49 -0400 Subject: [PATCH 03/26] feat: add type annotations throughout goodconf and remove ANN suppression - Annotate Field(), FileConfigSettingsSource.__init__(), GoodConf._load(), get_initial(), generate_yaml/json/toml(), django_manage() - Annotate argparser_add_argument() and execute_from_command_line_with_config() - Annotate patched_parser() closure in contrib/django using lazy-imported types - Annotate tests/utils.env_var() - Narrow Field json_schema_extra param to dict[str, Any] | None - Remove ANN from ruff ignore list; only ANN401 (Any) remains suppressed --- goodconf/__init__.py | 31 +++++++++++++++---------------- goodconf/contrib/argparse.py | 2 +- goodconf/contrib/django.py | 9 ++++++--- pyproject.toml | 2 -- tests/utils.py | 3 ++- 5 files changed, 24 insertions(+), 23 deletions(-) diff --git a/goodconf/__init__.py b/goodconf/__init__.py index 3902ba1..c9b3d32 100644 --- a/goodconf/__init__.py +++ b/goodconf/__init__.py @@ -7,6 +7,7 @@ import logging import os import sys +from collections.abc import Callable from functools import partial from io import StringIO from pathlib import Path @@ -14,8 +15,6 @@ from typing import TYPE_CHECKING, Any, cast, get_args if TYPE_CHECKING: - from collections.abc import Callable - from tomlkit.items import Item from pydantic._internal._config import config_keys @@ -35,11 +34,11 @@ def Field( - *args, - initial=None, - json_schema_extra=None, - **kwargs, -): + *args: Any, + initial: Callable[[], Any] | None = None, + json_schema_extra: dict[str, Any] | None = None, + **kwargs: Any, +) -> FieldInfo: if initial: json_schema_extra = json_schema_extra or {} json_schema_extra["initial"] = initial @@ -147,7 +146,7 @@ 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( @@ -184,7 +183,7 @@ 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: Any ) -> None: """ :param load: load config file on instantiation [default: False]. @@ -236,8 +235,8 @@ def _load( self, _config_file: str | None = None, _init_config_file: str | None = None, - **kwargs, - ): + **kwargs: Any, + ) -> None: if config_file := _config_file or _init_config_file: kwargs["_config_file"] = config_file super().__init__(**kwargs) @@ -246,14 +245,14 @@ 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: Any) -> dict[str, Any]: 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: Any) -> str: """ Dumps initial config in YAML """ @@ -282,14 +281,14 @@ def generate_yaml(cls, **override) -> str: return yaml_str.read() @classmethod - def generate_json(cls, **override) -> str: + def generate_json(cls, **override: Any) -> str: """ 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: Any) -> str: """ Dumps initial config in TOML """ @@ -332,7 +331,7 @@ 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 diff --git a/goodconf/contrib/argparse.py b/goodconf/contrib/argparse.py index 071bf07..8babac9 100644 --- a/goodconf/contrib/argparse.py +++ b/goodconf/contrib/argparse.py @@ -3,7 +3,7 @@ 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." cfg = config.model_config diff --git a/goodconf/contrib/django.py b/goodconf/contrib/django.py index 19142b3..91c5199 100644 --- a/goodconf/contrib/django.py +++ b/goodconf/contrib/django.py @@ -1,6 +1,7 @@ import argparse from collections.abc import Generator from contextlib import contextmanager +from typing import Any from goodconf import GoodConf from goodconf.contrib.argparse import argparser_add_argument @@ -13,11 +14,13 @@ def load_config_from_cli( """Loads config, checking CLI arguments for a config file""" # Monkey patch Django's command parser - from django.core.management.base import BaseCommand + from django.core.management.base import BaseCommand, CommandParser original_parser = BaseCommand.create_parser - def patched_parser(self, prog_name, subcommand): + def patched_parser( + self: BaseCommand, prog_name: str, subcommand: str, **kwargs: Any + ) -> CommandParser: parser = original_parser(self, prog_name, subcommand) argparser_add_argument(parser, config) return parser @@ -36,7 +39,7 @@ def patched_parser(self, prog_name, subcommand): BaseCommand.create_parser = original_parser # type: ignore[method-assign] -def execute_from_command_line_with_config(config: GoodConf, argv: list[str]): +def execute_from_command_line_with_config(config: GoodConf, argv: list[str]) -> None: """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 diff --git a/pyproject.toml b/pyproject.toml index a656cb9..d2583bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,8 +101,6 @@ ignore = [ "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) ] select = ["ALL"] diff --git a/tests/utils.py b/tests/utils.py index bcc12f2..02fbaba 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,11 +1,12 @@ import os +from collections.abc import Iterator from contextlib import contextmanager KEY = "GOODCONF_TEST" @contextmanager -def env_var(key, value): +def env_var(key: str, value: str) -> Iterator[None]: os.environ[key] = value try: yield From dcecfb064fbcd4c5828718c7a47a0d76c65895c5 Mon Sep 17 00:00:00 2001 From: Marc Gibbons <1726961+marcgibbons@users.noreply.github.com> Date: Tue, 10 Mar 2026 07:13:15 -0400 Subject: [PATCH 04/26] fix: enable mypy strict mode - Import PydanticUndefined from pydantic_core (explicitly exported) - Cast PydanticField return value to FieldInfo for no-any-return - Fix type: ignore code on BaseCommand monkey-patch to [method-assign] - Enable strict = true in [tool.mypy] --- goodconf/__init__.py | 7 +++++-- goodconf/contrib/django.py | 2 +- pyproject.toml | 1 + 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/goodconf/__init__.py b/goodconf/__init__.py index c9b3d32..103a561 100644 --- a/goodconf/__init__.py +++ b/goodconf/__init__.py @@ -19,8 +19,9 @@ 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, @@ -43,7 +44,9 @@ def Field( json_schema_extra = json_schema_extra or {} json_schema_extra["initial"] = initial - return PydanticField(*args, json_schema_extra=json_schema_extra, **kwargs) + return cast( + "FieldInfo", PydanticField(*args, json_schema_extra=json_schema_extra, **kwargs) + ) class GoodConfConfigDict(SettingsConfigDict): diff --git a/goodconf/contrib/django.py b/goodconf/contrib/django.py index 91c5199..385d505 100644 --- a/goodconf/contrib/django.py +++ b/goodconf/contrib/django.py @@ -25,7 +25,7 @@ def patched_parser( argparser_add_argument(parser, config) return parser - BaseCommand.create_parser = patched_parser # type: ignore[assignment] + BaseCommand.create_parser = patched_parser # type: ignore[method-assign] try: parser = argparse.ArgumentParser(add_help=False) diff --git a/pyproject.toml b/pyproject.toml index d2583bf..609a68d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,6 +79,7 @@ exclude = [ source = "vcs" [tool.mypy] +strict = true [tool.pytest.ini_options] addopts = ["--cov", "--strict-markers"] From e5138d08b1664de9dc1df2218448f7a82c07f4f5 Mon Sep 17 00:00:00 2001 From: Marc Gibbons <1726961+marcgibbons@users.noreply.github.com> Date: Tue, 10 Mar 2026 08:03:34 -0400 Subject: [PATCH 05/26] fix: extend mypy to cover tests/ - Run mypy on tests/ in pre-commit hook - Enable pydantic.mypy plugin - Add test override: relax untyped-def, suppress pydantic-settings false positives for assignment/call-arg - Use GoodConfConfigDict in test_django.py instead of pydantic ConfigDict - Fix individual ignores: arg-type, no-redef, attr-defined - Fix test_goodconf: guard __doc__ None before 'in' check - Fix test_optional_initial: call G.get_initial() (classmethod, no instance needed) --- .pre-commit-config.yaml | 2 +- pyproject.toml | 8 ++++++++ tests/test_django.py | 7 +++---- tests/test_goodconf.py | 12 ++++++------ tests/test_initial.py | 4 ++-- 5 files changed, 20 insertions(+), 13 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7a93a77..0e255cf 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -57,7 +57,7 @@ repos: - id: mypy name: mypy language: system - entry: uv run mypy goodconf + entry: uv run mypy goodconf tests types: [python] require_serial: true pass_filenames: false diff --git a/pyproject.toml b/pyproject.toml index 609a68d..ad960eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,8 +79,16 @@ exclude = [ source = "vcs" [tool.mypy] +plugins = ["pydantic.mypy"] strict = true +[[tool.mypy.overrides]] +# pydantic-settings fields require values at __init__ per mypy, but +# are loaded from env/files at runtime; suppress these false positives +disable_error_code = ["assignment", "call-arg"] +disallow_untyped_defs = false +module = "tests.*" + [tool.pytest.ini_options] addopts = ["--cov", "--strict-markers"] diff --git a/tests/test_django.py b/tests/test_django.py index 06ad5ed..d5dd924 100644 --- a/tests/test_django.py +++ b/tests/test_django.py @@ -1,9 +1,8 @@ import sys import pytest -from pydantic import ConfigDict -from goodconf import GoodConf +from goodconf import GoodConf, GoodConfConfigDict pytest.importorskip("django") @@ -15,7 +14,7 @@ def test_mgmt_command(mocker, tmpdir): temp_config.write("") class G(GoodConf): - model_config = ConfigDict() + model_config = GoodConfConfigDict() c = G() dj_args = ["manage.py", "diffsettings", "-v", "2"] @@ -31,7 +30,7 @@ def test_help(mocker, tmpdir, capsys): temp_config.write("") class G(GoodConf): - model_config = ConfigDict( + model_config = GoodConfConfigDict( file_env_var="MYAPP_CONF", default_files=["/etc/myapp.json"], ) diff --git a/tests/test_goodconf.py b/tests/test_goodconf.py index 8a48ccb..3ae0c68 100644 --- a/tests/test_goodconf.py +++ b/tests/test_goodconf.py @@ -43,7 +43,7 @@ class TestConf(GoodConf): assert "a = false" in output assert 'b = "Happy"' in output - class TestConf(GoodConf): + class TestConf(GoodConf): # type: ignore[no-redef] "Configuration for My App" a: str = Field(description="this is a") @@ -122,7 +122,7 @@ class TestConf(GoodConf): mkdn = TestConf.generate_markdown() # Not sure on final format, just do some basic smoke tests - assert TestConf.__doc__ in mkdn + assert (TestConf.__doc__ or "") in mkdn assert help_ in mkdn @@ -170,7 +170,7 @@ class TestConf(GoodConf): def test_undefined(): c = GoodConf() with pytest.raises(AttributeError): - c.UNDEFINED # noqa: B018 + c.UNDEFINED # type: ignore[attr-defined] # noqa: B018 def test_required_missing(): @@ -255,7 +255,7 @@ def test_fileconfigsettingssource_repr(): class SettingsClass: model_config = {} - fileconfigsettingssource = FileConfigSettingsSource(SettingsClass) + fileconfigsettingssource = FileConfigSettingsSource(SettingsClass) # type: ignore[arg-type] assert repr(fileconfigsettingssource) == "FileConfigSettingsSource()" @@ -264,11 +264,11 @@ def test_fileconfigsettingssource_get_field_value(): class SettingsClass: model_config = {} - fileconfigsettingssource = FileConfigSettingsSource(SettingsClass) + fileconfigsettingssource = FileConfigSettingsSource(SettingsClass) # type: ignore[arg-type] field = FieldInfo(title="testfield") assert fileconfigsettingssource.get_field_value(field, "testfield") == ( None, "", False, ) - assert fileconfigsettingssource.get_field_value(None, "a") == (None, "", False) + assert fileconfigsettingssource.get_field_value(None, "a") == (None, "", False) # type: ignore[arg-type] diff --git a/tests/test_initial.py b/tests/test_initial.py index ed469f4..d09a75c 100644 --- a/tests/test_initial.py +++ b/tests/test_initial.py @@ -14,7 +14,7 @@ class C(GoodConf): def test_initial_bad(): class C(GoodConf): - f: str = Field(initial="x") + f: str = Field(initial="x") # type: ignore[arg-type] with pytest.raises(TypeError, match="callable"): initial_for_field(KEY, C.model_fields["f"]) @@ -55,5 +55,5 @@ def test_optional_initial(): class G(GoodConf): a: str | None - initial = G().get_initial() + initial = G.get_initial() assert initial["a"] is None From 3a7ec259f740a129f56c5652314e7a07f8a7a153 Mon Sep 17 00:00:00 2001 From: Marc Gibbons <1726961+marcgibbons@users.noreply.github.com> Date: Tue, 10 Mar 2026 08:04:22 -0400 Subject: [PATCH 06/26] refactor: use getattr in test_undefined to avoid type: ignore Using getattr(c, "UNDEFINED") is explicit about intent (runtime attribute lookup) and removes both the attr-defined type: ignore and noqa: B018. --- tests/test_goodconf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_goodconf.py b/tests/test_goodconf.py index 3ae0c68..85d556d 100644 --- a/tests/test_goodconf.py +++ b/tests/test_goodconf.py @@ -170,7 +170,7 @@ class TestConf(GoodConf): def test_undefined(): c = GoodConf() with pytest.raises(AttributeError): - c.UNDEFINED # type: ignore[attr-defined] # noqa: B018 + getattr(c, "UNDEFINED") # noqa: B009 def test_required_missing(): From 61c7e84a0ad3ba3c29e041f0b3364759a6e09d5b Mon Sep 17 00:00:00 2001 From: Marc Gibbons <1726961+marcgibbons@users.noreply.github.com> Date: Tue, 10 Mar 2026 08:10:32 -0400 Subject: [PATCH 07/26] docs: add docstring to test_undefined explaining pydantic delegation --- tests/test_goodconf.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_goodconf.py b/tests/test_goodconf.py index 85d556d..4422168 100644 --- a/tests/test_goodconf.py +++ b/tests/test_goodconf.py @@ -168,6 +168,12 @@ class TestConf(GoodConf): def test_undefined(): + """Undefined attributes raise AttributeError via pydantic's BaseModel.__getattr__. + + GoodConf previously defined its own __getattr__ (removed in 9e80965). + The behaviour is now delegated to pydantic: + https://github.com/pydantic/pydantic/blob/v2.11.3/pydantic/main.py#L964 + """ c = GoodConf() with pytest.raises(AttributeError): getattr(c, "UNDEFINED") # noqa: B009 From 388f90a13a2647f6a3e9c69ca325d40551d6da25 Mon Sep 17 00:00:00 2001 From: Marc Gibbons <1726961+marcgibbons@users.noreply.github.com> Date: Tue, 10 Mar 2026 08:15:28 -0400 Subject: [PATCH 08/26] docs: document test_undefined history and deferred TODO for error message --- tests/test_goodconf.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/test_goodconf.py b/tests/test_goodconf.py index 4422168..626e174 100644 --- a/tests/test_goodconf.py +++ b/tests/test_goodconf.py @@ -168,11 +168,17 @@ class TestConf(GoodConf): def test_undefined(): - """Undefined attributes raise AttributeError via pydantic's BaseModel.__getattr__. + """Undefined attributes raise AttributeError. - GoodConf previously defined its own __getattr__ (removed in 9e80965). - The behaviour is now delegated to pydantic: + GoodConf originally implemented __getattr__ (a573dcb) to auto-load config + on first access and raise AttributeError with a message listing defined + values. That implementation was removed when goodconf migrated to + pydantic-based models; the AttributeError contract is now fulfilled by + pydantic's BaseModel.__getattr__: https://github.com/pydantic/pydantic/blob/v2.11.3/pydantic/main.py#L964 + + TODO: consider whether the helpful "defined values are: ..." message from + the original implementation should be restored via a custom __getattr__. """ c = GoodConf() with pytest.raises(AttributeError): From 2035d79509bf7b56ee84e19240fa940426e30aa0 Mon Sep 17 00:00:00 2001 From: Marc Gibbons <1726961+marcgibbons@users.noreply.github.com> Date: Tue, 10 Mar 2026 08:16:20 -0400 Subject: [PATCH 09/26] refactor: use hasattr assertion in test_undefined, remove TODO --- tests/test_goodconf.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/test_goodconf.py b/tests/test_goodconf.py index 626e174..f5be0d0 100644 --- a/tests/test_goodconf.py +++ b/tests/test_goodconf.py @@ -168,7 +168,7 @@ class TestConf(GoodConf): def test_undefined(): - """Undefined attributes raise AttributeError. + """Undefined attributes are not accessible. GoodConf originally implemented __getattr__ (a573dcb) to auto-load config on first access and raise AttributeError with a message listing defined @@ -176,13 +176,9 @@ def test_undefined(): pydantic-based models; the AttributeError contract is now fulfilled by pydantic's BaseModel.__getattr__: https://github.com/pydantic/pydantic/blob/v2.11.3/pydantic/main.py#L964 - - TODO: consider whether the helpful "defined values are: ..." message from - the original implementation should be restored via a custom __getattr__. """ c = GoodConf() - with pytest.raises(AttributeError): - getattr(c, "UNDEFINED") # noqa: B009 + assert not hasattr(c, "UNDEFINED") def test_required_missing(): From b9d438214f512a0cb6925b70bc0bcb1f70869a90 Mon Sep 17 00:00:00 2001 From: Marc Gibbons <1726961+marcgibbons@users.noreply.github.com> Date: Tue, 10 Mar 2026 08:36:21 -0400 Subject: [PATCH 10/26] configure pre-commit: uv-based mypy, prettier config, GitHub Actions CI workflow - Update mypy hook entry to `uv run mypy goodconf tests` so it runs in the project venv rather than requiring a globally installed mypy - Add .prettierrc.toml for YAML and Markdown formatting - Add .github/workflows/pre-commit.yml to replace pre-commit.ci; installs uv and syncs project deps before running hooks --- .github/workflows/pre-commit.yml | 28 ++++++++++++++++++++++++++++ .prettierrc.toml | 11 +++++++++++ 2 files changed, 39 insertions(+) create mode 100644 .github/workflows/pre-commit.yml create mode 100644 .prettierrc.toml diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml new file mode 100644 index 0000000..967d02b --- /dev/null +++ b/.github/workflows/pre-commit.yml @@ -0,0 +1,28 @@ +--- +name: pre-commit + +env: + GIT_REF: ${{ github.event.pull_request.head.sha || github.sha }} + +"on": + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + # Abort pending builds if there's an update. + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + pre-commit: + runs-on: ubuntu-latest + steps: + - uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + ref: ${{ env.GIT_REF }} + - name: Install dependencies + run: uv sync --group dev + - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 diff --git a/.prettierrc.toml b/.prettierrc.toml new file mode 100644 index 0000000..bd1fe75 --- /dev/null +++ b/.prettierrc.toml @@ -0,0 +1,11 @@ +printWidth = 88 +semi = true +singleQuote = false +tabWidth = 2 +trailingComma = "all" + +[[overrides]] +files = ["*.md"] + +[overrides.options] +proseWrap = "always" From 4b6523d32c4dae338fbf95a6e0c81ebf5980e09e Mon Sep 17 00:00:00 2001 From: Marc Gibbons <1726961+marcgibbons@users.noreply.github.com> Date: Tue, 10 Mar 2026 08:37:37 -0400 Subject: [PATCH 11/26] fix: mypy hook passes filenames like django-layout (uv run mypy, pass_filenames: true) --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0e255cf..934344d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -57,7 +57,7 @@ repos: - id: mypy name: mypy language: system - entry: uv run mypy goodconf tests + pass_filenames: true + entry: uv run mypy types: [python] require_serial: true - pass_filenames: false From 14158f0a8af18f94d53df5c694ef19e6513aeb55 Mon Sep 17 00:00:00 2001 From: Marc Gibbons <1726961+marcgibbons@users.noreply.github.com> Date: Tue, 10 Mar 2026 08:37:59 -0400 Subject: [PATCH 12/26] style: remove pass_filenames default from mypy hook --- .pre-commit-config.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 934344d..ab180c9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -57,7 +57,6 @@ repos: - id: mypy name: mypy language: system - pass_filenames: true entry: uv run mypy types: [python] require_serial: true From c40c46879996c7c4067a473baa4c6ab020ccdf99 Mon Sep 17 00:00:00 2001 From: Marc Gibbons <1726961+marcgibbons@users.noreply.github.com> Date: Tue, 10 Mar 2026 10:20:57 -0400 Subject: [PATCH 13/26] fix: add -> None return annotations to test functions (test_initial, test_goodconf) --- tests/test_goodconf.py | 44 +++++++++++++++++++++--------------------- tests/test_initial.py | 14 +++++++------- 2 files changed, 29 insertions(+), 29 deletions(-) diff --git a/tests/test_goodconf.py b/tests/test_goodconf.py index f5be0d0..5d0fa77 100644 --- a/tests/test_goodconf.py +++ b/tests/test_goodconf.py @@ -2,7 +2,7 @@ import os import re from textwrap import dedent -from typing import Literal +from typing import Any, Literal import pytest from pydantic import ValidationError @@ -12,7 +12,7 @@ from tests.utils import env_var -def test_initial(): +def test_initial() -> None: class TestConf(GoodConf): a: bool = Field(initial=lambda: True) b: bool = Field(default=False) @@ -23,7 +23,7 @@ class TestConf(GoodConf): assert initial["b"] is False -def test_dump_json(): +def test_dump_json() -> None: class TestConf(GoodConf): a: bool = Field(initial=lambda: True) @@ -32,7 +32,7 @@ class TestConf(GoodConf): assert TestConf.generate_json(a=False) == '{\n "a": false\n}' -def test_dump_toml(): +def test_dump_toml() -> None: pytest.importorskip("tomlkit") class TestConf(GoodConf): @@ -56,7 +56,7 @@ class TestConf(GoodConf): # type: ignore[no-redef] assert 'b = ""' in output -def test_dump_yaml(): +def test_dump_yaml() -> None: pytest.importorskip("ruamel.yaml") class TestConf(GoodConf): @@ -84,7 +84,7 @@ class TestConf(GoodConf): assert "b: yes" in output_override -def test_dump_yaml_no_docstring(): +def test_dump_yaml_no_docstring() -> None: pytest.importorskip("ruamel.yaml") class TestConf(GoodConf): @@ -100,7 +100,7 @@ class TestConf(GoodConf): ) -def test_dump_yaml_none(): +def test_dump_yaml_none() -> None: pytest.importorskip("ruamel.yaml") class TestConf(GoodConf): @@ -110,7 +110,7 @@ class TestConf(GoodConf): assert output.strip() == "a: ~" -def test_generate_markdown(): +def test_generate_markdown() -> None: help_ = "this is a" class TestConf(GoodConf): @@ -126,7 +126,7 @@ class TestConf(GoodConf): assert help_ in mkdn -def test_generate_markdown_no_docstring(): +def test_generate_markdown_no_docstring() -> None: help_ = "this is a" class TestConf(GoodConf): @@ -138,7 +138,7 @@ class TestConf(GoodConf): assert f" * description: {help_}" in mkdn.splitlines() -def test_generate_markdown_default_false(): +def test_generate_markdown_default_false() -> None: class TestConf(GoodConf): a: bool = Field(default=False) @@ -147,7 +147,7 @@ class TestConf(GoodConf): assert " * default: `False`" in lines -def test_generate_markdown_types(): +def test_generate_markdown_types() -> None: class TestConf(GoodConf): a: Literal["a", "b"] = Field(default="a") b: list[str] = Field() @@ -159,7 +159,7 @@ class TestConf(GoodConf): assert "default: `PydanticUndefined`" not in str(lines) -def test_generate_markdown_required(): +def test_generate_markdown_required() -> None: class TestConf(GoodConf): a: str @@ -167,7 +167,7 @@ class TestConf(GoodConf): assert "* **a** _REQUIRED_" in lines -def test_undefined(): +def test_undefined() -> None: """Undefined attributes are not accessible. GoodConf originally implemented __getattr__ (a573dcb) to auto-load config @@ -181,7 +181,7 @@ def test_undefined(): assert not hasattr(c, "UNDEFINED") -def test_required_missing(): +def test_required_missing() -> None: class TestConf(GoodConf): a: str = Field() @@ -194,7 +194,7 @@ class TestConf(GoodConf): TestConf(load=True) -def test_default_values_are_used(monkeypatch): +def test_default_values_are_used(monkeypatch: pytest.MonkeyPatch) -> None: """ Covers regression in: https://github.com/lincolnloop/goodconf/pull/51 @@ -217,7 +217,7 @@ class TestConf(GoodConf): assert c.c == "default_for_c" -def test_set_on_init(): +def test_set_on_init() -> None: class TestConf(GoodConf): a: str = Field() @@ -226,7 +226,7 @@ class TestConf(GoodConf): assert c.a == val -def test_env_prefix(): +def test_env_prefix() -> None: class TestConf(GoodConf): a: bool = False @@ -238,7 +238,7 @@ class TestConf(GoodConf): assert c.a -def test_precedence(tmpdir): +def test_precedence(tmpdir: Any) -> None: path = tmpdir.join("myapp.json") path.write(json.dumps({"init": "file", "env": "file", "file": "file"})) @@ -259,18 +259,18 @@ class TestConf(GoodConf, default_files=[path]): del os.environ["ENV"] -def test_fileconfigsettingssource_repr(): +def test_fileconfigsettingssource_repr() -> None: class SettingsClass: - model_config = {} + model_config: dict[str, Any] = {} fileconfigsettingssource = FileConfigSettingsSource(SettingsClass) # type: ignore[arg-type] assert repr(fileconfigsettingssource) == "FileConfigSettingsSource()" -def test_fileconfigsettingssource_get_field_value(): +def test_fileconfigsettingssource_get_field_value() -> None: class SettingsClass: - model_config = {} + model_config: dict[str, Any] = {} fileconfigsettingssource = FileConfigSettingsSource(SettingsClass) # type: ignore[arg-type] field = FieldInfo(title="testfield") diff --git a/tests/test_initial.py b/tests/test_initial.py index d09a75c..62496f1 100644 --- a/tests/test_initial.py +++ b/tests/test_initial.py @@ -5,14 +5,14 @@ from .utils import KEY -def test_initial(): +def test_initial() -> None: class C(GoodConf): f: str = Field(initial=lambda: "x") assert initial_for_field(KEY, C.model_fields["f"]) == "x" -def test_initial_bad(): +def test_initial_bad() -> None: class C(GoodConf): f: str = Field(initial="x") # type: ignore[arg-type] @@ -20,28 +20,28 @@ class C(GoodConf): initial_for_field(KEY, C.model_fields["f"]) -def test_initial_default(): +def test_initial_default() -> None: class C(GoodConf): f: str = Field("x") assert initial_for_field(KEY, C.model_fields["f"]) == "x" -def test_initial_default_factory(): +def test_initial_default_factory() -> None: class C(GoodConf): f: str = Field(default_factory=lambda: "y") assert initial_for_field(KEY, C.model_fields["f"]) == "y" -def test_no_initial(): +def test_no_initial() -> None: class C(GoodConf): f: str = Field() assert initial_for_field(KEY, C.model_fields["f"]) == "" -def test_default_initial(): +def test_default_initial() -> None: """Can get initial when Field is not used""" class G(GoodConf): @@ -51,7 +51,7 @@ class G(GoodConf): assert initial["a"] == "test" -def test_optional_initial(): +def test_optional_initial() -> None: class G(GoodConf): a: str | None From 10d59217b7639a1768a2013d274b8a8c113c79fb Mon Sep 17 00:00:00 2001 From: Marc Gibbons <1726961+marcgibbons@users.noreply.github.com> Date: Tue, 10 Mar 2026 10:23:06 -0400 Subject: [PATCH 14/26] fix: migrate tmpdir -> tmp_path in test_file_helpers --- tests/test_file_helpers.py | 53 +++++++++++++++++++------------------- 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/tests/test_file_helpers.py b/tests/test_file_helpers.py index 25245d7..85c6f06 100644 --- a/tests/test_file_helpers.py +++ b/tests/test_file_helpers.py @@ -1,67 +1,68 @@ import os import sys +from pathlib import Path import pytest from goodconf import _find_file, _load_config -def test_json(tmpdir): - conf = tmpdir.join("conf.json") - conf.write('{"a": "b", "c": 3}') +def test_json(tmp_path: Path) -> None: + conf = tmp_path / "conf.json" + conf.write_text('{"a": "b", "c": 3}') assert _load_config(str(conf)) == {"a": "b", "c": 3} -def test_load_toml(tmpdir): +def test_load_toml(tmp_path: Path) -> None: if sys.version_info < (3, 11): pytest.importorskip("tomlkit") - conf = tmpdir.join("conf.toml") - conf.write('a = "b"\nc = 3') + conf = tmp_path / "conf.toml" + conf.write_text('a = "b"\nc = 3') assert _load_config(str(conf)) == {"a": "b", "c": 3} -def test_load_empty_toml(tmpdir): +def test_load_empty_toml(tmp_path: Path) -> None: if sys.version_info < (3, 11): pytest.importorskip("tomlkit") - conf = tmpdir.join("conf.toml") - conf.write("") + conf = tmp_path / "conf.toml" + conf.write_text("") assert _load_config(str(conf)) == {} -def test_yaml(tmpdir): +def test_yaml(tmp_path: Path) -> None: pytest.importorskip("ruamel.yaml") - conf = tmpdir.join("conf.yaml") - conf.write("a: b\nc: 3") + conf = tmp_path / "conf.yaml" + conf.write_text("a: b\nc: 3") assert _load_config(str(conf)) == {"a": "b", "c": 3} -def test_load_empty_yaml(tmpdir): +def test_load_empty_yaml(tmp_path: Path) -> None: pytest.importorskip("ruamel.yaml") - conf = tmpdir.join("conf.yaml") - conf.write("") + conf = tmp_path / "conf.yaml" + conf.write_text("") assert _load_config(str(conf)) == {} -def test_missing(tmpdir): - conf = tmpdir.join("test.yml") +def test_missing(tmp_path: Path) -> None: + conf = tmp_path / "test.yml" assert _find_file(str(conf), require=False) is None -def test_missing_strict(tmpdir): - conf = tmpdir.join("test.yml") +def test_missing_strict(tmp_path: Path) -> None: + conf = tmp_path / "test.yml" with pytest.raises(FileNotFoundError): _find_file(str(conf)) -def test_abspath(tmpdir): - conf = tmpdir.join("test.yml") - conf.write("") +def test_abspath(tmp_path: Path) -> None: + conf = tmp_path / "test.yml" + conf.write_text("") path = _find_file(str(conf)) assert path == str(conf) -def test_relative(tmpdir): - conf = tmpdir.join("test.yml") - conf.write("") - os.chdir(conf.dirname) +def test_relative(tmp_path: Path) -> None: + conf = tmp_path / "test.yml" + conf.write_text("") + os.chdir(conf.parent) assert _find_file("test.yml") == str(conf) From 601f4ce0c1be579e411b3de644747b518e041d1b Mon Sep 17 00:00:00 2001 From: Marc Gibbons <1726961+marcgibbons@users.noreply.github.com> Date: Tue, 10 Mar 2026 10:24:08 -0400 Subject: [PATCH 15/26] fix: migrate tmpdir -> tmp_path, annotate mocker in test_files --- tests/test_files.py | 39 +++++++++++++++++++++------------------ 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/tests/test_files.py b/tests/test_files.py index b08c249..fe94854 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -1,14 +1,17 @@ import json +from pathlib import Path + +from pytest_mock import MockerFixture from goodconf import GoodConf from .utils import env_var -def test_conf_env_var(mocker, tmpdir): +def test_conf_env_var(mocker: MockerFixture, tmp_path: Path) -> None: mocked_load_config = mocker.patch("goodconf._load_config") - path = tmpdir.join("myapp.json") - path.write("") + path = tmp_path / "myapp.json" + path.write_text("") class G(GoodConf): model_config = {"file_env_var": "CONF"} @@ -19,15 +22,15 @@ class G(GoodConf): 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})) +def test_conflict(tmp_path: Path) -> None: + path = tmp_path / "myapp.json" + path.write_text(json.dumps({"A": 1, "B": 2})) class G(GoodConf): A: int B: int - model_config = {"default_files": [path]} + model_config = {"default_files": [str(path)]} with env_var("A", "3"): g = G() @@ -36,7 +39,7 @@ class G(GoodConf): assert g.B == 2 -def test_all_env_vars(mocker): +def test_all_env_vars(mocker: MockerFixture) -> None: mocked_set_values = mocker.patch("goodconf.BaseSettings.__init__") mocked_load_config = mocker.patch("goodconf._load_config") @@ -49,10 +52,10 @@ class G(GoodConf): mocked_load_config.assert_not_called() -def test_provided_file(mocker, tmpdir): +def test_provided_file(mocker: MockerFixture, tmp_path: Path) -> None: mocked_load_config = mocker.patch("goodconf._load_config") - path = tmpdir.join("myapp.json") - path.write("") + path = tmp_path / "myapp.json" + path.write_text("") class G(GoodConf): pass @@ -62,10 +65,10 @@ class G(GoodConf): mocked_load_config.assert_called_once_with(str(path)) -def test_provided_file_from_init(mocker, tmpdir): +def test_provided_file_from_init(mocker: MockerFixture, tmp_path: Path) -> None: mocked_load_config = mocker.patch("goodconf._load_config") - path = tmpdir.join("myapp.json") - path.write("") + path = tmp_path / "myapp.json" + path.write_text("") class G(GoodConf): pass @@ -75,11 +78,11 @@ class G(GoodConf): mocked_load_config.assert_called_once_with(str(path)) -def test_default_files(mocker, tmpdir): +def test_default_files(mocker: MockerFixture, tmp_path: Path) -> None: mocked_load_config = mocker.patch("goodconf._load_config") - path = tmpdir.join("myapp.json") - path.write("") - bad_path = tmpdir.join("does-not-exist.json") + path = tmp_path / "myapp.json" + path.write_text("") + bad_path = tmp_path / "does-not-exist.json" class G(GoodConf): model_config = {"default_files": [str(bad_path), str(path)]} From fb1460130c02027a6986195aecf28554caba4326 Mon Sep 17 00:00:00 2001 From: Marc Gibbons <1726961+marcgibbons@users.noreply.github.com> Date: Tue, 10 Mar 2026 10:25:46 -0400 Subject: [PATCH 16/26] fix: migrate tmpdir -> tmp_path in test_django and test_goodconf, annotate capsys --- tests/test_django.py | 16 ++++++++++------ tests/test_goodconf.py | 9 +++++---- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/tests/test_django.py b/tests/test_django.py index d5dd924..3e39b12 100644 --- a/tests/test_django.py +++ b/tests/test_django.py @@ -1,17 +1,19 @@ import sys +from pathlib import Path import pytest +from pytest_mock import MockerFixture from goodconf import GoodConf, GoodConfConfigDict pytest.importorskip("django") -def test_mgmt_command(mocker, tmpdir): +def test_mgmt_command(mocker: MockerFixture, tmp_path: Path) -> None: 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("") + temp_config = tmp_path / "config.yml" + temp_config.write_text("") class G(GoodConf): model_config = GoodConfConfigDict() @@ -23,11 +25,13 @@ class G(GoodConf): mocked_dj_execute.assert_called_once_with(dj_args) -def test_help(mocker, tmpdir, capsys): +def test_help( + mocker: MockerFixture, tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: mocker.patch("sys.exit") mocked_load_config = mocker.patch("goodconf._load_config") - temp_config = tmpdir.join("config.yml") - temp_config.write("") + temp_config = tmp_path / "config.yml" + temp_config.write_text("") class G(GoodConf): model_config = GoodConfConfigDict( diff --git a/tests/test_goodconf.py b/tests/test_goodconf.py index 5d0fa77..898fa51 100644 --- a/tests/test_goodconf.py +++ b/tests/test_goodconf.py @@ -1,6 +1,7 @@ import json import os import re +from pathlib import Path from textwrap import dedent from typing import Any, Literal @@ -238,11 +239,11 @@ class TestConf(GoodConf): assert c.a -def test_precedence(tmpdir: Any) -> None: - path = tmpdir.join("myapp.json") - path.write(json.dumps({"init": "file", "env": "file", "file": "file"})) +def test_precedence(tmp_path: Path) -> None: + path = tmp_path / "myapp.json" + path.write_text(json.dumps({"init": "file", "env": "file", "file": "file"})) - class TestConf(GoodConf, default_files=[path]): + class TestConf(GoodConf, default_files=[str(path)]): init: str = "" env: str = "" file: str = "" From 1977abc06f57f31b66a4b55783993fb989796d58 Mon Sep 17 00:00:00 2001 From: Marc Gibbons <1726961+marcgibbons@users.noreply.github.com> Date: Tue, 10 Mar 2026 10:53:46 -0400 Subject: [PATCH 17/26] =?UTF-8?q?fix:=20N802=20=E2=80=94=20noqa=20on=20Fie?= =?UTF-8?q?ld,=20intentional=20public=20API=20name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- goodconf/__init__.py | 2 +- pyproject.toml | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/goodconf/__init__.py b/goodconf/__init__.py index 103a561..ff9626a 100644 --- a/goodconf/__init__.py +++ b/goodconf/__init__.py @@ -34,7 +34,7 @@ log = logging.getLogger(__name__) -def Field( +def Field( # noqa: N802 *args: Any, initial: Callable[[], Any] | None = None, json_schema_extra: dict[str, Any] | None = None, diff --git a/pyproject.toml b/pyproject.toml index ad960eb..20be88d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -107,7 +107,6 @@ ignore = [ "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 ] From f1428cf59067a3f6e988742778715c9cef835323 Mon Sep 17 00:00:00 2001 From: Marc Gibbons <1726961+marcgibbons@users.noreply.github.com> Date: Tue, 10 Mar 2026 10:58:59 -0400 Subject: [PATCH 18/26] =?UTF-8?q?fix:=20ARG002=20=E2=80=94=20noqa=20on=20g?= =?UTF-8?q?et=5Ffield=5Fvalue,=20args=20required=20by=20pydantic-settings?= =?UTF-8?q?=20abstract=20interface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- goodconf/__init__.py | 4 +++- pyproject.toml | 1 - 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/goodconf/__init__.py b/goodconf/__init__.py index ff9626a..265f1a5 100644 --- a/goodconf/__init__.py +++ b/goodconf/__init__.py @@ -153,7 +153,9 @@ def __init__(self, settings_cls: type[BaseSettings]) -> None: super().__init__(settings_cls) def get_field_value( - self, field: FieldInfo, field_name: str + self, + field: FieldInfo, # noqa: ARG002 + field_name: str, # noqa: ARG002 ) -> tuple[Any, str, bool]: # Nothing to do here. Only implement the return statement to make mypy happy return None, "", False diff --git a/pyproject.toml b/pyproject.toml index 20be88d..5c1945f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,7 +101,6 @@ ignore = [ "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 From 10f5129f612932e1e23b097e07809e304ebd7fdf Mon Sep 17 00:00:00 2001 From: Marc Gibbons <1726961+marcgibbons@users.noreply.github.com> Date: Tue, 10 Mar 2026 12:28:12 -0400 Subject: [PATCH 19/26] =?UTF-8?q?fix:=20ARG001=20=E2=80=94=20noqa=20on=20s?= =?UTF-8?q?tream=20shims=20and=20patched=5Fparser,=20args=20required=20by?= =?UTF-8?q?=20calling=20convention?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- goodconf/__init__.py | 4 ++-- goodconf/contrib/django.py | 5 ++++- pyproject.toml | 1 - 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/goodconf/__init__.py b/goodconf/__init__.py index 265f1a5..a3ed217 100644 --- a/goodconf/__init__.py +++ b/goodconf/__init__.py @@ -83,12 +83,12 @@ def _load_config(path: str) -> dict[str, Any]: try: import tomllib - def load(stream: Any) -> Any: + def load(stream: Any) -> Any: # noqa: ARG001 return tomllib.loads(f.read()) except ImportError: # Fallback for Python < 3.11 import tomlkit - def load(stream: Any) -> Any: + def load(stream: Any) -> Any: # noqa: ARG001 return tomlkit.load(f).unwrap() loader = load diff --git a/goodconf/contrib/django.py b/goodconf/contrib/django.py index 385d505..1b7051f 100644 --- a/goodconf/contrib/django.py +++ b/goodconf/contrib/django.py @@ -19,7 +19,10 @@ def load_config_from_cli( original_parser = BaseCommand.create_parser def patched_parser( - self: BaseCommand, prog_name: str, subcommand: str, **kwargs: Any + self: BaseCommand, + prog_name: str, + subcommand: str, + **kwargs: Any, # noqa: ARG001 ) -> CommandParser: parser = original_parser(self, prog_name, subcommand) argparser_add_argument(parser, config) diff --git a/pyproject.toml b/pyproject.toml index 5c1945f..07fc3fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,7 +100,6 @@ 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 "COM812", # (ruff format) Checks for the absence of trailing commas "D", # Missing or badly formatted docstrings "E501", # Let the formatter handle long lines From 8b62075779456a09145c4af6b99e03d537154772 Mon Sep 17 00:00:00 2001 From: Marc Gibbons <1726961+marcgibbons@users.noreply.github.com> Date: Tue, 10 Mar 2026 12:30:01 -0400 Subject: [PATCH 20/26] =?UTF-8?q?fix:=20A001=20=E2=80=94=20rename=20help?= =?UTF-8?q?=20->=20help=5Ftext=20in=20argparser=5Fadd=5Fargument?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- goodconf/contrib/argparse.py | 8 ++++---- pyproject.toml | 1 - 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/goodconf/contrib/argparse.py b/goodconf/contrib/argparse.py index 8babac9..fb3399b 100644 --- a/goodconf/contrib/argparse.py +++ b/goodconf/contrib/argparse.py @@ -5,14 +5,14 @@ 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 default_files := cfg.get("default_files"): files_str = ", ".join(default_files) - help += f" Defaults to the first file that exists from [{files_str}]." - parser.add_argument("-C", "--config", metavar="FILE", help=help) + help_text += f" Defaults to the first file that exists from [{files_str}]." + parser.add_argument("-C", "--config", metavar="FILE", help=help_text) diff --git a/pyproject.toml b/pyproject.toml index 07fc3fb..06dfe5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -98,7 +98,6 @@ exclude = ["goodconf/_version.py"] [tool.ruff.lint] ignore = [ # Permanently suppressed - "A001", # Variable shadowing builtin (intentional: `help` in argparse) "ANN401", # Dynamically typed expressions (typing.Any) are disallowed in `**kwargs` "COM812", # (ruff format) Checks for the absence of trailing commas "D", # Missing or badly formatted docstrings From 55e4bbd449db4de15f298db342781b74c538915a Mon Sep 17 00:00:00 2001 From: Marc Gibbons <1726961+marcgibbons@users.noreply.github.com> Date: Tue, 10 Mar 2026 12:32:40 -0400 Subject: [PATCH 21/26] =?UTF-8?q?fix:=20E501=20=E2=80=94=20remove=20ignore?= =?UTF-8?q?,=20formatter=20handles=20line=20length?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 06dfe5e..8fe7be1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,7 +101,6 @@ ignore = [ "ANN401", # Dynamically typed expressions (typing.Any) are disallowed in `**kwargs` "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 "PLC0415", # Import not at top level (intentional: lazy imports for optional deps) From 0713856bccf5615a95fc72d63055699c94a1ef95 Mon Sep 17 00:00:00 2001 From: Marc Gibbons <1726961+marcgibbons@users.noreply.github.com> Date: Tue, 10 Mar 2026 12:33:15 -0400 Subject: [PATCH 22/26] =?UTF-8?q?fix:=20ISC001=20=E2=80=94=20remove=20igno?= =?UTF-8?q?re,=20ruff=20format=20handles=20implicit=20string=20concatenati?= =?UTF-8?q?on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8fe7be1..929ef64 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -102,7 +102,6 @@ ignore = [ "COM812", # (ruff format) Checks for the absence of trailing commas "D", # Missing or badly formatted docstrings "FBT", # Flake Boolean Trap (don't use arg=True in functions) - "ISC001", # (ruff format) Checks for implicitly concatenated strings on a single line "PLC0415", # Import not at top level (intentional: lazy imports for optional deps) "RUF012", # Mutable class attributes https://github.com/astral-sh/ruff/issues/5243 ] From 8d6799372b5e0d328505242d9fad3c7dadb3b67e Mon Sep 17 00:00:00 2001 From: Marc Gibbons <1726961+marcgibbons@users.noreply.github.com> Date: Tue, 10 Mar 2026 12:34:56 -0400 Subject: [PATCH 23/26] =?UTF-8?q?fix:=20PLC0415=20=E2=80=94=20noqa=20on=20?= =?UTF-8?q?lazy=20imports=20for=20optional=20deps=20(yaml,=20toml)=20and?= =?UTF-8?q?=20Django?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- goodconf/__init__.py | 14 ++++++++------ goodconf/contrib/django.py | 4 ++-- pyproject.toml | 1 - 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/goodconf/__init__.py b/goodconf/__init__.py index a3ed217..05dd2ea 100644 --- a/goodconf/__init__.py +++ b/goodconf/__init__.py @@ -75,18 +75,18 @@ def _load_config(path: str) -> dict[str, Any]: loader: Callable[..., 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: Any) -> Any: # noqa: ARG001 return tomllib.loads(f.read()) except ImportError: # Fallback for Python < 3.11 - import tomlkit + import tomlkit # noqa: PLC0415 def load(stream: Any) -> Any: # noqa: ARG001 return tomlkit.load(f).unwrap() @@ -261,7 +261,7 @@ def generate_yaml(cls, **override: Any) -> str: """ Dumps initial config in YAML """ - import ruamel.yaml + import ruamel.yaml # noqa: PLC0415 yaml = ruamel.yaml.YAML() yaml.representer.add_representer( @@ -297,7 +297,7 @@ def generate_toml(cls, **override: Any) -> str: """ Dumps initial config in TOML """ - import tomlkit + import tomlkit # noqa: PLC0415 toml_str = tomlkit.dumps(cls.get_initial(**override)) dict_from_toml = tomlkit.loads(toml_str) @@ -338,6 +338,8 @@ def generate_markdown(cls) -> str: 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) diff --git a/goodconf/contrib/django.py b/goodconf/contrib/django.py index 1b7051f..08eb19d 100644 --- a/goodconf/contrib/django.py +++ b/goodconf/contrib/django.py @@ -14,7 +14,7 @@ def load_config_from_cli( """Loads config, checking CLI arguments for a config file""" # Monkey patch Django's command parser - from django.core.management.base import BaseCommand, CommandParser + from django.core.management.base import BaseCommand, CommandParser # noqa: PLC0415 original_parser = BaseCommand.create_parser @@ -45,6 +45,6 @@ def patched_parser( def execute_from_command_line_with_config(config: GoodConf, argv: list[str]) -> None: """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 + from django.core.management import execute_from_command_line # noqa: PLC0415 execute_from_command_line(args) diff --git a/pyproject.toml b/pyproject.toml index 929ef64..71839e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -102,7 +102,6 @@ ignore = [ "COM812", # (ruff format) Checks for the absence of trailing commas "D", # Missing or badly formatted docstrings "FBT", # Flake Boolean Trap (don't use arg=True in functions) - "PLC0415", # Import not at top level (intentional: lazy imports for optional deps) "RUF012", # Mutable class attributes https://github.com/astral-sh/ruff/issues/5243 ] select = ["ALL"] From 9d2ced90f3efec783ed84d7b6f728d60f996a557 Mon Sep 17 00:00:00 2001 From: Marc Gibbons <1726961+marcgibbons@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:02:32 -0400 Subject: [PATCH 24/26] fix: remove ANN401 global suppress, inline noqa on dynamic Any --- .github/workflows/pre-commit.yml | 28 ---------------------------- .github/workflows/test.yml | 4 ++++ .pre-commit-config.yaml | 3 +++ goodconf/__init__.py | 25 ++++++++++++++----------- goodconf/contrib/django.py | 2 +- pyproject.toml | 1 - 6 files changed, 22 insertions(+), 41 deletions(-) delete mode 100644 .github/workflows/pre-commit.yml diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml deleted file mode 100644 index 967d02b..0000000 --- a/.github/workflows/pre-commit.yml +++ /dev/null @@ -1,28 +0,0 @@ ---- -name: pre-commit - -env: - GIT_REF: ${{ github.event.pull_request.head.sha || github.sha }} - -"on": - push: - branches: [main] - pull_request: - workflow_dispatch: - -concurrency: - # Abort pending builds if there's an update. - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - pre-commit: - runs-on: ubuntu-latest - steps: - - uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7 - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - ref: ${{ env.GIT_REF }} - - name: Install dependencies - run: uv sync --group dev - - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3186e83..1b03273 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,6 +33,10 @@ 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 goodconf tests + - name: Upload coverage to Codecov if: ${{ (success() || failure()) && matrix.python == '3.14' }} uses: codecov/codecov-action@v5 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ab180c9..a7904b0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,4 +1,7 @@ --- +ci: + skip: [mypy] + repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 diff --git a/goodconf/__init__.py b/goodconf/__init__.py index 05dd2ea..0f566f0 100644 --- a/goodconf/__init__.py +++ b/goodconf/__init__.py @@ -35,10 +35,10 @@ def Field( # noqa: N802 - *args: Any, + *args: Any, # noqa: ANN401 initial: Callable[[], Any] | None = None, json_schema_extra: dict[str, Any] | None = None, - **kwargs: Any, + **kwargs: Any, # noqa: ANN401 ) -> FieldInfo: if initial: json_schema_extra = json_schema_extra or {} @@ -83,12 +83,12 @@ def _load_config(path: str) -> dict[str, Any]: try: import tomllib # noqa: PLC0415 - def load(stream: Any) -> Any: # noqa: ARG001 + def load(stream: object) -> dict[str, Any]: # noqa: ARG001 return tomllib.loads(f.read()) except ImportError: # Fallback for Python < 3.11 import tomlkit # noqa: PLC0415 - def load(stream: Any) -> Any: # noqa: ARG001 + def load(stream: object) -> dict[str, Any]: # noqa: ARG001 return tomlkit.load(f).unwrap() loader = load @@ -128,7 +128,7 @@ def _fieldinfo_to_str(field_info: FieldInfo) -> str: return field_type -def initial_for_field(name: str, field_info: FieldInfo) -> Any: +def initial_for_field(name: str, field_info: FieldInfo) -> 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"]): @@ -188,7 +188,10 @@ def __repr__(self) -> str: class GoodConf(BaseSettings): def __init__( - self, load: bool = False, config_file: str | None = None, **kwargs: Any + self, + load: bool = False, + config_file: str | None = None, + **kwargs: Any, # noqa: ANN401 ) -> None: """ :param load: load config file on instantiation [default: False]. @@ -240,7 +243,7 @@ def _load( self, _config_file: str | None = None, _init_config_file: str | None = None, - **kwargs: Any, + **kwargs: Any, # noqa: ANN401 ) -> None: if config_file := _config_file or _init_config_file: kwargs["_config_file"] = config_file @@ -250,14 +253,14 @@ def load(self, filename: str | None = None) -> None: self._load(_config_file=filename) @classmethod - def get_initial(cls, **override: Any) -> dict[str, Any]: + def get_initial(cls, **override: Any) -> dict[str, 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: Any) -> str: + def generate_yaml(cls, **override: Any) -> str: # noqa: ANN401 """ Dumps initial config in YAML """ @@ -286,14 +289,14 @@ def generate_yaml(cls, **override: Any) -> str: return yaml_str.read() @classmethod - def generate_json(cls, **override: Any) -> str: + def generate_json(cls, **override: Any) -> str: # noqa: ANN401 """ Dumps initial config in JSON """ return json.dumps(cls.get_initial(**override), indent=2) @classmethod - def generate_toml(cls, **override: Any) -> str: + def generate_toml(cls, **override: Any) -> str: # noqa: ANN401 """ Dumps initial config in TOML """ diff --git a/goodconf/contrib/django.py b/goodconf/contrib/django.py index 08eb19d..3591b1f 100644 --- a/goodconf/contrib/django.py +++ b/goodconf/contrib/django.py @@ -22,7 +22,7 @@ def patched_parser( self: BaseCommand, prog_name: str, subcommand: str, - **kwargs: Any, # noqa: ARG001 + **kwargs: Any, # noqa: ANN401, ARG001 ) -> CommandParser: parser = original_parser(self, prog_name, subcommand) argparser_add_argument(parser, config) diff --git a/pyproject.toml b/pyproject.toml index 71839e7..a0872cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -98,7 +98,6 @@ exclude = ["goodconf/_version.py"] [tool.ruff.lint] ignore = [ # Permanently suppressed - "ANN401", # Dynamically typed expressions (typing.Any) are disallowed in `**kwargs` "COM812", # (ruff format) Checks for the absence of trailing commas "D", # Missing or badly formatted docstrings "FBT", # Flake Boolean Trap (don't use arg=True in functions) From b5176093030ee0e9ce262a2e86b062fed2dd2d4d Mon Sep 17 00:00:00 2001 From: Marc Gibbons <1726961+marcgibbons@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:10:09 -0400 Subject: [PATCH 25/26] fix: simplify mypy CI step to uv run mypy . --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1b03273..8dcd593 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,7 +35,7 @@ jobs: - name: mypy if: matrix.python == '3.14' - run: uv run mypy goodconf tests + run: uv run mypy . - name: Upload coverage to Codecov if: ${{ (success() || failure()) && matrix.python == '3.14' }} From 8373160c9129d63b3354d677454081813733c36f Mon Sep 17 00:00:00 2001 From: Marc Gibbons <1726961+marcgibbons@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:35:38 -0400 Subject: [PATCH 26/26] refactor: replace from-typing imports with import typing as t - Add if t.TYPE_CHECKING to coverage exclude_lines - Pin codecov-action to SHA v5.5.2 --- .github/workflows/test.yml | 4 +-- goodconf/__init__.py | 54 +++++++++++++++++++------------------- goodconf/contrib/django.py | 4 +-- pyproject.toml | 1 + tests/test_goodconf.py | 8 +++--- 5 files changed, 36 insertions(+), 35 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8dcd593..10cd591 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -39,14 +39,14 @@ jobs: - 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 diff --git a/goodconf/__init__.py b/goodconf/__init__.py index 0f566f0..f0df041 100644 --- a/goodconf/__init__.py +++ b/goodconf/__init__.py @@ -7,14 +7,14 @@ 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 TYPE_CHECKING, Any, cast, get_args -if TYPE_CHECKING: +if t.TYPE_CHECKING: from tomlkit.items import Item from pydantic._internal._config import config_keys @@ -35,16 +35,16 @@ def Field( # noqa: N802 - *args: Any, # noqa: ANN401 - initial: Callable[[], Any] | None = None, - json_schema_extra: dict[str, Any] | None = None, - **kwargs: Any, # noqa: ANN401 + *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 cast( + return t.cast( "FieldInfo", PydanticField(*args, json_schema_extra=json_schema_extra, **kwargs) ) @@ -66,13 +66,13 @@ 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. """ - loader: Callable[..., Any] + loader: Callable[..., t.Any] ext = Path(path).suffix if ext in [".yaml", ".yml"]: import ruamel.yaml # noqa: PLC0415 @@ -83,12 +83,12 @@ def _load_config(path: str) -> dict[str, Any]: try: import tomllib # noqa: PLC0415 - def load(stream: object) -> dict[str, Any]: # noqa: ARG001 + def load(stream: object) -> dict[str, t.Any]: # noqa: ARG001 return tomllib.loads(f.read()) except ImportError: # Fallback for Python < 3.11 import tomlkit # noqa: PLC0415 - def load(stream: object) -> dict[str, Any]: # noqa: ARG001 + def load(stream: object) -> dict[str, t.Any]: # noqa: ARG001 return tomlkit.load(f).unwrap() loader = load @@ -128,7 +128,7 @@ def _fieldinfo_to_str(field_info: FieldInfo) -> str: return field_type -def initial_for_field(name: str, field_info: FieldInfo) -> Any: # noqa: ANN401 +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"]): @@ -139,7 +139,7 @@ def initial_for_field(name: str, field_info: FieldInfo) -> Any: # noqa: ANN401 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 get_args(field_info.annotation): + if type(None) in t.get_args(field_info.annotation): return None return "" @@ -156,12 +156,12 @@ def get_field_value( self, field: FieldInfo, # noqa: ARG002 field_name: str, # noqa: ARG002 - ) -> tuple[Any, str, bool]: + ) -> 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 @@ -191,7 +191,7 @@ def __init__( self, load: bool = False, config_file: str | None = None, - **kwargs: Any, # noqa: ANN401 + **kwargs: t.Any, # noqa: ANN401 ) -> None: """ :param load: load config file on instantiation [default: False]. @@ -230,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, @@ -243,7 +243,7 @@ def _load( self, _config_file: str | None = None, _init_config_file: str | None = None, - **kwargs: Any, # noqa: ANN401 + **kwargs: t.Any, # noqa: ANN401 ) -> None: if config_file := _config_file or _init_config_file: kwargs["_config_file"] = config_file @@ -253,14 +253,14 @@ def load(self, filename: str | None = None) -> None: self._load(_config_file=filename) @classmethod - def get_initial(cls, **override: Any) -> dict[str, Any]: # noqa: ANN401 + 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: Any) -> str: # noqa: ANN401 + def generate_yaml(cls, **override: t.Any) -> str: # noqa: ANN401 """ Dumps initial config in YAML """ @@ -279,7 +279,7 @@ def generate_yaml(cls, **override: Any) -> str: # noqa: ANN401 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 ) @@ -289,14 +289,14 @@ def generate_yaml(cls, **override: Any) -> str: # noqa: ANN401 return yaml_str.read() @classmethod - def generate_json(cls, **override: Any) -> str: # noqa: ANN401 + 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: Any) -> str: # noqa: ANN401 + def generate_toml(cls, **override: t.Any) -> str: # noqa: ANN401 """ Dumps initial config in TOML """ @@ -310,8 +310,8 @@ def generate_toml(cls, **override: Any) -> str: # noqa: ANN401 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 diff --git a/goodconf/contrib/django.py b/goodconf/contrib/django.py index 3591b1f..05b16e4 100644 --- a/goodconf/contrib/django.py +++ b/goodconf/contrib/django.py @@ -1,7 +1,7 @@ import argparse +import typing as t from collections.abc import Generator from contextlib import contextmanager -from typing import Any from goodconf import GoodConf from goodconf.contrib.argparse import argparser_add_argument @@ -22,7 +22,7 @@ def patched_parser( self: BaseCommand, prog_name: str, subcommand: str, - **kwargs: Any, # noqa: ANN401, ARG001 + **kwargs: t.Any, # noqa: ANN401, ARG001 ) -> CommandParser: parser = original_parser(self, prog_name, subcommand) argparser_add_argument(parser, config) diff --git a/pyproject.toml b/pyproject.toml index a0872cb..77d2b11 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,7 @@ homepage = "https://github.com/lincolnloop/goodconf/" [tool.coverage.report] exclude_lines = [ "if TYPE_CHECKING:", + "if t.TYPE_CHECKING:", "pragma: no cover", "raise NotImplementedError", ] diff --git a/tests/test_goodconf.py b/tests/test_goodconf.py index 898fa51..ccd719d 100644 --- a/tests/test_goodconf.py +++ b/tests/test_goodconf.py @@ -1,9 +1,9 @@ import json import os import re +import typing as t from pathlib import Path from textwrap import dedent -from typing import Any, Literal import pytest from pydantic import ValidationError @@ -150,7 +150,7 @@ class TestConf(GoodConf): def test_generate_markdown_types() -> None: class TestConf(GoodConf): - a: Literal["a", "b"] = Field(default="a") + a: t.Literal["a", "b"] = Field(default="a") b: list[str] = Field() c: None @@ -262,7 +262,7 @@ class TestConf(GoodConf, default_files=[str(path)]): def test_fileconfigsettingssource_repr() -> None: class SettingsClass: - model_config: dict[str, Any] = {} + model_config: dict[str, t.Any] = {} fileconfigsettingssource = FileConfigSettingsSource(SettingsClass) # type: ignore[arg-type] @@ -271,7 +271,7 @@ class SettingsClass: def test_fileconfigsettingssource_get_field_value() -> None: class SettingsClass: - model_config: dict[str, Any] = {} + model_config: dict[str, t.Any] = {} fileconfigsettingssource = FileConfigSettingsSource(SettingsClass) # type: ignore[arg-type] field = FieldInfo(title="testfield")