diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2948b75..47c0906 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -9,6 +9,23 @@ 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: Install uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + + - name: Run mypy + 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/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 738ac7f..288ec4e 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 @@ -48,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 | None = None, -) -> Mapping[str, Any]: + defaults: Mapping[str, Any] | None = None, +) -> dict[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()) @@ -192,8 +193,8 @@ 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: + prefix: str, env: Mapping[str, str] | None = None, deprecations: Mapping[str, str | None] | None = None +) -> 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[Literal["replace", "insert"], 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 @@ -300,10 +301,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": @@ -323,7 +329,7 @@ def _assign( self, keys: Sequence[str], value: Any, - d: MutableMapping, + d: MutableMapping[str, Any], path: tuple[str, ...] = (), record: bool = True, ) -> None: @@ -363,7 +369,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 @@ -443,24 +449,24 @@ 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() - 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 @@ -486,16 +492,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: @@ -559,7 +565,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: @@ -574,7 +580,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) -> dict[str, Any]: """Return dictionary copy of configuration. .. warning:: @@ -586,11 +592,11 @@ 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. @@ -598,7 +604,7 @@ def merge(self, *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. @@ -614,7 +620,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 @@ -633,7 +639,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/py.typed b/donfig/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/donfig/tests/test_config.py b/donfig/tests/test_config.py index 2e5254e..d39277b 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, Mapping 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,8 +71,8 @@ def test_update_new_defaults(): assert update(o, n, priority="new-defaults", defaults=None) == update(o, n, priority="old") -def test_update_defaults(): - defaults = [ +def test_update_defaults() -> None: + defaults: list[Mapping[str, Any]] = [ {"a": 1, "b": {"c": 1}}, {"a": 2, "b": {"d": 2}}, ] @@ -91,14 +95,14 @@ def test_update_defaults(): assert config.to_dict() == {"a": 0, "b": {"c": 0, "d": 3}, "extra": 0, "new-extra": 0} -def test_defaults_accepts_any_sequence(): +def test_defaults_accepts_any_sequence() -> None: config = Config(CONFIG_NAME, defaults=({"a": 1}, {"b": 2})) assert config.to_dict() == {"a": 1, "b": 2} config.update_defaults({"c": 3}) assert config.get("c") == 3 -def test_merge(): +def test_merge() -> None: a = {"x": 1, "y": {"a": 1}} b = {"x": 2, "z": 3, "y": {"b": 2}} @@ -108,7 +112,7 @@ def test_merge(): assert c == expected -def test_config_merge(): +def test_config_merge() -> None: config = Config(CONFIG_NAME) config.clear() config.update({"x": 1, "y": {"a": 1}}) @@ -116,7 +120,7 @@ def test_config_merge(): assert config.to_dict() == {"x": 1, "y": {"a": 1, "b": 2}, "z": 3} -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}} @@ -137,7 +141,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}} @@ -159,7 +163,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: @@ -171,7 +175,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} @@ -186,7 +190,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 @@ -196,7 +200,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") @@ -210,7 +214,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") @@ -224,7 +228,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", @@ -249,10 +253,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, @@ -269,21 +273,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}} @@ -298,7 +302,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}} @@ -307,7 +311,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} @@ -349,7 +353,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 @@ -372,7 +376,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} @@ -389,7 +393,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} @@ -399,7 +403,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) @@ -409,7 +413,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") @@ -428,7 +432,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: @@ -448,7 +452,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} @@ -456,8 +460,8 @@ def test_rename(): assert config.config == {"foo": {"bar": 123}} -def test_refresh(): - defaults = [] +def test_refresh() -> None: + defaults: list[Mapping[str, Any]] = [] config = Config(CONFIG_NAME, defaults=defaults) config.update_defaults({"a": 1}) @@ -483,7 +487,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 @@ -491,7 +495,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) @@ -500,7 +504,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) @@ -520,7 +524,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}): @@ -528,11 +532,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() @@ -540,7 +544,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() @@ -552,7 +556,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"); ' @@ -566,7 +570,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. @@ -606,7 +610,7 @@ def test__get_paths(monkeypatch): assert len(paths) == len(set(paths)) -def test_paths_not_mutated(monkeypatch): +def test_paths_not_mutated(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("MYPKG_CONFIG", "foo-bar") paths = ["/etc/mypkg"] config = Config("mypkg", paths=paths) @@ -615,20 +619,14 @@ def test_paths_not_mutated(monkeypatch): assert paths == ["/etc/mypkg"] -def test_paths_accepts_any_sequence(monkeypatch): - monkeypatch.setenv("MYPKG_CONFIG", "foo-bar") - config = Config("mypkg", paths=("/etc/mypkg",)) - assert config.paths == ["/etc/mypkg", "foo-bar"] - - -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 @@ -636,19 +634,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() @@ -658,7 +656,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 438acae..0cfd83c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,19 @@ 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. +# 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", + "types-PyYAML", + "types-setuptools", + "pytest", # so the test suite type-checks against real pytest types + "cloudpickle", +] [tool.hatch.version] source = "vcs" @@ -72,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", @@ -91,14 +97,15 @@ 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"] + +[[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.