diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3186e83..10cd591 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 14a15ca..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 @@ -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] 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" diff --git a/goodconf/__init__.py b/goodconf/__init__.py index 424b112..f0df041 100644 --- a/goodconf/__init__.py +++ b/goodconf/__init__.py @@ -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: @@ -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: @@ -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 "" @@ -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 @@ -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]. @@ -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, @@ -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) @@ -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( @@ -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 ) @@ -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) @@ -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 @@ -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) diff --git a/goodconf/contrib/argparse.py b/goodconf/contrib/argparse.py index 8aa1f9f..fb3399b 100644 --- a/goodconf/contrib/argparse.py +++ b/goodconf/contrib/argparse.py @@ -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) diff --git a/goodconf/contrib/django.py b/goodconf/contrib/django.py index c2b3f70..05b16e4 100644 --- a/goodconf/contrib/django.py +++ b/goodconf/contrib/django.py @@ -1,9 +1,10 @@ import argparse +import typing as t 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 @@ -13,16 +14,21 @@ 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 # noqa: PLC0415 original_parser = BaseCommand.create_parser - def patched_parser(self, prog_name, subcommand): + def patched_parser( + self: BaseCommand, + prog_name: str, + subcommand: str, + **kwargs: t.Any, # noqa: ANN401, ARG001 + ) -> CommandParser: parser = original_parser(self, prog_name, subcommand) argparser_add_argument(parser, config) return parser - BaseCommand.create_parser = patched_parser + BaseCommand.create_parser = patched_parser # type: ignore[method-assign] try: parser = argparse.ArgumentParser(add_help=False) @@ -33,12 +39,12 @@ 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]): +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 4751b17..77d2b11 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.*", @@ -52,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", ] @@ -78,34 +80,29 @@ exclude = [ source = "vcs" [tool.mypy] -ignore_errors = true +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"] +[tool.ruff] +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` - "ARG001", # Unused function argument - "ARG002", # Unused method argument "COM812", # (ruff format) Checks for the absence of trailing commas "D", # Missing or badly formatted docstrings - "E501", # Let the formatter handle long lines "FBT", # Flake Boolean Trap (don't use arg=True in functions) - "ISC001", # (ruff format) Checks for implicitly concatenated strings on a single line - "N802", # Function name should be lowercase (Field is a public API name) - "PLC0415", # Import not at top level (intentional: lazy imports for optional deps) "RUF012", # Mutable class attributes https://github.com/astral-sh/ruff/issues/5243 - # Temporary — see docs/plans/ruff-cleanup.md - "ANN", # Missing type annotations (annotation work deferred) - "EM102", # Exception message in f-string - "PTH", # Use pathlib instead of os.path (refactoring deferred) - "TC002", # Move import to TYPE_CHECKING block - "TID252", # Relative imports - "TRY003", # Long exception messages - "TRY004", # Prefer TypeError over AttributeError ] select = ["ALL"] diff --git a/tests/test_django.py b/tests/test_django.py index 06ad5ed..3e39b12 100644 --- a/tests/test_django.py +++ b/tests/test_django.py @@ -1,21 +1,22 @@ import sys +from pathlib import Path import pytest -from pydantic import ConfigDict +from pytest_mock import MockerFixture -from goodconf import GoodConf +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 = ConfigDict() + model_config = GoodConfConfigDict() c = G() dj_args = ["manage.py", "diffsettings", "-v", "2"] @@ -24,14 +25,16 @@ 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 = ConfigDict( + model_config = GoodConfConfigDict( file_env_var="MYAPP_CONF", default_files=["/etc/myapp.json"], ) 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) 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)]} diff --git a/tests/test_goodconf.py b/tests/test_goodconf.py index 8a48ccb..ccd719d 100644 --- a/tests/test_goodconf.py +++ b/tests/test_goodconf.py @@ -1,8 +1,9 @@ import json import os import re +import typing as t +from pathlib import Path from textwrap import dedent -from typing import Literal import pytest from pydantic import ValidationError @@ -12,7 +13,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 +24,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 +33,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): @@ -43,7 +44,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") @@ -56,7 +57,7 @@ class TestConf(GoodConf): assert 'b = ""' in output -def test_dump_yaml(): +def test_dump_yaml() -> None: pytest.importorskip("ruamel.yaml") class TestConf(GoodConf): @@ -84,7 +85,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 +101,7 @@ class TestConf(GoodConf): ) -def test_dump_yaml_none(): +def test_dump_yaml_none() -> None: pytest.importorskip("ruamel.yaml") class TestConf(GoodConf): @@ -110,7 +111,7 @@ class TestConf(GoodConf): assert output.strip() == "a: ~" -def test_generate_markdown(): +def test_generate_markdown() -> None: help_ = "this is a" class TestConf(GoodConf): @@ -122,11 +123,11 @@ 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 -def test_generate_markdown_no_docstring(): +def test_generate_markdown_no_docstring() -> None: help_ = "this is a" class TestConf(GoodConf): @@ -138,7 +139,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,9 +148,9 @@ 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") + a: t.Literal["a", "b"] = Field(default="a") b: list[str] = Field() c: None @@ -159,7 +160,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,13 +168,21 @@ 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 + 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 + """ c = GoodConf() - with pytest.raises(AttributeError): - c.UNDEFINED # noqa: B018 + assert not hasattr(c, "UNDEFINED") -def test_required_missing(): +def test_required_missing() -> None: class TestConf(GoodConf): a: str = Field() @@ -186,7 +195,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 @@ -209,7 +218,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() @@ -218,7 +227,7 @@ class TestConf(GoodConf): assert c.a == val -def test_env_prefix(): +def test_env_prefix() -> None: class TestConf(GoodConf): a: bool = False @@ -230,11 +239,11 @@ class TestConf(GoodConf): assert c.a -def test_precedence(tmpdir): - 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 = "" @@ -251,24 +260,24 @@ 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, t.Any] = {} - fileconfigsettingssource = FileConfigSettingsSource(SettingsClass) + 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, t.Any] = {} - 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 81e0ec2..62496f1 100644 --- a/tests/test_initial.py +++ b/tests/test_initial.py @@ -5,43 +5,43 @@ 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") + f: str = Field(initial="x") # type: ignore[arg-type] - with pytest.raises(ValueError, match="callable"): + with pytest.raises(TypeError, match="callable"): 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,9 +51,9 @@ class G(GoodConf): assert initial["a"] == "test" -def test_optional_initial(): +def test_optional_initial() -> None: class G(GoodConf): a: str | None - initial = G().get_initial() + initial = G.get_initial() assert initial["a"] is None 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