diff --git a/flake.lock b/flake.lock index ae2c6bc..84ad773 100644 --- a/flake.lock +++ b/flake.lock @@ -5,11 +5,11 @@ "systems": "systems" }, "locked": { - "lastModified": 1710146030, - "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", + "lastModified": 1726560853, + "narHash": "sha256-X6rJYSESBVr3hBoH0WbKE5KvhPU5bloyZ2L4K60/fPQ=", "owner": "numtide", "repo": "flake-utils", - "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", + "rev": "c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a", "type": "github" }, "original": { @@ -18,6 +18,39 @@ "type": "github" } }, + "lix-unit": { + "inputs": { + "mdbook-nixdoc": [ + "pyproject-nix", + "mdbook-nixdoc" + ], + "nix-github-actions": [ + "pyproject-nix", + "nix-github-actions" + ], + "nixpkgs": [ + "pyproject-nix", + "nixpkgs" + ], + "treefmt-nix": [ + "pyproject-nix", + "treefmt-nix" + ] + }, + "locked": { + "lastModified": 1727322567, + "narHash": "sha256-scZo6AyJTxTK9wYW0HmWDzLxVxOoFr7/XkIVJCmmOe4=", + "owner": "adisbladis", + "repo": "lix-unit", + "rev": "59c489dbc5b27a83fadc94fde2c2b69abb4c0e80", + "type": "github" + }, + "original": { + "owner": "adisbladis", + "repo": "lix-unit", + "type": "github" + } + }, "mdbook-nixdoc": { "inputs": { "nix-github-actions": [ @@ -66,11 +99,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1725983898, - "narHash": "sha256-4b3A9zPpxAxLnkF9MawJNHDtOOl6ruL0r6Og1TEDGCE=", + "lastModified": 1729665710, + "narHash": "sha256-AlcmCXJZPIlO5dmFzV3V2XF6x/OpNWUV8Y/FMPGd8Z4=", "owner": "nixos", "repo": "nixpkgs", - "rev": "1355a0cbfeac61d785b7183c0caaec1f97361b43", + "rev": "2768c7d042a37de65bb1b5b3268fc987e534c49d", "type": "github" }, "original": { @@ -82,6 +115,7 @@ }, "pyproject-nix": { "inputs": { + "lix-unit": "lix-unit", "mdbook-nixdoc": "mdbook-nixdoc", "nix-github-actions": "nix-github-actions", "nixpkgs": [ @@ -90,11 +124,11 @@ "treefmt-nix": "treefmt-nix" }, "locked": { - "lastModified": 1726093189, - "narHash": "sha256-aPzd6M1k1H0C2zoMlSfAdTg4Za+WfB8Qj+OAbQAVAMs=", + "lastModified": 1729676952, + "narHash": "sha256-6Kn+s20SVpBjt2RJ6e4hQfbAcdv+lWzowWjB3M5rO8M=", "owner": "nix-community", "repo": "pyproject.nix", - "rev": "6c56846759ba16382bc2bdbee42c2f56c21654be", + "rev": "b9d97172161301c82cd978123c61abc81b88537e", "type": "github" }, "original": { @@ -133,11 +167,11 @@ ] }, "locked": { - "lastModified": 1724833132, - "narHash": "sha256-F4djBvyNRAXGusJiNYInqR6zIMI3rvlp6WiKwsRISos=", + "lastModified": 1727252110, + "narHash": "sha256-3O7RWiXpvqBcCl84Mvqa8dXudZ1Bol1ubNdSmQt7nF4=", "owner": "numtide", "repo": "treefmt-nix", - "rev": "3ffd842a5f50f435d3e603312eefa4790db46af5", + "rev": "1bff2ba6ec22bc90e9ad3f7e94cca0d37870afa3", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 336f66f..d95acde 100644 --- a/flake.nix +++ b/flake.nix @@ -33,7 +33,11 @@ attrs = project.renderers.withPackages { inherit python; extras = [ "tests" ]; - extraPackages = python-pkgs: [ python-pkgs.ipykernel ]; + extraPackages = python-pkgs: [ + python-pkgs.ipykernel + python-pkgs.python-lsp-server + python-pkgs.pylsp-mypy + ]; }; pythonEnv = python.withPackages attrs; in diff --git a/pyproject.toml b/pyproject.toml index 0493f23..1e1af46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [build-system] build-backend = "setuptools.build_meta" -requires = ["matplotlib", "setuptools"] +requires = ["setuptools"] [project] authors = [ @@ -16,7 +16,8 @@ classifiers = [ ] dependencies = [ "appdirs", - "matplotlib" + "matplotlib", + "tomli; python_version < '3.11'" ] description = "Perfect matplotlib figures for latex" dynamic = ["version"] @@ -46,7 +47,10 @@ Homepage = "https://github.com/cgahr/latexplotlib" Issues = "https://github.com/cgahr/latexplotlib/issues" [tool.coverage.report] -exclude_lines = ["if TYPE_CHECKING:"] +exclude_lines = [ + "if TYPE_CHECKING:", + "import tomli as tomllib" # tested via GitHub actions +] [tool.coverage.run] source = ["src"] @@ -117,7 +121,8 @@ src = ["src"] [tool.ruff.lint] fixable = ["I"] ignore = [ - "ANN101" # missing-type-self + "ANN101", # missing-type-self + "ANN102" # missing-type-cls ] select = [ "A", @@ -177,6 +182,7 @@ suppress-dummy-args = true "ARG002", # unused-method-argument "INP", # implicit-namespace-package "PLR0913", # too-many-arguments + "PLR6301", "S101", # assert "SLF001" # private-member-access ] diff --git a/src/latexplotlib/__init__.py b/src/latexplotlib/__init__.py index 93b7a10..2f9b595 100644 --- a/src/latexplotlib/__init__.py +++ b/src/latexplotlib/__init__.py @@ -1,6 +1,5 @@ from typing import Any -from ._cleanup import purge_old_styles from ._config import size from ._latexplotlib import ( convert_inches_to_pt, @@ -27,5 +26,4 @@ def __getattr__(name: str) -> Any: # noqa: ANN401 return getattr(plt, name) -purge_old_styles(__path__) make_styles_available(__path__) diff --git a/src/latexplotlib/_cleanup.py b/src/latexplotlib/_cleanup.py deleted file mode 100644 index 7c710b5..0000000 --- a/src/latexplotlib/_cleanup.py +++ /dev/null @@ -1,40 +0,0 @@ -import filecmp -from pathlib import Path - -from matplotlib import get_configdir - -from ._config import _PURGED_OLD, config - -STYLELIB = "stylelib" -STYLES = { - "latex9pt-minimal.mplstyle", - "latex9pt.mplstyle", - "latex10pt-minimal.mplstyle", - "latex10pt.mplstyle", - "latex11pt-minimal.mplstyle", - "latex11pt.mplstyle", - "latex12pt-minimal.mplstyle", - "latex12pt.mplstyle", -} -STYLES_FOLDER = "styles" - - -def purge_old_styles(path: list[str]) -> None: - if config[_PURGED_OLD]: - return - - old_styledir = Path(get_configdir()) / STYLELIB - - if not old_styledir.is_dir(): - config[_PURGED_OLD] = True - return - - old_styles = {s.name for s in old_styledir.glob("latex*.mplstyle")} - - for style in STYLES & old_styles: - if filecmp.cmp( - Path(path[0]) / STYLES_FOLDER / style, old_styledir / style, shallow=False - ): - (old_styledir / style).unlink() - - config[_PURGED_OLD] = True diff --git a/src/latexplotlib/_config.py b/src/latexplotlib/_config.py index 62e6978..8f3e347 100644 --- a/src/latexplotlib/_config.py +++ b/src/latexplotlib/_config.py @@ -1,75 +1,86 @@ import json -from collections.abc import Iterator, Mapping +import sys +import warnings +from collections.abc import Iterator from contextlib import contextmanager from pathlib import Path from typing import Union from appdirs import user_config_dir +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + Number = Union[int, float] ConfigData = Union[Number, bool] GOLDEN_RATIO: float = (5**0.5 + 1) / 2 NAME: str = "latexplotlib" -_PURGED_OLD = "_purged_old_styles" - CONFIGFILE: str = "config.ini" CONFIGDIR: Path = Path(user_config_dir(NAME)) CONFIGPATH: Path = CONFIGDIR / CONFIGFILE -DEFAULT_CONFIG: dict[str, Number] = {"width": 630, "height": 412, _PURGED_OLD: False} - +DEFAULT_WIDTH = 630 +DEFAULT_HEIGHT = 412 -class Config: - def __init__(self, path: Path) -> None: - self.path = path - if not self.path.exists(): - self.reset() +def find_pyproject_toml() -> Path | None: + pyproject = "pyproject.toml" + path = Path().absolute() + while path != Path("/"): + if (path / pyproject).exists(): + return path / pyproject + path = path.parent - self._config = self._open(path) - - def _open(self, path: Path) -> dict[str, ConfigData]: - with path.open(encoding="utf-8") as fh: - config: dict[str, ConfigData] = json.load(fh) - return config + return None - def _write(self, cfg: Mapping[str, ConfigData]) -> None: - if not self.path.parent.exists(): - self.path.parent.mkdir(parents=True) - with self.path.open("w", encoding="utf-8") as fh: - json.dump(cfg, fh, indent=4) +def find_config_ini() -> Path | None: + if CONFIGPATH.exists(): + msg = f""" + Configuring latexplotlib via '{CONFIGPATH}' is being deprecated. Please use + the [tool.latexplotlib] section of the 'pyproject.toml' file instead. If a + 'pyproject.toml' file is present in the current directory or a parent + directory, the configuration at '{CONFIGPATH}' is being ignored. - def reset(self) -> None: - if self.path.exists(): - self.path.unlink() + To silence this warning, please delete the config file '{CONFIGPATH}'. + """ + warnings.warn(msg, DeprecationWarning, stacklevel=5) - self._write(DEFAULT_CONFIG) + return CONFIGPATH - def reload(self) -> None: - self._config = self._open(self.path) + return None - def __getitem__(self, name: str) -> ConfigData: - return self._config.get(name, DEFAULT_CONFIG[name]) - def __setitem__(self, name: str, value: ConfigData) -> None: - self._config[name] = value - self._write(self._config) +class Size: + _width: Number + _height: Number + def __init__(self, *, width: Number, height: Number) -> None: + self._width, self._height = width, height -config = Config(CONFIGPATH) + @classmethod + def from_pyproject_toml(cls, path: Path) -> "Size": + with path.open("rb") as fh: + cfg = tomllib.load(fh) + config = cfg.get("tool", {}).get("latexplotlib", {}) -class Size: - _width: Number - _height: Number + return cls( + width=config.get("width", DEFAULT_WIDTH), + height=config.get("height", DEFAULT_HEIGHT), + ) - def __init__(self) -> None: - self._width, self._height = config["width"], config["height"] + @classmethod + def from_config_ini(cls, path: Path) -> "Size": + with path.open(encoding="utf-8") as fh: + config: dict[str, Number] = json.load(fh) - def reload(self) -> None: - config.reload() - self._width, self._height = config["width"], config["height"] + return cls( + width=config.get("width", DEFAULT_WIDTH), + height=config.get("height", DEFAULT_HEIGHT), + ) def get(self) -> tuple[Number, Number]: """Returns the current size of the figure in pts. @@ -96,7 +107,6 @@ def set(self, width: Number, height: Number) -> None: height : int The height of the latex page in pts. """ - config["width"], config["height"] = width, height self._width, self._height = width, height @contextmanager @@ -123,4 +133,16 @@ def __str__(self) -> str: return str(f"{self._width}pt, {self._height}pt") -size = Size() +def get_size() -> Size: + if (path := find_pyproject_toml()) is not None: + # look for config.ini to emit deprecation warning if it exists + _ = find_config_ini() + return Size.from_pyproject_toml(path) + + if (path := find_config_ini()) is not None: + return Size.from_config_ini(path) + + return Size(width=DEFAULT_WIDTH, height=DEFAULT_HEIGHT) + + +size = get_size() diff --git a/tests/test_10_config.py b/tests/test_10_config.py index 5ae8f39..be389e9 100644 --- a/tests/test_10_config.py +++ b/tests/test_10_config.py @@ -1,144 +1,50 @@ -import json - import pytest from latexplotlib import _config as cfg -GOLDEN_RATIO = (5**0.5 + 1) / 2 - - -CONFIGFILE = "config.ini" - -NAME = "latexplotlib" - def test_constants(): - assert cfg.CONFIGFILE - assert cfg.NAME - assert cfg.CONFIGDIR - assert cfg.CONFIGPATH - assert cfg.DEFAULT_CONFIG + assert cfg.DEFAULT_HEIGHT + assert cfg.DEFAULT_WIDTH -class TestConfig: +class TestFindPyprojectToml: @pytest.fixture - def default(self, monkeypatch): - default = {"apple": 10, "egg": 1, "skyscraper": "a"} - monkeypatch.setattr(cfg, "DEFAULT_CONFIG", default) - return default + def path(self, tmp_path, mocker): + path = tmp_path / "a" / "b" / "c" + path.mkdir(parents=True) - @pytest.fixture - def path(self, tmp_path, monkeypatch): - path = tmp_path / "directory" / "dir2" / "tmp.ini" - path.parent.mkdir(parents=True) + mocker.patch("latexplotlib._config.Path.absolute", return_value=path) return path - @pytest.fixture - def config(self, default, path): - with path.open("w", encoding="utf-8") as fh: - json.dump(default, fh) - - return cfg.Config(path) - - @pytest.fixture - def mock_open(self, default, mocker): - return mocker.patch("latexplotlib._config.Config._open", return_value=default) - - def test___init___path_exists(self, default, mock_open, mocker, path): + def test_pyproject_exists(self, path): + path = path.parent.parent / "pyproject.toml" path.touch() - mocker.patch( - "latexplotlib._config.Config.reset", - side_effect=ValueError("Should not happen"), - ) - - config = cfg.Config(path) - - assert config.path == path - assert config._config == default - mock_open.assert_called_once_with(path) - - def test___init___path_not_exists(self, default, mock_open, mocker, path): - reset = mocker.patch( - "latexplotlib._config.Config.reset", side_effect=path.touch - ) - - config = cfg.Config(path) - - assert config.path == path - assert config._config == default - reset.assert_called_once() - mock_open.assert_called_once_with(path) - - def test__open(self, config, default, path): - config._config = None - assert config._open(path) == default - - def test__write(self, config, default, path): - config.path.unlink() - config._write(default) - - cfg = config._open(path) - assert cfg == default - - def test__write_no_parent(self, config, default, path): - config.path.unlink() - config.path.parent.rmdir() - - config._write(default) - - cfg = config._open(path) - assert cfg == default - - def test__write_no_parents_2(self, config, default, path): - config.path.unlink() - config.path.parent.rmdir() - config.path.parent.parent.rmdir() - - config._write(default) - - cfg = config._open(path) - assert cfg == default - def test_reset_path_exists(self, config, default, mocker): - config._write(default) - assert config.path.exists() + cfg.find_pyproject_toml() - mocker.patch.object(config, "_write") + def test_pyproject_not_exists(self, path): + assert cfg.find_pyproject_toml() is None - config.reset() - assert not config.path.exists() - config._write.assert_called_once_with(default) - - def test_reset_path_not_exists(self, config, default, mocker): - config.path.unlink() - assert not config.path.exists() - mocker.patch.object(config, "_write") - - config.reset() - - assert not config.path.exists() - config._write.assert_called_once_with(default) - - def test_reload(self, config, default, mock_open): - config._config = None - config.reload() - - assert config._config == default - mock_open.assert_called_once() +class TestFindConfigIni: + @pytest.fixture + def path(self, tmp_path, monkeypatch): + path = tmp_path / "a" + path.mkdir(parents=True) + path /= "config.123" - def test___getitem__(self, config, default): - for key, item in default.items(): - assert config[key] == item + monkeypatch.setattr(cfg, "CONFIGPATH", path) + return path - def test___setitem__(self, config, default): - assert config["skyscraper"] != "apple" - config["skyscraper"] = "apple" - assert config["skyscraper"] == "apple" + def test_configini_exists(self, path): + path.touch() + with pytest.warns(DeprecationWarning): + cfg.find_config_ini() -def test_config_path(): - assert cfg.config.path == cfg.CONFIGPATH + def test_configini_not_exists(self, path): + assert cfg.find_config_ini() is None class TestSize: @@ -150,20 +56,12 @@ def height(self): def width(self): return 10 - @pytest.fixture(autouse=True) - def _patch_config(self, height, width, monkeypatch, mocker): - d = {"width": width, "height": height} - config = mocker.MagicMock( - __getitem__=lambda _, v: d.__getitem__(v), reload=mocker.MagicMock() - ) - monkeypatch.setattr(cfg, "config", config) - @pytest.fixture - def size(self): - return cfg.Size() + def size(self, width, height): + return cfg.Size(width=width, height=height) def test___init__(self, width, height): - size = cfg.Size() + size = cfg.Size(width=width, height=height) assert size._width == width assert size._height == height @@ -171,20 +69,6 @@ def test___init__(self, width, height): def test_get(self, width, height, size): assert size.get() == (width, height) - def test_set(self, size): - size.set(43, 44) - assert size.get() == (43, 44) - - def test_reload(self, size): - cur = size.get() - - size.set(0, 0) - assert size.get() != cur - - size.reload() - cfg.config.reload.assert_called_once() - assert size.get() == cur - def test_context(self, size): assert size.get() == (10, 20) @@ -198,3 +82,51 @@ def test_str(self, size): def test_repr(self, size): repr(size) + + def test_from_pyproject_toml_complete(self, tmp_path): + width = 123 + height = 456 + path = tmp_path / "pyproject.toml" + with path.open("w") as fh: + fh.writelines( + [ + "[tool.latexplotlib]\n", + f"width = {width}\n", + f"height = {height}\n", + ] + ) + + size = cfg.Size.from_pyproject_toml(path) + assert size._width == width + assert size._height == height + + def test_from_pyproject_toml_empty(self, tmp_path): + path = tmp_path / "pyproject.toml" + path.touch() + + size = cfg.Size.from_pyproject_toml(path) + assert size._width == cfg.DEFAULT_WIDTH + assert size._height == cfg.DEFAULT_HEIGHT + + def test_from_pyproject_toml_tool(self, tmp_path): + path = tmp_path / "pyproject.toml" + with path.open("w") as fh: + fh.writelines(["[tool]\n"]) + + size = cfg.Size.from_pyproject_toml(path) + assert size._width == cfg.DEFAULT_WIDTH + assert size._height == cfg.DEFAULT_HEIGHT + + def test_from_pyproject_toml_tool_latexplotlib(self, tmp_path): + path = tmp_path / "pyproject.toml" + with path.open("w") as fh: + fh.writelines(["[tool.latexplotlib]\n"]) + + size = cfg.Size.from_pyproject_toml(path) + assert size._width == cfg.DEFAULT_WIDTH + assert size._height == cfg.DEFAULT_HEIGHT + + +class TestGetSize: + def test_todo(self): + raise NotImplementedError diff --git a/tests/test_20_cleanup.py b/tests/test_20_cleanup.py deleted file mode 100644 index c1e9a59..0000000 --- a/tests/test_20_cleanup.py +++ /dev/null @@ -1,137 +0,0 @@ -import pytest - -import latexplotlib._cleanup as cleanup - - -def test_styles(): - old_names = { - "latex9pt-minimal.mplstyle", - "latex9pt.mplstyle", - "latex10pt-minimal.mplstyle", - "latex10pt.mplstyle", - "latex11pt-minimal.mplstyle", - "latex11pt.mplstyle", - "latex12pt-minimal.mplstyle", - "latex12pt.mplstyle", - } - assert old_names == cleanup.STYLES - - -class TestPurgeOldStyles: - @pytest.fixture - def purged_str(self, monkeypatch): - s = "banana" - monkeypatch.setattr(cleanup, "_PURGED_OLD", s) - return s - - @pytest.fixture(autouse=True) - def config(self, purged_str, monkeypatch): - default = {purged_str: False} - monkeypatch.setattr(cleanup, "config", default) - - def fun(): - assert default[purged_str] is True, "'is_purged'-flag is not set to True" - - self.assert_purged_true = fun - return default - - @pytest.fixture - def styles_folder(self, monkeypatch): - styles_folder = "new" - monkeypatch.setattr(cleanup, "STYLES_FOLDER", styles_folder) - return styles_folder - - @pytest.fixture - def new(self, tmp_path, styles_folder): - path = tmp_path / styles_folder - path.mkdir() - return path - - @pytest.fixture - def stylelib(self, monkeypatch): - stylelib = "old" - monkeypatch.setattr(cleanup, "STYLELIB", stylelib) - return stylelib - - @pytest.fixture - def old(self, tmp_path, stylelib): - path = tmp_path / stylelib - path.mkdir() - return path - - @pytest.fixture(autouse=True) - def mock_configdir(self, mocker, tmp_path): - return mocker.patch( - "latexplotlib._cleanup.get_configdir", return_value=tmp_path - ) - - def test_is_purged(self, config, purged_str, mock_configdir): - config[purged_str] = True - cleanup.purge_old_styles([]) - - assert not mock_configdir.called - self.assert_purged_true() - - def test_old_styledir_not_exists(self, mock_configdir, old): - old.rmdir() - - cleanup.purge_old_styles([]) - mock_configdir.assert_called_once() - - self.assert_purged_true() - - def test_empty_dir(self, mock_configdir, new): - cleanup.purge_old_styles([new.parent]) - mock_configdir.assert_called_once() - - self.assert_purged_true() - - @pytest.fixture( - params=[ - "latex9pt-minimal.mplstyle", - "latex9pt.mplstyle", - "latex10pt-minimal.mplstyle", - "latex10pt.mplstyle", - "latex11pt-minimal.mplstyle", - "latex11pt.mplstyle", - "latex12pt-minimal.mplstyle", - "latex12pt.mplstyle", - ] - ) - def mplstyle(self, request): - return request.param - - def test_removes_file(self, new, old, mplstyle, mocker): - cmp_spy = mocker.spy(cleanup.filecmp, "cmp") - - new_file = new / mplstyle - old_file = old / mplstyle - new_file.touch() - old_file.touch() - assert new_file.exists() - assert old_file.exists() - - cleanup.purge_old_styles([new.parent]) - - assert new_file.exists() - assert not old_file.exists() - cmp_spy.assert_called_once_with(new_file, old_file, shallow=False) - - def test_file_different(self, new, old, mplstyle, mocker): - cmp_spy = mocker.spy(cleanup.filecmp, "cmp") - - new_file = new / mplstyle - old_file = old / mplstyle - new_file.touch() - - with old_file.open("w") as fh: - fh.write("1\n") - - assert new_file.exists() - assert old_file.exists() - - cleanup.purge_old_styles([new.parent]) - - assert new_file.exists() - assert old_file.exists() - cmp_spy.assert_called_once_with(new_file, old_file, shallow=False)