From 4b7ef27529bb46daf6444d447130de8be11401e2 Mon Sep 17 00:00:00 2001 From: Benoit Hamelin Date: Wed, 13 Nov 2024 17:05:02 -0500 Subject: [PATCH] Unit tests for config system --- datamapplot/config.py | 87 ++++++++++++++++++++++++++---- datamapplot/tests/test_config.py | 90 ++++++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+), 11 deletions(-) create mode 100644 datamapplot/tests/test_config.py diff --git a/datamapplot/config.py b/datamapplot/config.py index e9f2350..c114aca 100644 --- a/datamapplot/config.py +++ b/datamapplot/config.py @@ -1,7 +1,15 @@ -from warnings import warn +from collections.abc import Sequence +import inspect as ins import json -import platformdirs from pathlib import Path +import platformdirs +from typing import Any, Callable, cast, ParamSpec, TypeVar, Union +from warnings import warn + + +P = ParamSpec("P") +T = TypeVar("T") + DEFAULT_CONFIG = { "dpi": 100, @@ -9,6 +17,17 @@ "cdn_url": "unpkg.com", } + +class ConfigError(Exception): + + def __init__(self, message: str, parameter: ins.Parameter) -> None: + super().__init__(message) + self.parameter = parameter + + +UnconfigurableParameters = Sequence[str] + + class ConfigManager: """Configuration manager for the datamapplot package.""" @@ -19,13 +38,13 @@ def __new__(cls): cls._instance = super(ConfigManager, cls).__new__(cls) cls._instance._config = {} return cls._instance - + def __init__(self): - if self._instance is None: + if not self._config: self._config_dir = platformdirs.user_config_dir("datamapplot") self._config_file = Path(self._config_dir) / "config.json" self._config = DEFAULT_CONFIG.copy() - + self._ensure_config_file() self._load_config() @@ -33,13 +52,13 @@ def _ensure_config_file(self) -> None: """Create config directory and file if they don't exist.""" try: self._config_file.parent.mkdir(parents=True, exist_ok=True) - + if not self._config_file.exists(): with open(self._config_file, 'w') as f: json.dump(DEFAULT_CONFIG, f, indent=2) except Exception as e: warn(f"Error creating config file: {e}") - + def _load_config(self) -> None: """Load configuration from file.""" try: @@ -48,7 +67,7 @@ def _load_config(self) -> None: self._config.update(loaded_config) except Exception as e: warn(f"Error loading config file: {e}") - + def save(self) -> None: """Save current configuration to file.""" try: @@ -56,10 +75,10 @@ def save(self) -> None: json.dump(self._config, f, indent=2) except Exception as e: warn(f"Error saving config file: {e}") - + def __getitem__(self, key): return self._config[key] - + def __setitem__(self, key, value): self._config[key] = value @@ -69,4 +88,50 @@ def __delitem__(self, key): def __contains__(self, key): return key in self._config - \ No newline at end of file + def complete( + self, + fn_or_unc: Union[None, UnconfigurableParameters, Callable[P, T]], + unconfigurable: UnconfigurableParameters = set(), + ) -> Union[Callable[[Callable[P, T]], Callable[P, T]], Callable[P, T]]: + def decorator(fn: Callable[P, T]) -> Callable[P, T]: + sig = ins.signature(fn) + + def _complete(*args, **kwargs): + bound_args = sig.bind(*args, **kwargs) + bindings = bound_args.arguments + from_config = {} + for name, param in sig.parameters.items(): + if name not in bindings and name in self: + if not _is_admissible(param): + raise ConfigError( + "Only keyword (or plausibly keyword) parameters " + "can be set through the DataMapPlot configuration " + f"file. Parameter {param.name} ({param.kind}) " + "is thus not admissible.", + param + ) + if name in unconfigurable: + raise ConfigError( + f"Parameter {param.name} is deliberately listed as " + "forbidden from being defined through the DataMapPlot " + "configuration file.", + param + ) + from_config[name] = self[name] + return fn(*bound_args.args, **(bound_args.kwargs | from_config)) + + return _complete + + if fn_or_unc is None: + return decorator + elif not hasattr(fn_or_unc, "__call__"): + unconfigurable = cast(UnconfigurableParameters, fn_or_unc) + return decorator + return decorator(fn_or_unc) + + +_KINDS_ADMISSIBLE = {ins.Parameter.POSITIONAL_OR_KEYWORD, ins.Parameter.KEYWORD_ONLY} + + +def _is_admissible(param: ins.Parameter) -> bool: + return param.kind in _KINDS_ADMISSIBLE diff --git a/datamapplot/tests/test_config.py b/datamapplot/tests/test_config.py new file mode 100644 index 0000000..ec15564 --- /dev/null +++ b/datamapplot/tests/test_config.py @@ -0,0 +1,90 @@ +from copy import copy +import inspect as ins +from pathlib import Path +import platformdirs +import pytest + +from ..config import ConfigManager, ConfigError + + +@pytest.fixture +def no_change_to_config_file(): + cfgmgr = ConfigManager() + assert cfgmgr._config_file.is_file() + contents_before = cfgmgr._config_file.read_bytes() + try: + yield None + finally: + contents_after = cfgmgr._config_file.read_bytes() + if contents_after != contents_before: + cfgmgr._config_file.write_bytes(contents_before) + pytest.fail( + "Unit test was supposed not to change the configuration file, " + "yet it did." + ) + + +def test_tweak_config_sanity(no_change_to_config_file): + cfgmgr = ConfigManager() + cfgmgr["asdf"] = "qwer" + + +@pytest.fixture +def config(no_change_to_config_file): + config = ConfigManager() + orig = copy(config._config) + yield config + config._config = orig + + +@pytest.fixture +def the_func(config): + for name in ["a", "args", "b", "c", "dont_touch", "kwargs"]: + assert name not in config + + @config.complete({"dont_touch"}) + def _the_func(a, *args, b=None, c="asdf", dont_touch="nope", **kwargs): + return a, args, b, c, dont_touch, kwargs + return _the_func + + +def test_no_config_args(the_func, config): + config["args"] = ("heck", "no") + with pytest.raises(ConfigError): + the_func("A") + + +def test_no_config_kwargs(the_func, config): + config["kwargs"] = {"heck": "no"} + with pytest.raises(ConfigError): + the_func("A") + + +def test_config_positional_useless(the_func, config): + config["a"] = "how would that even work?" # Can never reach. + assert the_func("A") == ("A", (), None, "asdf", "nope", {}) + + +def test_fetch_b_config(the_func, config): + config["b"] = 98 + assert the_func("A") == ("A", (), 98, "asdf", "nope", {}) + + +def test_override_configed_b(the_func, config): + config["b"] = 98 + assert the_func("A", "B", b=3) == ("A", ("B",), 3, "asdf", "nope", {}) + + +def test_nonconfiged_c(the_func, config): + config["b"] = 98 + assert the_func("A", c="qwer") == ("A", (), 98, "qwer", "nope", {}) + + +def test_no_config_donttouch(the_func, config): + config["dont_touch"] = "this mustn't work" + with pytest.raises(ConfigError): + the_func("A") + + +def test_override_donttouch(the_func): + assert the_func("A", dont_touch="poke") == ("A", (), None, "asdf", "poke", {})