Skip to content

Commit

Permalink
Unit tests for config system
Browse files Browse the repository at this point in the history
  • Loading branch information
hamelin committed Nov 13, 2024
1 parent f815347 commit 4b7ef27
Show file tree
Hide file tree
Showing 2 changed files with 166 additions and 11 deletions.
87 changes: 76 additions & 11 deletions datamapplot/config.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,33 @@
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,
"figsize": (10, 10),
"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."""

Expand All @@ -19,27 +38,27 @@ 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()

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:
Expand All @@ -48,18 +67,18 @@ 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:
with open(self._config_file, 'w') as f:
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

Expand All @@ -69,4 +88,50 @@ def __delitem__(self, key):
def __contains__(self, key):
return key in self._config


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
90 changes: 90 additions & 0 deletions datamapplot/tests/test_config.py
Original file line number Diff line number Diff line change
@@ -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", {})

0 comments on commit 4b7ef27

Please sign in to comment.