From 595555d03637117cfaa13e212917d3ae8ba6ffcf Mon Sep 17 00:00:00 2001 From: Max Jones <14077947+maxrjones@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:21:24 -0400 Subject: [PATCH 01/10] types: make donfig strict-clean and run mypy via a dependency group Co-Authored-By: Claude Opus 4.8 (1M context) --- .pre-commit-config.yaml | 27 +++++----- donfig/_lock.py | 23 ++++----- donfig/config_obj.py | 87 ++++++++++++++++++-------------- donfig/tests/test_config.py | 98 +++++++++++++++++++------------------ donfig/tests/test_lock.py | 8 +-- donfig/utils.py | 3 +- pyproject.toml | 25 ++++++++-- 7 files changed, 151 insertions(+), 120 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0fc5b88..018a9a9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,24 +20,23 @@ repos: hooks: - id: flake8 language_version: python3 - - repo: https://github.com/pre-commit/mirrors-mypy - rev: v2.1.0 + # mypy runs via `uv run --group typecheck`, so the type stubs live in the + # `typecheck` dependency group (pyproject.toml) rather than being duplicated + # here as additional_dependencies. + - repo: local hooks: - id: mypy - # Override default --ignore-missing-imports - # Use pyproject.toml if possible instead of adding command line parameters here - args: [ --warn-unused-configs ] - additional_dependencies: - # Type stubs - - types-docutils - - types-PyYAML - - types-setuptools - # Typed libraries - - numpy - - pytest + name: mypy + language: system + entry: uv run --group typecheck mypy + pass_filenames: false + always_run: true + types_or: [python, pyi] ci: # To trigger manually, comment on a pull request with "pre-commit.ci autofix" autofix_prs: false autoupdate_schedule: "monthly" - skip: [] + # mypy is a `language: system` hook needing uv + the repo's dependency + # groups, which are unavailable on pre-commit.ci runners; run it in CI instead. + skip: [mypy] diff --git a/donfig/_lock.py b/donfig/_lock.py index 5b5bfaa..8d862e4 100644 --- a/donfig/_lock.py +++ b/donfig/_lock.py @@ -11,6 +11,7 @@ import uuid from threading import Lock +from typing import Any from weakref import WeakValueDictionary @@ -42,9 +43,9 @@ class SerializableLock: The creation of locks is itself not threadsafe. """ - _locks: WeakValueDictionary = WeakValueDictionary() + _locks: WeakValueDictionary[str, Lock] = WeakValueDictionary() - def __init__(self, token=None): + def __init__(self, token: str | None = None) -> None: self.token = token or str(uuid.uuid4()) if self.token in SerializableLock._locks: self.lock = SerializableLock._locks[self.token] @@ -52,28 +53,28 @@ def __init__(self, token=None): self.lock = Lock() SerializableLock._locks[self.token] = self.lock - def acquire(self, *args, **kwargs): + def acquire(self, *args: Any, **kwargs: Any) -> bool: return self.lock.acquire(*args, **kwargs) - def release(self, *args, **kwargs): + def release(self, *args: Any, **kwargs: Any) -> None: return self.lock.release(*args, **kwargs) - def __enter__(self): + def __enter__(self) -> None: self.lock.__enter__() - def __exit__(self, *args): + def __exit__(self, *args: Any) -> None: self.lock.__exit__(*args) - def locked(self): + def locked(self) -> bool: return self.lock.locked() - def __getstate__(self): + def __getstate__(self) -> str: return self.token - def __setstate__(self, token): - self.__init__(token) + def __setstate__(self, token: str) -> None: + self.__init__(token) # type: ignore[misc] - def __str__(self): + def __str__(self) -> str: return f"<{self.__class__.__name__}: {self.token}>" __repr__ = __str__ diff --git a/donfig/config_obj.py b/donfig/config_obj.py index 29a0035..6f3dadd 100644 --- a/donfig/config_obj.py +++ b/donfig/config_obj.py @@ -15,6 +15,7 @@ from collections.abc import Mapping, MutableMapping, Sequence from contextlib import nullcontext from copy import deepcopy +from types import TracebackType from typing import Any, Literal import yaml @@ -51,8 +52,8 @@ def update( old: MutableMapping[str, Any], new: Mapping[str, Any], priority: Literal["old", "new", "new-defaults"] = "new", - defaults: Mapping | None = None, -) -> Mapping[str, Any]: + defaults: Mapping[str, Any] | None = None, +) -> MutableMapping[str, Any]: """Update a nested dictionary with values from another This is like dict.update except that it smoothly merges nested values @@ -114,7 +115,7 @@ def update( return old -def merge(*dicts: Mapping) -> dict: +def merge(*dicts: Mapping[str, Any]) -> dict[str, Any]: """Update a sequence of nested dictionaries This prefers the values in the latter dictionaries to those in the former @@ -131,13 +132,13 @@ def merge(*dicts: Mapping) -> dict: donfig.config_obj.update """ - result: dict = {} + result: dict[str, Any] = {} for d in dicts: update(result, d) return result -def collect_yaml(paths: Sequence[str]) -> list[dict]: +def collect_yaml(paths: Sequence[str]) -> list[dict[str, Any]]: """Collect configuration from yaml files This searches through a list of paths, expands to find all yaml or json @@ -174,7 +175,7 @@ def collect_yaml(paths: Sequence[str]) -> list[dict]: return configs -def _load_config_file(path: str) -> dict | None: +def _load_config_file(path: str) -> dict[str, Any] | None: try: with open(path) as f: config = yaml.safe_load(f.read()) @@ -193,7 +194,7 @@ def _load_config_file(path: str) -> dict | None: def collect_env( prefix: str, env: Mapping[str, str] | None = None, deprecations: MutableMapping[str, str | None] | None = None -) -> dict: +) -> dict[str, Any]: """Collect config from environment variables This grabs environment variables of the form "DASK_FOO__BAR_BAZ=123" and @@ -223,7 +224,7 @@ def collect_env( except (SyntaxError, ValueError): d[varname] = value - result: dict = {} + result: dict[str, Any] = {} # fake thread lock to use set functionality lock = nullcontext() ConfigSet(result, lock, deprecations or {}, d) @@ -252,16 +253,16 @@ class ConfigSet: def __init__( self, - config: MutableMapping, - lock: SerializableLock | contextlib.AbstractContextManager, - deprecations: MutableMapping[str, str | None], - arg: Mapping | None = None, - **kwargs, - ): + config: MutableMapping[str, Any], + lock: SerializableLock | contextlib.AbstractContextManager[Any], + deprecations: Mapping[str, str | None], + arg: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> None: with lock: self.config = config self.deprecations = deprecations - self._record: list[tuple[str, tuple, Any]] = [] + self._record: list[tuple[str, tuple[str, ...], Any]] = [] if arg is not None: for key, value in arg.items(): @@ -273,7 +274,7 @@ def __init__( key = self._check_deprecations(key) self._assign(key.split("."), value, config) - def _check_deprecations(self, key: str): + def _check_deprecations(self, key: str) -> str: """Check if the provided value has been renamed or removed. Parameters @@ -298,10 +299,15 @@ def _check_deprecations(self, key: str): else: return key - def __enter__(self): + def __enter__(self) -> MutableMapping[str, Any]: return self.config - def __exit__(self, type, value, traceback): + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: for op, path, value in reversed(self._record): d = self.config if op == "replace": @@ -321,7 +327,7 @@ def _assign( self, keys: Sequence[str], value: Any, - d: MutableMapping, + d: MutableMapping[str, Any], path: tuple[str, ...] = (), record: bool = True, ) -> None: @@ -361,7 +367,7 @@ def _assign( self._assign(keys[1:], value, d[key], path, record=record) -def expand_environment_variables(config): +def expand_environment_variables(config: Any) -> Any: """Expand environment variables in a nested config dictionary This function will recursively search through any nested dictionaries @@ -396,7 +402,7 @@ class Config: def __init__( self, name: str, - defaults: list[Mapping[str, Any]] | None = None, + defaults: Sequence[Mapping[str, Any]] | None = None, paths: list[str] | None = None, env: Mapping[str, str] | None = None, env_var: str | None = None, @@ -436,27 +442,32 @@ def __init__( self.env = env self.main_path = main_path self.paths = paths - self.defaults = defaults or [] + # Preserve the historical contract that a caller-supplied ``defaults`` + # list is aliased (``update_defaults`` appends to it in place); only copy + # when a non-list Sequence is passed. + self.defaults: list[Mapping[str, Any]] = ( + defaults if isinstance(defaults, list) else list(defaults) if defaults else [] + ) self.deprecations = deprecations self.config: MutableMapping[str, Any] = {} self.config_lock = SerializableLock() self.refresh() - def __contains__(self, item): + def __contains__(self, item: Any) -> bool: try: self[item] return True except (TypeError, IndexError, KeyError): return False - def __getitem__(self, item): + def __getitem__(self, item: str) -> Any: return self.get(item) - def pprint(self, **kwargs): + def pprint(self, **kwargs: Any) -> None: return pprint.pprint(self.config, **kwargs) - def collect(self, paths: list[str] | None = None, env: Mapping[str, str] | None = None) -> dict: + def collect(self, paths: list[str] | None = None, env: Mapping[str, str] | None = None) -> dict[str, Any]: """Collect configuration from paths and environment variables Parameters @@ -482,16 +493,16 @@ def collect(self, paths: list[str] | None = None, env: Mapping[str, str] | None paths = self.paths if env is None: env = self.env - configs = [] + configs: list[Mapping[str, Any]] = [] - if yaml: - configs.extend(collect_yaml(paths=paths)) + # yaml is a hard dependency, so its loader is always available. + configs.extend(collect_yaml(paths=paths)) configs.append(collect_env(self.env_prefix, env=env)) return merge(*configs) - def refresh(self, **kwargs) -> None: + def refresh(self, **kwargs: Any) -> None: """Update configuration by re-reading yaml files and env variables. This goes through the following stages: @@ -555,7 +566,7 @@ def get(self, key: str, default: Any = no_default) -> Any: raise return result - def update_defaults(self, new: Mapping) -> None: + def update_defaults(self, new: Mapping[str, Any]) -> None: """Add a new set of defaults to the configuration It does two things: @@ -570,7 +581,7 @@ def update_defaults(self, new: Mapping) -> None: self.defaults.append(new) update(self.config, new, priority="new-defaults", defaults=current_defaults) - def to_dict(self): + def to_dict(self) -> MutableMapping[str, Any]: """Return dictionary copy of configuration. .. warning:: @@ -582,19 +593,19 @@ def to_dict(self): """ return deepcopy(self.config) - def clear(self): + def clear(self) -> None: """Clear all existing configuration.""" self.config.clear() - def merge(self, *dicts): + def merge(self, *dicts: Mapping[str, Any]) -> None: """Merge this configuration with multiple dictionaries. See :func:`~donfig.config_obj.merge` for more information. """ - self.config = merge(self.config, dicts) + self.config = merge(self.config, *dicts) - def update(self, new, priority="new"): + def update(self, new: Mapping[str, Any], priority: Literal["old", "new", "new-defaults"] = "new") -> None: """Update the internal configuration dictionary with `new`. See :func:`~donfig.config_obj.update` for more information. @@ -610,7 +621,7 @@ def expand_environment_variables(self) -> None: """ self.config = expand_environment_variables(self.config) - def rename(self, aliases: Mapping) -> None: + def rename(self, aliases: Mapping[str, str]) -> None: """Rename old keys to new keys This helps migrate older configuration versions over time @@ -629,7 +640,7 @@ def rename(self, aliases: Mapping) -> None: self.set(new) - def set(self, arg=None, **kwargs): + def set(self, arg: Mapping[str, Any] | None = None, **kwargs: Any) -> ConfigSet: """Set configuration values within a context manager. Parameters diff --git a/donfig/tests/test_config.py b/donfig/tests/test_config.py index 50f7993..055c5c9 100644 --- a/donfig/tests/test_config.py +++ b/donfig/tests/test_config.py @@ -1,13 +1,17 @@ #!/usr/bin/env python # Copyright (c) 2018- Donfig Developers # Copyright (c) 2014-2018, Anaconda, Inc. and contributors +from __future__ import annotations + import os import site import stat import subprocess import sys from collections import OrderedDict +from collections.abc import Iterator from contextlib import contextmanager +from typing import Any import cloudpickle import pytest @@ -30,7 +34,7 @@ ENV_PREFIX = CONFIG_NAME.upper() + "_" -def test_canonical_name(): +def test_canonical_name() -> None: c = {"foo-bar": 1, "fizz_buzz": 2} assert canonical_name("foo-bar", c) == "foo-bar" assert canonical_name("foo_bar", c) == "foo-bar" @@ -40,7 +44,7 @@ def test_canonical_name(): assert canonical_name("new_key", c) == "new_key" -def test_update(): +def test_update() -> None: a = {"x": 1, "y": {"a": 1}} b = {"x": 2, "z": 3, "y": OrderedDict({"b": 2})} update(b, a) @@ -52,7 +56,7 @@ def test_update(): assert b == {"x": 2, "y": {"a": 3, "b": 2}, "z": 3} -def test_update_new_defaults(): +def test_update_new_defaults() -> None: d = {"x": 1, "y": 1, "z": {"a": 1, "b": 1}} o = {"x": 1, "y": 2, "z": {"a": 1, "b": 2}, "c": 2, "c2": {"d": 2}} n = {"x": 3, "y": 3, "z": OrderedDict({"a": 3, "b": 3}), "c": 3, "c2": {"d": 3}} @@ -67,7 +71,7 @@ def test_update_new_defaults(): assert update(o, n, priority="new-defaults", defaults=None) == update(o, n, priority="old") -def test_update_defaults(): +def test_update_defaults() -> None: defaults = [ {"a": 1, "b": {"c": 1}}, {"a": 2, "b": {"d": 2}}, @@ -86,7 +90,7 @@ def test_update_defaults(): assert config.to_dict() == {"a": 0, "b": {"c": 0, "d": 3}, "extra": 0, "new-extra": 0} -def test_merge(): +def test_merge() -> None: a = {"x": 1, "y": {"a": 1}} b = {"x": 2, "z": 3, "y": {"b": 2}} @@ -96,7 +100,7 @@ def test_merge(): assert c == expected -def test_collect_yaml_paths(): +def test_collect_yaml_paths() -> None: a = {"x": 1, "y": {"a": 1}} b = {"x": 2, "z": 3, "y": {"b": 2}} @@ -117,7 +121,7 @@ def test_collect_yaml_paths(): assert config == expected -def test_collect_yaml_dir(): +def test_collect_yaml_dir() -> None: a = {"x": 1, "y": {"a": 1}} b = {"x": 2, "z": 3, "y": {"b": 2}} @@ -139,7 +143,7 @@ def test_collect_yaml_dir(): @contextmanager -def no_read_permissions(path): +def no_read_permissions(path: str) -> Iterator[None]: perm_orig = stat.S_IMODE(os.stat(path).st_mode) perm_new = perm_orig ^ stat.S_IREAD try: @@ -151,7 +155,7 @@ def no_read_permissions(path): @pytest.mark.skipif(sys.platform == "win32", reason="Can't make writeonly file on windows") @pytest.mark.parametrize("kind", ["directory", "file"]) -def test_collect_yaml_permission_errors(tmpdir, kind): +def test_collect_yaml_permission_errors(tmpdir: Any, kind: str) -> None: a = {"x": 1, "y": 2} b = {"y": 3, "z": 4} @@ -166,7 +170,7 @@ def test_collect_yaml_permission_errors(tmpdir, kind): if kind == "directory": cant_read = dir_path - expected = {} + expected: dict[str, int] = {} else: cant_read = a_path expected = b @@ -176,7 +180,7 @@ def test_collect_yaml_permission_errors(tmpdir, kind): assert config == expected -def test_collect_yaml_malformed_file(tmpdir): +def test_collect_yaml_malformed_file(tmpdir: Any) -> None: dir_path = str(tmpdir) fil_path = os.path.join(dir_path, "a.yaml") @@ -190,7 +194,7 @@ def test_collect_yaml_malformed_file(tmpdir): assert "original error message" in str(rec.value) -def test_collect_yaml_no_top_level_dict(tmpdir): +def test_collect_yaml_no_top_level_dict(tmpdir: Any) -> None: dir_path = str(tmpdir) fil_path = os.path.join(dir_path, "a.yaml") @@ -204,7 +208,7 @@ def test_collect_yaml_no_top_level_dict(tmpdir): assert "must have a dict" in str(rec.value) -def test_env(): +def test_env() -> None: env = { ENV_PREFIX + "A_B": "123", ENV_PREFIX + "C": "True", @@ -229,10 +233,10 @@ def test_env(): assert res == expected -def test_collect(): +def test_collect() -> None: a = {"x": 1, "y": {"a": 1}} b = {"x": 2, "z": 3, "y": {"b": 2}} - env = {ENV_PREFIX + "W": 4} + env: dict[str, Any] = {ENV_PREFIX + "W": 4} expected = { "w": 4, @@ -249,21 +253,21 @@ def test_collect(): with open(fn2, "w") as f: yaml.dump(b, f) - config = config.collect([fn1, fn2], env=env) - assert config == expected + result = config.collect([fn1, fn2], env=env) + assert result == expected -def test_collect_env_none(): +def test_collect_env_none() -> None: os.environ[ENV_PREFIX + "FOO"] = "bar" config = Config(CONFIG_NAME) try: - config = config.collect([]) - assert config == {"foo": "bar"} + result = config.collect([]) + assert result == {"foo": "bar"} finally: del os.environ[ENV_PREFIX + "FOO"] -def test_get(): +def test_get() -> None: test_config = Config(CONFIG_NAME) test_config.config = {"x": 1, "y": {"a": 2}} @@ -278,7 +282,7 @@ def test_get(): test_config["y.b"] -def test_contains(): +def test_contains() -> None: test_config = Config(CONFIG_NAME) test_config.config = {"x": 1, "y": {"a": 2}} @@ -287,7 +291,7 @@ def test_contains(): assert "y.b" not in test_config -def test_ensure_file(tmpdir): +def test_ensure_file(tmpdir: Any) -> None: a = {"x": 1, "y": {"a": 1}} b = {"x": 123} @@ -329,7 +333,7 @@ def test_ensure_file(tmpdir): assert not result -def test_set(): +def test_set() -> None: config = Config(CONFIG_NAME) with config.set(abc=123): assert config.config["abc"] == 123 @@ -352,7 +356,7 @@ def test_set(): assert config.config["abc"]["x"] == 123 -def test_set_kwargs(): +def test_set_kwargs() -> None: config = Config(CONFIG_NAME) with config.set(foo__bar=1, foo__baz=2): assert config.config["foo"] == {"bar": 1, "baz": 2} @@ -369,7 +373,7 @@ def test_set_kwargs(): assert "foo" not in config.config -def test_set_nested(): +def test_set_nested() -> None: config = Config(CONFIG_NAME) with config.set({"abc": {"x": 123}}): assert config.config["abc"] == {"x": 123} @@ -379,7 +383,7 @@ def test_set_nested(): assert "abc" not in config.config -def test_set_hard_to_copyables(): +def test_set_hard_to_copyables() -> None: import threading config = Config(CONFIG_NAME) @@ -389,7 +393,7 @@ def test_set_hard_to_copyables(): @pytest.mark.parametrize("mkdir", [True, False]) -def test_ensure_file_directory(mkdir, tmpdir): +def test_ensure_file_directory(mkdir: bool, tmpdir: Any) -> None: a = {"x": 1, "y": {"a": 1}} source = os.path.join(str(tmpdir), "source.yaml") @@ -408,7 +412,7 @@ def test_ensure_file_directory(mkdir, tmpdir): assert os.path.exists(os.path.join(dest, "source.yaml")) -def test_ensure_file_defaults_to_TEST_CONFIG_directory(tmpdir): +def test_ensure_file_defaults_to_TEST_CONFIG_directory(tmpdir: Any) -> None: a = {"x": 1, "y": {"a": 1}} source = os.path.join(str(tmpdir), "source.yaml") with open(source, "w") as f: @@ -428,7 +432,7 @@ def test_ensure_file_defaults_to_TEST_CONFIG_directory(tmpdir): assert os.path.split(fn)[1] == os.path.split(source)[1] -def test_rename(): +def test_rename() -> None: config = Config(CONFIG_NAME) aliases = {"foo_bar": "foo.bar"} config.config = {"foo-bar": 123} @@ -436,8 +440,8 @@ def test_rename(): assert config.config == {"foo": {"bar": 123}} -def test_refresh(): - defaults = [] +def test_refresh() -> None: + defaults: list[dict[str, Any]] = [] config = Config(CONFIG_NAME, defaults=defaults) config.update_defaults({"a": 1}) @@ -463,7 +467,7 @@ def test_refresh(): ({"a": "A", "b": [1, "2", "$FOO"]}, {"a": "A", "b": [1, "2", "foo"]}), ], ) -def test_expand_environment_variables(inp, out): +def test_expand_environment_variables(inp: Any, out: Any) -> None: try: os.environ["FOO"] = "foo" assert expand_environment_variables(inp) == out @@ -471,7 +475,7 @@ def test_expand_environment_variables(inp, out): del os.environ["FOO"] -def test_env_var_canonical_name(monkeypatch): +def test_env_var_canonical_name(monkeypatch: pytest.MonkeyPatch) -> None: value = 3 monkeypatch.setenv(ENV_PREFIX + "A_B", str(value)) config = Config(CONFIG_NAME) @@ -480,7 +484,7 @@ def test_env_var_canonical_name(monkeypatch): assert config.get("a-b") == value -def test_get_set_canonical_name(): +def test_get_set_canonical_name() -> None: c = {"x-y": {"a_b": 123}} config = Config(CONFIG_NAME) config.update(c) @@ -500,7 +504,7 @@ def test_get_set_canonical_name(): @pytest.mark.parametrize("key", ["custom_key", "custom-key"]) -def test_get_set_roundtrip(key): +def test_get_set_roundtrip(key: str) -> None: value = 123 config = Config(CONFIG_NAME) with config.set({key: value}): @@ -508,11 +512,11 @@ def test_get_set_roundtrip(key): assert config.get("custom-key") == value -def test_merge_none_to_dict(): +def test_merge_none_to_dict() -> None: assert merge({"a": None, "c": 0}, {"a": {"b": 1}}) == {"a": {"b": 1}, "c": 0} -def test_pprint(capsys): +def test_pprint(capsys: pytest.CaptureFixture[str]) -> None: test_config = Config(CONFIG_NAME) test_config.config = {"x": 1, "y": {"a": 2}} test_config.pprint() @@ -520,7 +524,7 @@ def test_pprint(capsys): assert cap_out == """{'x': 1, 'y': {'a': 2}}\n""" -def test_to_dict(): +def test_to_dict() -> None: test_config = Config(CONFIG_NAME) test_config.config = {"x": 1, "y": {"a": 2}} d = test_config.to_dict() @@ -532,7 +536,7 @@ def test_to_dict(): assert d["y"] != test_config.config["y"] -def test_path_includes_site_prefix(): +def test_path_includes_site_prefix() -> None: command = ( "import site, os; " 'prefix = os.path.join("include", "this", "path"); ' @@ -546,7 +550,7 @@ def test_path_includes_site_prefix(): subprocess.check_call([sys.executable, "-c", command]) -def test__get_paths(monkeypatch): +def test__get_paths(monkeypatch: pytest.MonkeyPatch) -> None: # These settings, if present, would interfere with these tests # We temporarily remove them to avoid interference from the # machine where tests are being run. @@ -586,14 +590,14 @@ def test__get_paths(monkeypatch): assert len(paths) == len(set(paths)) -def test_serialization(): +def test_serialization() -> None: config = Config(CONFIG_NAME) config.set(one_key="one_value") new_config = cloudpickle.loads(cloudpickle.dumps(config)) assert new_config.get("one_key") == "one_value" -def test_deprecations_rename(): +def test_deprecations_rename() -> None: config = Config(CONFIG_NAME, deprecations={"fuse_ave_width": "optimization.fuse.ave-width"}) with pytest.warns(Warning) as info, config.set(fuse_ave_width=123): assert config.get("optimization.fuse.ave-width") == 123 @@ -601,19 +605,19 @@ def test_deprecations_rename(): assert "optimization.fuse.ave-width" in str(info[0].message) -def test_deprecations_removed(): +def test_deprecations_removed() -> None: config = Config(CONFIG_NAME, deprecations={"fuse_ave_width": None}) with pytest.raises(ValueError): config.set(fuse_ave_width=123) -def test_config_serialization_functions(): +def test_config_serialization_functions() -> None: serialized = serialize({"array": {"svg": {"size": 150}}}) config_dict = deserialize(serialized) assert config_dict["array"]["svg"]["size"] == 150 -def test_config_object_serialization(): +def test_config_object_serialization() -> None: config = Config(CONFIG_NAME) config.set({"array.svg.size": 150}) ser_config = config.serialize() @@ -623,7 +627,7 @@ def test_config_object_serialization(): assert deser_config.get("array.svg.size") == 150 -def test_config_inheritance(monkeypatch): +def test_config_inheritance(monkeypatch: pytest.MonkeyPatch) -> None: ser_dict = serialize({"array": {"svg": {"size": 150}}}) monkeypatch.setenv(f"{ENV_PREFIX}_INTERNAL_INHERIT_CONFIG", ser_dict) config = Config(CONFIG_NAME) diff --git a/donfig/tests/test_lock.py b/donfig/tests/test_lock.py index 5ea08d8..0c4d509 100644 --- a/donfig/tests/test_lock.py +++ b/donfig/tests/test_lock.py @@ -7,7 +7,7 @@ from .._lock import SerializableLock -def test_SerializableLock(): +def test_SerializableLock() -> None: a = SerializableLock() b = SerializableLock() with a: @@ -42,7 +42,7 @@ def test_SerializableLock(): pass -def test_SerializableLock_name_collision(): +def test_SerializableLock_name_collision() -> None: a = SerializableLock("a") b = SerializableLock("b") c = SerializableLock("a") @@ -53,7 +53,7 @@ def test_SerializableLock_name_collision(): assert d.lock not in (a.lock, b.lock, c.lock) -def test_SerializableLock_locked(): +def test_SerializableLock_locked() -> None: a = SerializableLock("a") assert not a.locked() with a: @@ -61,7 +61,7 @@ def test_SerializableLock_locked(): assert not a.locked() -def test_SerializableLock_acquire_blocking(): +def test_SerializableLock_acquire_blocking() -> None: a = SerializableLock("a") assert a.acquire(blocking=True) assert not a.acquire(blocking=False) diff --git a/donfig/utils.py b/donfig/utils.py index 87197ae..2b998da 100644 --- a/donfig/utils.py +++ b/donfig/utils.py @@ -4,11 +4,12 @@ import os import shutil import tempfile +from collections.abc import Iterator from contextlib import contextmanager, suppress @contextmanager -def tmpfile(extension="", dir=None): +def tmpfile(extension: str = "", dir: str | None = None) -> Iterator[str]: extension = "." + extension.lstrip(".") handle, filename = tempfile.mkstemp(extension, dir=dir) os.close(handle) diff --git a/pyproject.toml b/pyproject.toml index 4764039..5e7b4b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,19 @@ docs = [ "cloudpickle", ] +# Type-checking toolchain (PEP 735 dependency group): mypy plus the type stubs. +# This is the single source of truth for the stubs — the pre-commit mypy hook +# runs `uv run --group typecheck mypy` rather than duplicating the list. +[dependency-groups] +typecheck = [ + "mypy", + "types-docutils", + "types-PyYAML", + "types-setuptools", + "pytest", # so the test suite type-checks against real pytest types + "cloudpickle", +] + [tool.setuptools] include-package-data = true @@ -48,14 +61,16 @@ python_version = "3.10" platform = "linux" # platform = win32 # platform = darwin +strict = true allow_untyped_decorators = false ignore_missing_imports = true -no_implicit_optional = true -show_error_codes = true -warn_redundant_casts = true -warn_unused_ignores = true warn_unreachable = true -warn_unused_configs = true +enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"] + +# `donfig/version.py` is generated by versioneer; do not type-check it. +[[tool.mypy.overrides]] +module = "donfig.version" +ignore_errors = true [tool.black] line-length = 120 From afe64658f6dc28293364005cce9ca22d64bb4e08 Mon Sep 17 00:00:00 2001 From: Max Jones <14077947+maxrjones@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:31:11 -0400 Subject: [PATCH 02/10] chore: type-check in CI --- .github/workflows/ci.yaml | 15 +++++++++++++++ .pre-commit-config.yaml | 2 ++ pyproject.toml | 18 ++++-------------- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 35e0cd1..1c6433a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -9,6 +9,21 @@ concurrency: cancel-in-progress: true jobs: + typecheck: + runs-on: "ubuntu-latest" + steps: + - name: Checkout source + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + # hatch-vcs needs the full history to resolve the version when uv + # builds the project environment + fetch-depth: 0 + persist-credentials: false + + - name: Run mypy + # uv is preinstalled on GitHub-hosted runners + run: uv run --group typecheck mypy donfig + test: runs-on: ${{ matrix.os }} strategy: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 117bf69..55bdb35 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -23,12 +23,14 @@ repos: # Use pyproject.toml if possible instead of adding command line parameters here args: [ --warn-unused-configs ] additional_dependencies: + # Keep in sync with the `typecheck` dependency group in pyproject.toml # Type stubs - types-docutils - types-PyYAML - types-setuptools # Typed libraries - pytest + - cloudpickle - repo: https://github.com/scientific-python/cookie rev: 2026.04.04 hooks: diff --git a/pyproject.toml b/pyproject.toml index 46be6f1..4d62d96 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,10 +42,11 @@ docs = [ [dependency-groups] test = ["pytest", "cloudpickle"] docs = ["sphinx>=4.0.0", "numpydoc", "pytest", "cloudpickle"] -dev = [{ include-group = "test" }] +dev = [{ include-group = "test" }, { include-group = "typecheck" }] # Type-checking toolchain (PEP 735 dependency group): mypy plus the type stubs. -# This is the single source of truth for the stubs — the pre-commit mypy hook -# runs `uv run --group typecheck mypy` rather than duplicating the list. +# The mirrors-mypy pre-commit hook cannot read dependency groups, so its +# additional_dependencies in .pre-commit-config.yaml must be kept in sync with +# this list by hand. typecheck = [ "mypy", "types-docutils", @@ -83,12 +84,6 @@ ignore = [ # (`donfig/tests/`) rather than a src-layout; both are intentional. "PY004", "PY005", - # MyPy strictness is tightened in a separate typing PR, not here. - "MY101", - "MY102", - "MY104", - "MY105", - "MY106", # Adding the doc/markdown formatters (blacken-docs etc.) is out of scope for # this packaging change. "PC111", @@ -108,11 +103,6 @@ ignore_missing_imports = true warn_unreachable = true enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"] -# `donfig/version.py` is generated by versioneer; do not type-check it. -[[tool.mypy.overrides]] -module = "donfig.version" -ignore_errors = true - [tool.codespell] # Vendored, minified js-yaml bundle used by the docs config converter — not prose. skip = "doc/_static/js-yaml.min.js" From db46b353ed3bca3f64f67711e357b1978394fb7d Mon Sep 17 00:00:00 2001 From: Max Jones <14077947+maxrjones@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:31:37 -0400 Subject: [PATCH 03/10] chore: test config.merge --- donfig/config_obj.py | 6 +++--- donfig/tests/test_config.py | 8 ++++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/donfig/config_obj.py b/donfig/config_obj.py index d8b2206..876a857 100644 --- a/donfig/config_obj.py +++ b/donfig/config_obj.py @@ -444,9 +444,9 @@ def __init__( self.env = env self.main_path = main_path self.paths = paths - # Preserve the historical contract that a caller-supplied ``defaults`` - # list is aliased (``update_defaults`` appends to it in place); only copy - # when a non-list Sequence is passed. + # A caller-supplied list (even an empty one) is aliased, not copied, so + # that ``update_defaults`` appends to it in place; any other Sequence is + # copied into a new list. self.defaults: list[Mapping[str, Any]] = ( defaults if isinstance(defaults, list) else list(defaults) if defaults else [] ) diff --git a/donfig/tests/test_config.py b/donfig/tests/test_config.py index 055c5c9..c4b8b6f 100644 --- a/donfig/tests/test_config.py +++ b/donfig/tests/test_config.py @@ -100,6 +100,14 @@ def test_merge() -> None: assert c == expected +def test_config_merge() -> None: + config = Config(CONFIG_NAME) + config.clear() + config.update({"x": 1, "y": {"a": 1}}) + config.merge({"y": {"b": 2}}, {"z": 3}) + assert config.to_dict() == {"x": 1, "y": {"a": 1, "b": 2}, "z": 3} + + def test_collect_yaml_paths() -> None: a = {"x": 1, "y": {"a": 1}} b = {"x": 2, "z": 3, "y": {"b": 2}} From 418dcbdb2cd44fdbee8fd1d733a3dbfb255be35b Mon Sep 17 00:00:00 2001 From: Max Jones <14077947+maxrjones@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:31:50 -0400 Subject: [PATCH 04/10] chore: add py.typed --- donfig/py.typed | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 donfig/py.typed diff --git a/donfig/py.typed b/donfig/py.typed new file mode 100644 index 0000000..e69de29 From 301ba5a68133b7c090dbd1f444d92085f0c5500f Mon Sep 17 00:00:00 2001 From: Max Jones <14077947+maxrjones@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:56:29 -0400 Subject: [PATCH 05/10] chore: scope ignore_missing_imports to cloudpickle only --- pyproject.toml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 4d62d96..0cfd83c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -99,10 +99,14 @@ platform = "linux" # platform = darwin strict = true allow_untyped_decorators = false -ignore_missing_imports = true warn_unreachable = true enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"] +[[tool.mypy.overrides]] +# cloudpickle ships neither inline types nor a stubs package. +module = ["cloudpickle"] +ignore_missing_imports = true + [tool.codespell] # Vendored, minified js-yaml bundle used by the docs config converter — not prose. skip = "doc/_static/js-yaml.min.js" From 56d68ba2514173f927c19ba1cee764a9715fee5c Mon Sep 17 00:00:00 2001 From: Max Jones <14077947+maxrjones@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:26:24 -0400 Subject: [PATCH 06/10] Configure mypy job --- .github/workflows/ci.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1c6433a..e8f80cf 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -20,8 +20,10 @@ jobs: fetch-depth: 0 persist-credentials: false + - name: Install uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + - name: Run mypy - # uv is preinstalled on GitHub-hosted runners run: uv run --group typecheck mypy donfig test: From 973f111e92b09a408049bcfb81a4f4f073d53f9d Mon Sep 17 00:00:00 2001 From: Max Jones <14077947+maxrjones@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:23:49 -0400 Subject: [PATCH 07/10] refactor: type defaults as MutableSequence to make the aliasing contract explicit --- donfig/config_obj.py | 13 +++++-------- donfig/tests/test_config.py | 6 +++--- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/donfig/config_obj.py b/donfig/config_obj.py index 876a857..aa7fc08 100644 --- a/donfig/config_obj.py +++ b/donfig/config_obj.py @@ -12,7 +12,7 @@ import site import sys import warnings -from collections.abc import Mapping, MutableMapping, Sequence +from collections.abc import Mapping, MutableMapping, MutableSequence, Sequence from contextlib import nullcontext from copy import deepcopy from types import TracebackType @@ -404,7 +404,7 @@ class Config: def __init__( self, name: str, - defaults: Sequence[Mapping[str, Any]] | None = None, + defaults: MutableSequence[Mapping[str, Any]] | None = None, paths: list[str] | None = None, env: Mapping[str, str] | None = None, env_var: str | None = None, @@ -444,12 +444,9 @@ def __init__( self.env = env self.main_path = main_path self.paths = paths - # A caller-supplied list (even an empty one) is aliased, not copied, so - # that ``update_defaults`` appends to it in place; any other Sequence is - # copied into a new list. - self.defaults: list[Mapping[str, Any]] = ( - defaults if isinstance(defaults, list) else list(defaults) if defaults else [] - ) + # Aliased, not copied: ``update_defaults`` appends into the + # caller-supplied sequence in place. + self.defaults: MutableSequence[Mapping[str, Any]] = defaults if defaults is not None else [] self.deprecations = deprecations self.config: MutableMapping[str, Any] = {} diff --git a/donfig/tests/test_config.py b/donfig/tests/test_config.py index c4b8b6f..54c3d42 100644 --- a/donfig/tests/test_config.py +++ b/donfig/tests/test_config.py @@ -9,7 +9,7 @@ import subprocess import sys from collections import OrderedDict -from collections.abc import Iterator +from collections.abc import Iterator, Mapping from contextlib import contextmanager from typing import Any @@ -72,7 +72,7 @@ def test_update_new_defaults() -> None: def test_update_defaults() -> None: - defaults = [ + defaults: list[Mapping[str, Any]] = [ {"a": 1, "b": {"c": 1}}, {"a": 2, "b": {"d": 2}}, ] @@ -449,7 +449,7 @@ def test_rename() -> None: def test_refresh() -> None: - defaults: list[dict[str, Any]] = [] + defaults: list[Mapping[str, Any]] = [] config = Config(CONFIG_NAME, defaults=defaults) config.update_defaults({"a": 1}) From 3d09a7c41742777724fe391453e380a5085c0d53 Mon Sep 17 00:00:00 2001 From: Max Jones <14077947+maxrjones@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:26:01 -0400 Subject: [PATCH 08/10] types: simplify to dict --- donfig/config_obj.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/donfig/config_obj.py b/donfig/config_obj.py index eb8dbbe..1d78ba4 100644 --- a/donfig/config_obj.py +++ b/donfig/config_obj.py @@ -49,11 +49,11 @@ def canonical_name(k: str, config: Mapping[str, Any]) -> str: def update( - old: MutableMapping[str, Any], + old: dict[str, Any], new: Mapping[str, Any], priority: Literal["old", "new", "new-defaults"] = "new", defaults: Mapping[str, Any] | None = None, -) -> MutableMapping[str, Any]: +) -> dict[str, Any]: """Update a nested dictionary with values from another This is like dict.update except that it smoothly merges nested values @@ -193,7 +193,7 @@ def _load_config_file(path: str) -> dict[str, Any] | None: def collect_env( - prefix: str, env: Mapping[str, str] | None = None, deprecations: MutableMapping[str, str | None] | None = None + prefix: str, env: Mapping[str, str] | None = None, deprecations: Mapping[str, str | None] | None = None ) -> dict[str, Any]: """Collect config from environment variables @@ -262,7 +262,7 @@ def __init__( with lock: self.config = config self.deprecations = deprecations - self._record: list[tuple[str, tuple[str, ...], Any]] = [] + self._record: list[tuple[Literal["replace", "insert"], tuple[str, ...], Any]] = [] if arg is not None: for key, value in arg.items(): @@ -447,7 +447,7 @@ def __init__( self.defaults: list[Mapping[str, Any]] = list(defaults) if defaults is not None else [] self.deprecations = deprecations - self.config: MutableMapping[str, Any] = {} + self.config: dict[str, Any] = {} self.config_lock = SerializableLock() self.refresh() @@ -578,7 +578,7 @@ def update_defaults(self, new: Mapping[str, Any]) -> None: self.defaults.append(new) update(self.config, new, priority="new-defaults", defaults=current_defaults) - def to_dict(self) -> MutableMapping[str, Any]: + def to_dict(self) -> dict[str, Any]: """Return dictionary copy of configuration. .. warning:: From 511cdae2b77755f3a220d72a6e78c308e220898b Mon Sep 17 00:00:00 2001 From: David Hoese Date: Mon, 3 Aug 2026 09:42:15 -0500 Subject: [PATCH 09/10] Add missing type annotations in test_config.py --- donfig/tests/test_config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/donfig/tests/test_config.py b/donfig/tests/test_config.py index ed19cc5..2c7d3d5 100644 --- a/donfig/tests/test_config.py +++ b/donfig/tests/test_config.py @@ -610,7 +610,7 @@ def test__get_paths(monkeypatch: pytest.MonkeyPatch) -> None: assert len(paths) == len(set(paths)) -def test_paths_not_mutated(monkeypatch): +def test_paths_not_mutated(monkeypatch) -> None: monkeypatch.setenv("MYPKG_CONFIG", "foo-bar") paths = ["/etc/mypkg"] config = Config("mypkg", paths=paths) @@ -619,7 +619,7 @@ def test_paths_not_mutated(monkeypatch): assert paths == ["/etc/mypkg"] -def test_paths_accepts_any_sequence(monkeypatch): +def test_paths_accepts_any_sequence(monkeypatch) -> None: monkeypatch.setenv("MYPKG_CONFIG", "foo-bar") config = Config("mypkg", paths=("/etc/mypkg",)) assert config.paths == ["/etc/mypkg", "foo-bar"] From d2ccb8274b3e57ad7adc0679e032ef2ac10b4e26 Mon Sep 17 00:00:00 2001 From: Max Jones <14077947+maxrjones@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:11:36 -0400 Subject: [PATCH 10/10] chore: update tests --- donfig/tests/test_config.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/donfig/tests/test_config.py b/donfig/tests/test_config.py index 2c7d3d5..d39277b 100644 --- a/donfig/tests/test_config.py +++ b/donfig/tests/test_config.py @@ -610,7 +610,7 @@ def test__get_paths(monkeypatch: pytest.MonkeyPatch) -> None: assert len(paths) == len(set(paths)) -def test_paths_not_mutated(monkeypatch) -> None: +def test_paths_not_mutated(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("MYPKG_CONFIG", "foo-bar") paths = ["/etc/mypkg"] config = Config("mypkg", paths=paths) @@ -619,12 +619,6 @@ def test_paths_not_mutated(monkeypatch) -> None: assert paths == ["/etc/mypkg"] -def test_paths_accepts_any_sequence(monkeypatch) -> None: - monkeypatch.setenv("MYPKG_CONFIG", "foo-bar") - config = Config("mypkg", paths=("/etc/mypkg",)) - assert config.paths == ["/etc/mypkg", "foo-bar"] - - def test_serialization() -> None: config = Config(CONFIG_NAME) config.set(one_key="one_value")