Skip to content

Commit 9984b49

Browse files
Fix custom colormaps location (store them in the application config dir) (#88)
* 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 * docs: add release notes for the custom colormaps location fix Assisted-by: Claude Opus 5.5
2 parents a893a5a + 39f87e8 commit 9984b49

6 files changed

Lines changed: 323 additions & 25 deletions

File tree

‎doc/release_notes/release_2.12.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
* **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
1313
* **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))
1414
* **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
15+
* **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))
1516

1617
⚡ Performance:
1718

‎plotpy/config.py‎

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@
1515

1616
import os.path as osp
1717
import warnings
18-
from typing import Literal
18+
from typing import Callable, Literal
1919

2020
from guidata import qthelpers as qth
2121
from guidata.configtools import add_image_module_path, get_translation
22-
from guidata.userconfig import UserConfig
22+
from guidata.userconfig import UserConfig, get_config_basedir
2323

2424

2525
def get_main_colors() -> tuple[str, str]:
@@ -786,9 +786,63 @@ def get_plotpy_defaults() -> dict[str, int | float | str | bool]:
786786
}
787787

788788

789+
class PlotPyUserConfig(UserConfig):
790+
"""PlotPy configuration
791+
792+
The configuration directory, which contains PlotPy's user files (e.g. custom
793+
colormaps), depends on the application name: ``.plotpy`` by default, in the user's
794+
configuration directory. Host applications may store these files in their own
795+
configuration directory by calling :py:meth:`set_application`.
796+
797+
Unlike :py:class:`guidata.userconfig.UserConfig`, looking up a path does not create
798+
the configuration directory: it is only created when a file is written.
799+
"""
800+
801+
def __init__(self, defaults: dict) -> None:
802+
super().__init__(defaults)
803+
self.__application_callbacks: list[Callable[[], None]] = []
804+
805+
def register_application_callback(self, callback: Callable[[], None]) -> None:
806+
"""Register a function to call each time the application name is set
807+
808+
Args:
809+
callback: function without arguments
810+
"""
811+
if callback not in self.__application_callbacks:
812+
self.__application_callbacks.append(callback)
813+
814+
def set_application(
815+
self, name: str, version: str | None, load: bool = True, raw_mode: bool = False
816+
) -> None:
817+
"""Set the application name and version, then call registered callbacks
818+
819+
Args:
820+
name: name of the application, which is also the name of the
821+
configuration directory (e.g. ``"MyApp/plotpy"``)
822+
version: current version in format "X.Y.Z" (or None)
823+
load: if True, load the configuration from the .ini file
824+
raw_mode: if True, enable raw mode of ConfigParser
825+
"""
826+
super().set_application(name, version, load=load, raw_mode=raw_mode)
827+
for callback in self.__application_callbacks:
828+
callback()
829+
830+
def get_path(self, basename: str) -> str:
831+
"""Return filename path inside configuration directory
832+
833+
Args:
834+
basename: file name (or absolute path, which is returned as is)
835+
836+
Returns:
837+
Absolute path of the file (the directory is not created)
838+
"""
839+
return osp.join(get_config_basedir(), f".{self.name}", basename)
840+
841+
789842
DEFAULTS = get_plotpy_defaults()
790843

791-
CONF = UserConfig(DEFAULTS)
844+
CONF = PlotPyUserConfig(DEFAULTS)
845+
CONF.set_application("plotpy", None, load=False)
792846

793847

794848
def update_plotpy_defaults() -> None:

‎plotpy/mathutils/colormap.py‎

