From 0008724a50e96760dd59c16df26ad5252587244f Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:33:48 +0100 Subject: [PATCH 1/2] fix(colormap): store custom colormaps in the application config dir PlotPy's configuration had no application name, so custom colormaps were saved in a "~/.none" directory shared by all applications, which was created as soon as colormaps were loaded. Their path was moreover computed at import time, so that an application naming PlotPy's configuration afterwards (e.g. "DataLab_v1/plotpy") never got its own custom colormaps. - Name PlotPy's configuration "plotpy" by default - Compute the custom colormaps path on demand, and reload custom colormaps in place each time CONF.set_application is called (CUSTOM_COLORMAPS_PATH remains available, computed on demand) - The first time, copy custom colormaps from the legacy ".none" and ".plotpy" directories, without modifying the legacy files - Do not create the configuration directory on path lookups - Run colormap tests in a temporary configuration directory Closes #70 Assisted-by: Claude Opus 5.5 --- plotpy/config.py | 60 +++++++- plotpy/mathutils/colormap.py | 144 +++++++++++++++--- plotpy/tests/conftest.py | 18 +++ .../tests/features/test_colormap_manager.py | 1 + plotpy/tests/unit/test_colormap_config.py | 124 +++++++++++++++ 5 files changed, 322 insertions(+), 25 deletions(-) create mode 100644 plotpy/tests/unit/test_colormap_config.py diff --git a/plotpy/config.py b/plotpy/config.py index 4f11b577..b8588179 100644 --- a/plotpy/config.py +++ b/plotpy/config.py @@ -15,11 +15,11 @@ import os.path as osp import warnings -from typing import Literal +from typing import Callable, Literal from guidata import qthelpers as qth from guidata.configtools import add_image_module_path, get_translation -from guidata.userconfig import UserConfig +from guidata.userconfig import UserConfig, get_config_basedir def get_main_colors() -> tuple[str, str]: @@ -786,9 +786,63 @@ def get_plotpy_defaults() -> dict[str, int | float | str | bool]: } +class PlotPyUserConfig(UserConfig): + """PlotPy configuration + + The configuration directory, which contains PlotPy's user files (e.g. custom + colormaps), depends on the application name: ``.plotpy`` by default, in the user's + configuration directory. Host applications may store these files in their own + configuration directory by calling :py:meth:`set_application`. + + Unlike :py:class:`guidata.userconfig.UserConfig`, looking up a path does not create + the configuration directory: it is only created when a file is written. + """ + + def __init__(self, defaults: dict) -> None: + super().__init__(defaults) + self.__application_callbacks: list[Callable[[], None]] = [] + + def register_application_callback(self, callback: Callable[[], None]) -> None: + """Register a function to call each time the application name is set + + Args: + callback: function without arguments + """ + if callback not in self.__application_callbacks: + self.__application_callbacks.append(callback) + + def set_application( + self, name: str, version: str | None, load: bool = True, raw_mode: bool = False + ) -> None: + """Set the application name and version, then call registered callbacks + + Args: + name: name of the application, which is also the name of the + configuration directory (e.g. ``"MyApp/plotpy"``) + version: current version in format "X.Y.Z" (or None) + load: if True, load the configuration from the .ini file + raw_mode: if True, enable raw mode of ConfigParser + """ + super().set_application(name, version, load=load, raw_mode=raw_mode) + for callback in self.__application_callbacks: + callback() + + def get_path(self, basename: str) -> str: + """Return filename path inside configuration directory + + Args: + basename: file name (or absolute path, which is returned as is) + + Returns: + Absolute path of the file (the directory is not created) + """ + return osp.join(get_config_basedir(), f".{self.name}", basename) + + DEFAULTS = get_plotpy_defaults() -CONF = UserConfig(DEFAULTS) +CONF = PlotPyUserConfig(DEFAULTS) +CONF.set_application("plotpy", None, load=False) def update_plotpy_defaults() -> None: diff --git a/plotpy/mathutils/colormap.py b/plotpy/mathutils/colormap.py index fece04bf..06fc9c2d 100644 --- a/plotpy/mathutils/colormap.py +++ b/plotpy/mathutils/colormap.py @@ -18,6 +18,8 @@ * :py:func:`.get_cmap`: get a colormap from its name * :py:func:`.cmap_exists`: check if a colormap exists * :py:func:`.add_cmap`: add a colormap to the list of available colormaps +* :py:func:`.get_custom_colormaps_path`: get the path of the custom colormaps file +* :py:func:`.reload_custom_colormaps`: reload custom colormaps from this file * :py:func:`.build_icon_from_cmap`: build an icon representing the colormap * :py:func:`.build_icon_from_cmap_name`: build an icon representing the colormap from its name @@ -26,12 +28,23 @@ logarithmic scale (e.g. for the color bar of images shown with a logarithmic Z-axis). +Custom colormaps (e.g. created with the colormap manager) are saved in the +configuration directory of PlotPy: ``.plotpy`` by default, in the user's configuration +directory (``~/.config`` on Linux, the user's home directory otherwise). Applications +may save them in their own configuration directory by setting the application name of +PlotPy's configuration, e.g. ``plotpy.config.CONF.set_application("MyApp/plotpy", +"1.0.0")``: custom colormaps are then reloaded from this directory. The first time, +custom colormaps saved by previous versions of PlotPy (in the ``.none`` or ``.plotpy`` +directory) are copied there. + Reference ^^^^^^^^^ .. autofunction:: get_cmap .. autofunction:: cmap_exists .. autofunction:: add_cmap +.. autofunction:: get_custom_colormaps_path +.. autofunction:: reload_custom_colormaps .. autofunction:: build_icon_from_cmap .. autofunction:: build_icon_from_cmap_name .. autoclass:: LogScaleColorMap @@ -43,12 +56,15 @@ import json import math import os -from typing import Dict, Literal, Sequence +import os.path as osp +import warnings +from typing import Any, Dict, Literal, Sequence import numpy as np import qtpy.QtCore as QC import qtpy.QtGui as QG from guidata.configtools import get_module_data_path +from guidata.userconfig import get_config_basedir from qwt import QwtInterval, toQImage from qwt.color_map import QwtColorMap @@ -133,6 +149,85 @@ def get_cmap_path(config_path: str): return user_config_path +def _save_raw_colormaps(json_path: str, raw_colormaps: dict[str, Any]) -> None: + """Saves raw colormaps into the given json file, creating its directory if needed. + + Args: + json_path: absolute path of the json file in which to save the colormaps + raw_colormaps: Dictionnary of colormaps names -> raw colormap sequences + """ + os.makedirs(osp.dirname(json_path), exist_ok=True) + with open(json_path, "w", encoding="utf-8") as f: + json.dump(raw_colormaps, f, indent=4) + + +def save_colormaps(json_filename: str, colormaps: CmapDictType): + """Saves colormaps into the given json file. Refer ton function get_cmap_path to + know what json_filename can be used. + + Args: + json_filename: json file name/path in which to save the colormaps + colormaps: Dictionnary of colormpas names -> CustomQwtLinearColormap + """ + raw_colormaps = {cmap.name: cmap.to_tuples() for cmap in colormaps.values()} + _save_raw_colormaps(CONF.get_path(json_filename), raw_colormaps) + + +#: Configuration directories in which previous versions of PlotPy saved the custom +#: colormaps of all applications (``.none`` when PlotPy's configuration had no +#: application name, ``.plotpy`` when the host application named it "plotpy") +LEGACY_CUSTOM_COLORMAPS_DIRS = (".none", ".plotpy") + + +def _get_custom_colormaps_filename() -> str: + """Returns the "colormaps/custom" option of PlotPy's configuration""" + return CONF.get( + "colormaps", + "colormaps/custom", + default="colormaps_custom.json", # type: ignore + ) + + +def get_custom_colormaps_path() -> str: + """Returns the path of the json file in which custom colormaps are saved. + + This file is located in the configuration directory of the application set with + ``plotpy.config.CONF.set_application`` (``.plotpy`` by default, in the user's + configuration directory), unless the "colormaps/custom" option of the "colormaps" + configuration section is an absolute path. + + Returns: + Absolute path of the custom colormaps json file (which may not exist yet) + """ + return CONF.get_path(_get_custom_colormaps_filename()) + + +def load_custom_colormaps() -> CmapDictType: + """Loads the custom colormaps of the current application (see function + get_custom_colormaps_path). + + If the custom colormaps file of the application does not exist yet, the custom + colormaps saved by previous versions of PlotPy (see LEGACY_CUSTOM_COLORMAPS_DIRS) + are first copied into it. The legacy files are left untouched. + + Returns: + Dictionnary of colormaps names -> CustomQwtLinearColormap + """ + filename = _get_custom_colormaps_filename() + json_path = CONF.get_path(filename) + if not osp.isfile(json_path) and not osp.isabs(filename): + raw_colormaps: dict[str, Any] = {} + for dirname in LEGACY_CUSTOM_COLORMAPS_DIRS: + legacy_path = osp.join(get_config_basedir(), dirname, filename) + raw_colormaps.update(load_raw_colormaps_from_json(legacy_path)) + if raw_colormaps: + try: + _save_raw_colormaps(json_path, raw_colormaps) + except OSError as exc: + warnings.warn(f"Unable to copy custom colormaps to {json_path}: {exc}") + return load_qwt_colormaps_from_json(json_path) + + # Load default colormaps path from the config file DEFAULT_COLORMAPS_PATH = get_cmap_path( CONF.get( @@ -141,18 +236,10 @@ def get_cmap_path(config_path: str): default="colormaps_default.json", # type: ignore ) ) -# Load custom colormaps path from the config file -CUSTOM_COLORMAPS_PATH = get_cmap_path( - CONF.get( - "colormaps", - "colormaps/custom", - default="colormaps_custom.json", # type: ignore - ) -) # Load default and custom colormaps from json files DEFAULT_COLORMAPS: CmapDictType = load_qwt_colormaps_from_json(DEFAULT_COLORMAPS_PATH) -CUSTOM_COLORMAPS: CmapDictType = load_qwt_colormaps_from_json(CUSTOM_COLORMAPS_PATH) +CUSTOM_COLORMAPS: CmapDictType = load_custom_colormaps() # Merge default and custom colormaps into a single dictionnary to simplify access ALL_COLORMAPS: CmapDictType = {**DEFAULT_COLORMAPS, **CUSTOM_COLORMAPS} @@ -161,18 +248,31 @@ def get_cmap_path(config_path: str): DEFAULT = ALL_COLORMAPS["jet"] -def save_colormaps(json_filename: str, colormaps: CmapDictType): - """Saves colormaps into the given json file. Refer ton function get_cmap_path to - know what json_filename can be used. +def reload_custom_colormaps() -> None: + """Reloads the custom colormaps of the current application (see function + load_custom_colormaps). - Args: - json_filename: json file name/path in which to save the colormaps - colormaps: Dictionnary of colormpas names -> CustomQwtLinearColormap + This function is called each time the application name of PlotPy's configuration + is set with ``plotpy.config.CONF.set_application``. The CUSTOM_COLORMAPS and + ALL_COLORMAPS global variables are updated in place. """ - raw_colormaps = {cmap.name: cmap.to_tuples() for cmap in colormaps.values()} - json_abs_path = CONF.get_path(json_filename) - with open(json_abs_path, "w", encoding="utf-8") as f: - json.dump(raw_colormaps, f, indent=4) + custom_colormaps = load_custom_colormaps() + CUSTOM_COLORMAPS.clear() + CUSTOM_COLORMAPS.update(custom_colormaps) + ALL_COLORMAPS.clear() + ALL_COLORMAPS.update(DEFAULT_COLORMAPS) + ALL_COLORMAPS.update(custom_colormaps) + + +CONF.register_application_callback(reload_custom_colormaps) + + +def __getattr__(name: str) -> Any: + """Returns module attributes computed on demand""" + if name == "CUSTOM_COLORMAPS_PATH": + # Kept for backward compatibility: this path depends on the application name + return get_custom_colormaps_path() + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") def build_icon_from_cmap( @@ -300,7 +400,7 @@ def add_cmap(cmap: EditableColormap) -> None: global ALL_COLORMAPS, CUSTOM_COLORMAPS ALL_COLORMAPS[cmap.name.lower()] = cmap CUSTOM_COLORMAPS[cmap.name.lower()] = cmap - save_colormaps(CUSTOM_COLORMAPS_PATH, CUSTOM_COLORMAPS) + save_colormaps(get_custom_colormaps_path(), CUSTOM_COLORMAPS) def delete_cmap(cmap: EditableColormap) -> None: @@ -308,7 +408,7 @@ def delete_cmap(cmap: EditableColormap) -> None: global ALL_COLORMAPS, CUSTOM_COLORMAPS if CUSTOM_COLORMAPS.pop(cmap.name.lower(), None) is not None: del ALL_COLORMAPS[cmap.name.lower()] - save_colormaps(CUSTOM_COLORMAPS_PATH, CUSTOM_COLORMAPS) + save_colormaps(get_custom_colormaps_path(), CUSTOM_COLORMAPS) class LogScaleColorMap(QwtColorMap): diff --git a/plotpy/tests/conftest.py b/plotpy/tests/conftest.py index 998d895c..d7b3ee24 100644 --- a/plotpy/tests/conftest.py +++ b/plotpy/tests/conftest.py @@ -72,6 +72,24 @@ def disable_gc_for_tests(): gc.enable() +@pytest.fixture +def isolated_config_dir(tmp_path): + """Use a temporary user configuration directory for PlotPy's user files (e.g. + custom colormaps), restoring the application name and custom colormaps afterwards. + """ + # pylint: disable=import-outside-toplevel + from plotpy.config import CONF + from plotpy.mathutils.colormap import reload_custom_colormaps + + app_name = CONF.name + with pytest.MonkeyPatch.context() as mpatch: + for varname in ("HOME", "USERPROFILE", "XDG_CONFIG_HOME"): + mpatch.setenv(varname, str(tmp_path)) + reload_custom_colormaps() + yield tmp_path + CONF.set_application(app_name, None, load=False) + + def pytest_report_header(config): """Add additional information to the pytest report header.""" qtbindings_version = qtpy.PYSIDE_VERSION diff --git a/plotpy/tests/features/test_colormap_manager.py b/plotpy/tests/features/test_colormap_manager.py index 78953bf1..149b409c 100644 --- a/plotpy/tests/features/test_colormap_manager.py +++ b/plotpy/tests/features/test_colormap_manager.py @@ -33,6 +33,7 @@ def test_cmap(cmap_name="Kinda Viridis"): delete_cmap(get_cmap(cmap_name)) +@pytest.mark.usefixtures("isolated_config_dir") def test_colormap_manager(test_cmap: EditableColormap) -> None: """Test the colormap manager widget.""" with qt_app_context(exec_loop=False): diff --git a/plotpy/tests/unit/test_colormap_config.py b/plotpy/tests/unit/test_colormap_config.py new file mode 100644 index 00000000..36845294 --- /dev/null +++ b/plotpy/tests/unit/test_colormap_config.py @@ -0,0 +1,124 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the terms of the BSD 3-Clause +# (see plotpy/LICENSE for details) + +"""Custom colormaps location tests + +Custom colormaps are saved in the configuration directory of the application set with +``CONF.set_application``, and copied from legacy directories the first time. +""" + +from __future__ import annotations + +import json +import os +import os.path as osp + +import pytest +import qtpy.QtCore as QC +import qtpy.QtGui as QG + +from plotpy.config import CONF +from plotpy.mathutils import colormap as cmapmod +from plotpy.widgets.colormap.widget import EditableColormap + +RAW_CMAP = [[0.0, "#0000ff"], [1.0, "#ffff00"]] +CUSTOM_BASENAME = "colormaps_custom.json" + + +def write_json(path: str, data: dict) -> None: + """Write data to a json file, creating its directory""" + os.makedirs(osp.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as fdesc: + json.dump(data, fdesc) + + +def read_json(path: str) -> dict: + """Read a json file""" + with open(path, encoding="utf-8") as fdesc: + return json.load(fdesc) + + +def app_path(config_dir: str, app_name: str) -> str: + """Return the expected custom colormaps path of an application""" + return osp.normpath(osp.join(config_dir, f".{app_name}", CUSTOM_BASENAME)) + + +def test_default_application_name() -> None: + """PlotPy's configuration has its own name by default""" + assert CONF.name == "plotpy" + + +def test_no_directory_created_on_lookup(isolated_config_dir) -> None: + """Looking up configuration paths does not create any directory""" + config_dir = str(isolated_config_dir) + CONF.set_application("MyApp_v1/plotpy", None, load=False) + cmapmod.get_custom_colormaps_path() + CONF.filename() + assert os.listdir(config_dir) == [] + + +def test_custom_colormaps_follow_application(isolated_config_dir) -> None: + """Custom colormaps are saved to, and reloaded from, the application directory""" + config_dir = str(isolated_config_dir) + all_cmaps, custom_cmaps = cmapmod.ALL_COLORMAPS, cmapmod.CUSTOM_COLORMAPS + name = "Issue 70 colormap" + cmap = EditableColormap( + [QG.QColor(QC.Qt.GlobalColor.blue), QG.QColor(QC.Qt.GlobalColor.yellow)], + name=name, + ) + + CONF.set_application("MyApp_v1/plotpy", None, load=False) + path = app_path(config_dir, "MyApp_v1/plotpy") + assert osp.normpath(cmapmod.get_custom_colormaps_path()) == path + assert cmapmod.CUSTOM_COLORMAPS_PATH == cmapmod.get_custom_colormaps_path() + cmapmod.add_cmap(cmap) + assert list(read_json(path)) == [name] + assert os.listdir(config_dir) == [".MyApp_v1"] + + CONF.set_application("OtherApp/plotpy", None, load=False) + assert not cmapmod.cmap_exists(name) + assert cmapmod.cmap_exists("jet") + + CONF.set_application("MyApp_v1/plotpy", None, load=False) + assert cmapmod.cmap_exists(name) + assert cmapmod.cmap_exists(name, cmapmod.CUSTOM_COLORMAPS) + # Dictionaries imported by other modules are updated in place: + assert cmapmod.ALL_COLORMAPS is all_cmaps + assert cmapmod.CUSTOM_COLORMAPS is custom_cmaps + + cmapmod.delete_cmap(cmapmod.get_cmap(name)) + assert read_json(path) == {} + + +@pytest.mark.parametrize( + "app_name, legacy_dirs", + [ + ("MyApp_v1/plotpy", (".none", ".plotpy")), + ("plotpy", (".none",)), + ], +) +def test_legacy_custom_colormaps_migration( + isolated_config_dir, app_name: str, legacy_dirs: tuple[str, ...] +) -> None: + """Custom colormaps saved in legacy directories are copied the first time""" + config_dir = str(isolated_config_dir) + legacy_files = {} + for dirname in legacy_dirs: + legacy_path = osp.join(config_dir, dirname, CUSTOM_BASENAME) + legacy_files[legacy_path] = {f"From {dirname}": RAW_CMAP} + write_json(legacy_path, legacy_files[legacy_path]) + + CONF.set_application(app_name, None, load=False) + names = sorted(f"From {dirname}" for dirname in legacy_dirs) + assert sorted(read_json(app_path(config_dir, app_name))) == names + for name in names: + assert cmapmod.cmap_exists(name, cmapmod.CUSTOM_COLORMAPS) + for legacy_path, data in legacy_files.items(): + assert read_json(legacy_path) == data + + # Once the application has its own file, legacy files are ignored: + write_json(osp.join(config_dir, ".none", CUSTOM_BASENAME), {"Later": RAW_CMAP}) + CONF.set_application(app_name, None, load=False) + assert not cmapmod.cmap_exists("Later") From 39f87e841faeeb6e0ef4f192bce509b14079979d Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:33:50 +0100 Subject: [PATCH 2/2] docs: add release notes for the custom colormaps location fix Assisted-by: Claude Opus 5.5 --- doc/release_notes/release_2.12.md | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/release_notes/release_2.12.md b/doc/release_notes/release_2.12.md index ece84d39..d6bfb8bb 100644 --- a/doc/release_notes/release_2.12.md +++ b/doc/release_notes/release_2.12.md @@ -12,6 +12,7 @@ * **Editing points of downsampled curves** — With downsampling enabled, moving a curve point modified another point of the curve data than the one shown, since the handle of a drawn point was used as an index into the full data * **Curve fitting dialog** — Running the automatic fit no longer raises `OverflowError: argument 1 overflowed` errors when a fitted value ends up far outside the range of its slider (e.g. with the Powell method on a fit range restricted to the tail of a peak). These errors left the slider of the parameter unresponsive, and the values of the following parameters were not displayed. Such values are now shown with a warning icon, whose tooltip gives the slider range, while the slider stays at its nearest end. Typing `nan` or `inf` in a parameter field now restores the previous value instead of raising errors, and the number of slider steps must be at least 2 in the parameter properties dialog (closes [Issue #76](https://github.com/PlotPyStack/PlotPy/issues/76)) * **Cross-section panels with many hidden images** — X/Y cross-section panels kept one curve for every image ever displayed, even after it was hidden, so plots containing hundreds of hidden images became progressively slower to refresh. Curves are now only kept for visible images, and are recreated when a hidden image is shown again +* **Custom colormaps location** — Custom colormaps were saved in a `.none` directory of the user's configuration directory (`~/.config/.none` on Linux, `%USERPROFILE%\.none` on Windows), shared by all applications whatever their own configuration directory, and this directory was created as soon as colormaps were loaded. Custom colormaps are now saved in PlotPy's configuration directory (`.plotpy` by default) or, if the application sets the name of PlotPy's configuration with `plotpy.config.CONF.set_application` (e.g. `"MyApp/plotpy"`), in the configuration directory of this application, from which they are reloaded even if the name is set after PlotPy was imported. The first time, custom colormaps saved in the former `.none` and `.plotpy` directories are copied to the new location, without modifying the former files. Looking up the location of PlotPy's configuration files no longer creates any directory (closes [Issue #70](https://github.com/PlotPyStack/PlotPy/issues/70)) ⚡ Performance: