Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/release_notes/release_2.12.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
60 changes: 57 additions & 3 deletions plotpy/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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:
Expand Down
144 changes: 122 additions & 22 deletions plotpy/mathutils/colormap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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(
Expand All @@ -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}
Expand All @@ -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(
Expand Down Expand Up @@ -300,15 +400,15 @@ 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:
"""Deletes the given colormap from both ALL_COLORMAPS and CUSTOM_COLORMAPS global"""
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):
Expand Down
18 changes: 18 additions & 0 deletions plotpy/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions plotpy/tests/features/test_colormap_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading
Loading