Lines changed: 122 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
* :py:func:`.get_cmap`: get a colormap from its name
1919
* :py:func:`.cmap_exists`: check if a colormap exists
2020
* :py:func:`.add_cmap`: add a colormap to the list of available colormaps
21+
* :py:func:`.get_custom_colormaps_path`: get the path of the custom colormaps file
22+
* :py:func:`.reload_custom_colormaps`: reload custom colormaps from this file
2123
* :py:func:`.build_icon_from_cmap`: build an icon representing the colormap
2224
* :py:func:`.build_icon_from_cmap_name`: build an icon representing the colormap
2325
from its name
@@ -26,12 +28,23 @@
2628
logarithmic scale (e.g. for the color bar of images shown with a logarithmic
2729
Z-axis).
2830
31+
Custom colormaps (e.g. created with the colormap manager) are saved in the
32+
configuration directory of PlotPy: ``.plotpy`` by default, in the user's configuration
33+
directory (``~/.config`` on Linux, the user's home directory otherwise). Applications
34+
may save them in their own configuration directory by setting the application name of
35+
PlotPy's configuration, e.g. ``plotpy.config.CONF.set_application("MyApp/plotpy",
36+
"1.0.0")``: custom colormaps are then reloaded from this directory. The first time,
37+
custom colormaps saved by previous versions of PlotPy (in the ``.none`` or ``.plotpy``
38+
directory) are copied there.
39+
2940
Reference
3041
^^^^^^^^^
3142
3243
.. autofunction:: get_cmap
3344
.. autofunction:: cmap_exists
3445
.. autofunction:: add_cmap
46+
.. autofunction:: get_custom_colormaps_path
47+
.. autofunction:: reload_custom_colormaps
3548
.. autofunction:: build_icon_from_cmap
3649
.. autofunction:: build_icon_from_cmap_name
3750
.. autoclass:: LogScaleColorMap
@@ -43,12 +56,15 @@
4356
import json
4457
import math
4558
import os
46-
from typing import Dict, Literal, Sequence
59+
import os.path as osp
60+
import warnings
61+
from typing import Any, Dict, Literal, Sequence
4762

4863
import numpy as np
4964
import qtpy.QtCore as QC
5065
import qtpy.QtGui as QG
5166
from guidata.configtools import get_module_data_path
67+
from guidata.userconfig import get_config_basedir
5268
from qwt import QwtInterval, toQImage
5369
from qwt.color_map import QwtColorMap
5470

@@ -133,6 +149,85 @@ def get_cmap_path(config_path: str):
133149
return user_config_path
134150

135151

152+
def _save_raw_colormaps(json_path: str, raw_colormaps: dict[str, Any]) -> None:
153+
"""Saves raw colormaps into the given json file, creating its directory if needed.
154+
155+
Args:
156+
json_path: absolute path of the json file in which to save the colormaps
157+
raw_colormaps: Dictionnary of colormaps names -> raw colormap sequences
158+
"""
159+
os.makedirs(osp.dirname(json_path), exist_ok=True)
160+
with open(json_path, "w", encoding="utf-8") as f:
161+
json.dump(raw_colormaps, f, indent=4)
162+
163+
164+
def save_colormaps(json_filename: str, colormaps: CmapDictType):
165+
"""Saves colormaps into the given json file. Refer ton function get_cmap_path to
166+
know what json_filename can be used.
167+
168+
Args:
169+
json_filename: json file name/path in which to save the colormaps
170+
colormaps: Dictionnary of colormpas names -> CustomQwtLinearColormap
171+
"""
172+
raw_colormaps = {cmap.name: cmap.to_tuples() for cmap in colormaps.values()}
173+
_save_raw_colormaps(CONF.get_path(json_filename), raw_colormaps)
174+
175+
176+
#: Configuration directories in which previous versions of PlotPy saved the custom
177+
#: colormaps of all applications (``.none`` when PlotPy's configuration had no
178+
#: application name, ``.plotpy`` when the host application named it "plotpy")
179+
LEGACY_CUSTOM_COLORMAPS_DIRS = (".none", ".plotpy")
180+
181+
182+
def _get_custom_colormaps_filename() -> str:
183+
"""Returns the "colormaps/custom" option of PlotPy's configuration"""
184+
return CONF.get(
185+
"colormaps",
186+
"colormaps/custom",
187+
default="colormaps_custom.json", # type: ignore
188+
)
189+
190+
191+
def get_custom_colormaps_path() -> str:
192+
"""Returns the path of the json file in which custom colormaps are saved.
193+
194+
This file is located in the configuration directory of the application set with
195+
``plotpy.config.CONF.set_application`` (``.plotpy`` by default, in the user's
196+
configuration directory), unless the "colormaps/custom" option of the "colormaps"
197+
configuration section is an absolute path.
198+
199+
Returns:
200+
Absolute path of the custom colormaps json file (which may not exist yet)
201+
"""
202+
return CONF.get_path(_get_custom_colormaps_filename())
203+
204+
205+
def load_custom_colormaps() -> CmapDictType:
206+
"""Loads the custom colormaps of the current application (see function
207+
get_custom_colormaps_path).
208+
209+
If the custom colormaps file of the application does not exist yet, the custom
210+
colormaps saved by previous versions of PlotPy (see LEGACY_CUSTOM_COLORMAPS_DIRS)
211+
are first copied into it. The legacy files are left untouched.
212+
213+
Returns:
214+
Dictionnary of colormaps names -> CustomQwtLinearColormap
215+
"""
216+
filename = _get_custom_colormaps_filename()
217+
json_path = CONF.get_path(filename)
218+
if not osp.isfile(json_path) and not osp.isabs(filename):
219+
raw_colormaps: dict[str, Any] = {}
220+
for dirname in LEGACY_CUSTOM_COLORMAPS_DIRS:
221+
legacy_path = osp.join(get_config_basedir(), dirname, filename)
222+
raw_colormaps.update(load_raw_colormaps_from_json(legacy_path))
223+
if raw_colormaps:
224+
try:
225+
_save_raw_colormaps(json_path, raw_colormaps)
226+
except OSError as exc:
227+
warnings.warn(f"Unable to copy custom colormaps to {json_path}: {exc}")
228+
return load_qwt_colormaps_from_json(json_path)
229+
230+
136231
# Load default colormaps path from the config file
137232
DEFAULT_COLORMAPS_PATH = get_cmap_path(
138233
CONF.get(
@@ -141,18 +236,10 @@ def get_cmap_path(config_path: str):
141236
default="colormaps_default.json", # type: ignore
142237
)
143238
)
144-
# Load custom colormaps path from the config file
145-
CUSTOM_COLORMAPS_PATH = get_cmap_path(
146-
CONF.get(
147-
"colormaps",
148-
"colormaps/custom",
149-
default="colormaps_custom.json", # type: ignore
150-
)
151-
)
152239

153240
# Load default and custom colormaps from json files
154241
DEFAULT_COLORMAPS: CmapDictType = load_qwt_colormaps_from_json(DEFAULT_COLORMAPS_PATH)
155-
CUSTOM_COLORMAPS: CmapDictType = load_qwt_colormaps_from_json(CUSTOM_COLORMAPS_PATH)
242+
CUSTOM_COLORMAPS: CmapDictType = load_custom_colormaps()
156243

157244
# Merge default and custom colormaps into a single dictionnary to simplify access
158245
ALL_COLORMAPS: CmapDictType = {**DEFAULT_COLORMAPS, **CUSTOM_COLORMAPS}
@@ -161,18 +248,31 @@ def get_cmap_path(config_path: str):
161248
DEFAULT = ALL_COLORMAPS["jet"]
162249

163250

164-
def save_colormaps(json_filename: str, colormaps: CmapDictType):
165-
"""Saves colormaps into the given json file. Refer ton function get_cmap_path to
166-
know what json_filename can be used.
251+
def reload_custom_colormaps() -> None:
252+
"""Reloads the custom colormaps of the current application (see function
253+
load_custom_colormaps).
167254
168-
Args:
169-
json_filename: json file name/path in which to save the colormaps
170-
colormaps: Dictionnary of colormpas names -> CustomQwtLinearColormap
255+
This function is called each time the application name of PlotPy's configuration
256+
is set with ``plotpy.config.CONF.set_application``. The CUSTOM_COLORMAPS and
257+
ALL_COLORMAPS global variables are updated in place.
171258
"""
172-
raw_colormaps = {cmap.name: cmap.to_tuples() for cmap in colormaps.values()}
173-
json_abs_path = CONF.get_path(json_filename)
174-
with open(json_abs_path, "w", encoding="utf-8") as f:
175-
json.dump(raw_colormaps, f, indent=4)
259+
custom_colormaps = load_custom_colormaps()
260+
CUSTOM_COLORMAPS.clear()
261+
CUSTOM_COLORMAPS.update(custom_colormaps)
262+
ALL_COLORMAPS.clear()
263+
ALL_COLORMAPS.update(DEFAULT_COLORMAPS)
264+
ALL_COLORMAPS.update(custom_colormaps)
265+
266+
267+
CONF.register_application_callback(reload_custom_colormaps)
268+
269+
270+
def __getattr__(name: str) -> Any:
271+
"""Returns module attributes computed on demand"""
272+
if name == "CUSTOM_COLORMAPS_PATH":
273+
# Kept for backward compatibility: this path depends on the application name
274+
return get_custom_colormaps_path()
275+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
176276

177277

178278
def build_icon_from_cmap(
@@ -300,15 +400,15 @@ def add_cmap(cmap: EditableColormap) -> None:
300400
global ALL_COLORMAPS, CUSTOM_COLORMAPS
301401
ALL_COLORMAPS[cmap.name.lower()] = cmap
302402
CUSTOM_COLORMAPS[cmap.name.lower()] = cmap
303-
save_colormaps(CUSTOM_COLORMAPS_PATH, CUSTOM_COLORMAPS)
403+
save_colormaps(get_custom_colormaps_path(), CUSTOM_COLORMAPS)
304404

305405

306406
def delete_cmap(cmap: EditableColormap) -> None:
307407
"""Deletes the given colormap from both ALL_COLORMAPS and CUSTOM_COLORMAPS global"""
308408
global ALL_COLORMAPS, CUSTOM_COLORMAPS
309409
if CUSTOM_COLORMAPS.pop(cmap.name.lower(), None) is not None:
310410
del ALL_COLORMAPS[cmap.name.lower()]
311-
save_colormaps(CUSTOM_COLORMAPS_PATH, CUSTOM_COLORMAPS)
411+
save_colormaps(get_custom_colormaps_path(), CUSTOM_COLORMAPS)
312412

313413

314414
class LogScaleColorMap(QwtColorMap):

‎plotpy/tests/conftest.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,24 @@ def disable_gc_for_tests():
7272
gc.enable()
7373

7474

75+
@pytest.fixture
76+
def isolated_config_dir(tmp_path):
77+
"""Use a temporary user configuration directory for PlotPy's user files (e.g.
78+
custom colormaps), restoring the application name and custom colormaps afterwards.
79+
"""
80+
# pylint: disable=import-outside-toplevel
81+
from plotpy.config import CONF
82+
from plotpy.mathutils.colormap import reload_custom_colormaps
83+
84+
app_name = CONF.name
85+
with pytest.MonkeyPatch.context() as mpatch:
86+
for varname in ("HOME", "USERPROFILE", "XDG_CONFIG_HOME"):
87+
mpatch.setenv(varname, str(tmp_path))
88+
reload_custom_colormaps()
89+
yield tmp_path
90+
CONF.set_application(app_name, None, load=False)
91+
92+
7593
def pytest_report_header(config):
7694
"""Add additional information to the pytest report header."""
7795
qtbindings_version = qtpy.PYSIDE_VERSION

‎plotpy/tests/features/test_colormap_manager.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ def test_cmap(cmap_name="Kinda Viridis"):
3333
delete_cmap(get_cmap(cmap_name))
3434

3535

36+
@pytest.mark.usefixtures("isolated_config_dir")
3637
def test_colormap_manager(test_cmap: EditableColormap) -> None:
3738
"""Test the colormap manager widget."""
3839
with qt_app_context(exec_loop=False):

0 commit comments

Comments
 (0)