From 623cea420110004aa743b0c58d7caf7d303af751 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Wed, 8 Oct 2025 18:03:34 +0200 Subject: [PATCH 01/10] skip cycle on initial read --- ultraplot/config.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/ultraplot/config.py b/ultraplot/config.py index 1b98da4a..70573f30 100644 --- a/ultraplot/config.py +++ b/ultraplot/config.py @@ -778,12 +778,14 @@ def __getitem__(self, key): pass return rc_matplotlib[key] # might issue matplotlib removed/renamed error - def __setitem__(self, key, value): + def __setitem__(self, key, value, *, skip_cycle=False): """ Modify an `rc_matplotlib` or `rc_ultraplot` setting using dictionary notation (e.g., ``uplt.rc[name] = value``). """ - kw_ultraplot, kw_matplotlib = self._get_item_dicts(key, value) + kw_ultraplot, kw_matplotlib = self._get_item_dicts( + key, value, skip_cycle=skip_cycle + ) rc_ultraplot.update(kw_ultraplot) rc_matplotlib.update(kw_matplotlib) @@ -874,7 +876,7 @@ def _init(self, *, local, user, default, skip_cycle=False): if user: user_path = self.user_file() if os.path.isfile(user_path): - self.load(user_path) + self.load(user_path, skip_cycle=skip_cycle) # Update from local paths if local: @@ -882,7 +884,7 @@ def _init(self, *, local, user, default, skip_cycle=False): for path in local_paths: if path == user_path: # local files always have precedence continue - self.load(path) + self.load(path, skip_cycle=skip_cycle) @staticmethod def _validate_key(key, value=None): @@ -1663,7 +1665,7 @@ def _load_file(self, path): return rcdict - def load(self, path): + def load(self, path, *, skip_cycle=False): """ Load settings from the specified file. @@ -1678,7 +1680,7 @@ def load(self, path): """ rcdict = self._load_file(path) for key, value in rcdict.items(): - self.__setitem__(key, value) + self.__setitem__(key, value, skip_cycle=skip_cycle) @staticmethod def _save_rst(path): From 8ef514967636ee363191d51ebba0aaf01890cd13 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Wed, 8 Oct 2025 18:18:05 +0200 Subject: [PATCH 02/10] add unittest --- ultraplot/tests/test_config.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/ultraplot/tests/test_config.py b/ultraplot/tests/test_config.py index 6950ec16..1e5981e7 100644 --- a/ultraplot/tests/test_config.py +++ b/ultraplot/tests/test_config.py @@ -1,4 +1,5 @@ import ultraplot as uplt, pytest +import importlib def test_wrong_keyword_reset(): @@ -17,3 +18,23 @@ def test_wrong_keyword_reset(): fig, ax = uplt.subplots(proj="cyl") ax.format(coastcolor="black") fig.canvas.draw() + + +def test_cycle_in_rc_file(tmp_path, monkeypatch): + """ + Test that setting the cycle in an rc file does not cause a circular import. + """ + rc_content = "cycle: colorblind" + rc_file = tmp_path / ".ultraplotrc" + rc_file.write_text(rc_content) + + monkeypatch.setattr(uplt.config.Configurator, "user_file", lambda: str(rc_file)) + + try: + # We need to reload ultraplot and its config to trigger the initialization + # logic that reads the rc file. + importlib.reload(uplt) + importlib.reload(uplt.config) + except ImportError as e: + pytest.fail(f"Setting 'cycle' in rc file raised an import error: {e}") + assert uplt.rc["cycle"] == "colorblind" From b1f1d4c03cbaa5436df5d875d78217b221124b19 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Wed, 8 Oct 2025 18:18:12 +0200 Subject: [PATCH 03/10] add deferred settings --- ultraplot/__init__.py | 6 +++++- ultraplot/config.py | 18 +++++++++++++----- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/ultraplot/__init__.py b/ultraplot/__init__.py index 87da7d8b..55d44540 100644 --- a/ultraplot/__init__.py +++ b/ultraplot/__init__.py @@ -78,9 +78,13 @@ # Validate colormap names and propagate 'cycle' to 'axes.prop_cycle' # NOTE: cmap.sequential also updates siblings 'cmap' and 'image.cmap' -from .config import rc +from .config import rc, _deferred_rc_settings from .internals import rcsetup, warnings +for k, v in _deferred_rc_settings.items(): + rc[k] = v +_deferred_rc_settings.clear() + rcsetup.VALIDATE_REGISTERED_CMAPS = True for _key in ( diff --git a/ultraplot/config.py b/ultraplot/config.py index 70573f30..e356ebef 100644 --- a/ultraplot/config.py +++ b/ultraplot/config.py @@ -18,6 +18,8 @@ from collections.abc import MutableMapping from numbers import Real +_deferred_rc_settings = {} + import cycler import matplotlib as mpl import matplotlib.colors as mcolors @@ -993,12 +995,14 @@ def _get_item_dicts(self, key, value, skip_cycle=False): # Cycler # NOTE: Have to skip this step during initial ultraplot import - elif contains("cycle") and not skip_cycle: - from .colors import _get_cmap_subtype + elif contains("cycle"): + kw_ultraplot["cycle"] = value + if not skip_cycle: + from .colors import _get_cmap_subtype - cmap = _get_cmap_subtype(value, "discrete") - kw_matplotlib["axes.prop_cycle"] = cycler.cycler("color", cmap.colors) - kw_matplotlib["patch.facecolor"] = "C0" + cmap = _get_cmap_subtype(value, "discrete") + kw_matplotlib["axes.prop_cycle"] = cycler.cycler("color", cmap.colors) + kw_matplotlib["patch.facecolor"] = "C0" # Turning bounding box on should turn border off and vice versa elif contains("abc.bbox", "title.bbox", "abc.border", "title.border"): @@ -1680,6 +1684,10 @@ def load(self, path, *, skip_cycle=False): """ rcdict = self._load_file(path) for key, value in rcdict.items(): + if skip_cycle and "cycle" in key: + global _deferred_rc_settings + _deferred_rc_settings[key] = value + continue self.__setitem__(key, value, skip_cycle=skip_cycle) @staticmethod From 0a4b50b9bf12bb13e3d4629c07a2810c9c43bbb8 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Wed, 8 Oct 2025 19:02:06 +0200 Subject: [PATCH 04/10] rm deferred dict --- ultraplot/__init__.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/ultraplot/__init__.py b/ultraplot/__init__.py index 55d44540..87da7d8b 100644 --- a/ultraplot/__init__.py +++ b/ultraplot/__init__.py @@ -78,13 +78,9 @@ # Validate colormap names and propagate 'cycle' to 'axes.prop_cycle' # NOTE: cmap.sequential also updates siblings 'cmap' and 'image.cmap' -from .config import rc, _deferred_rc_settings +from .config import rc from .internals import rcsetup, warnings -for k, v in _deferred_rc_settings.items(): - rc[k] = v -_deferred_rc_settings.clear() - rcsetup.VALIDATE_REGISTERED_CMAPS = True for _key in ( From 39870df5f1996bc69610149f1fc3daa2f5fa5a37 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Wed, 8 Oct 2025 19:02:53 +0200 Subject: [PATCH 05/10] add handlers to configurator --- ultraplot/colors.py | 19 +++++++++++++++ ultraplot/config.py | 59 ++++++++++++++++++--------------------------- 2 files changed, 43 insertions(+), 35 deletions(-) diff --git a/ultraplot/colors.py b/ultraplot/colors.py index 4a48edde..3bddc6ee 100644 --- a/ultraplot/colors.py +++ b/ultraplot/colors.py @@ -30,6 +30,25 @@ import numpy.ma as ma from .config import rc +import cycler + + +def _cycle_handler(value): + """ + Handler for the 'cycle' rc setting. + Registration is deferred as it causes a a circular import loop + """ + from .colors import _get_cmap_subtype + + cmap = _get_cmap_subtype(value, "discrete") + return { + "axes.prop_cycle": cycler.cycler("color", cmap.colors), + "patch.facecolor": "C0", + } + + +rc.register_handler("cycle", _cycle_handler) + from .internals import ic # noqa: F401 from .internals import ( _kwargs_to_args, diff --git a/ultraplot/config.py b/ultraplot/config.py index e356ebef..5c230f20 100644 --- a/ultraplot/config.py +++ b/ultraplot/config.py @@ -18,7 +18,6 @@ from collections.abc import MutableMapping from numbers import Real -_deferred_rc_settings = {} import cycler import matplotlib as mpl @@ -766,8 +765,13 @@ def __init__(self, local=True, user=True, default=True, **kwargs): %(rc.params)s """ self._context = [] + self._setting_handlers = {} self._init(local=local, user=user, default=default, **kwargs) + def register_handler(self, name, func): + """Register a handler for a specific setting.""" + self._setting_handlers[name] = func + def __getitem__(self, key): """ Return an `rc_matplotlib` or `rc_ultraplot` setting using dictionary notation @@ -780,14 +784,12 @@ def __getitem__(self, key): pass return rc_matplotlib[key] # might issue matplotlib removed/renamed error - def __setitem__(self, key, value, *, skip_cycle=False): + def __setitem__(self, key, value): """ Modify an `rc_matplotlib` or `rc_ultraplot` setting using dictionary notation (e.g., ``uplt.rc[name] = value``). """ - kw_ultraplot, kw_matplotlib = self._get_item_dicts( - key, value, skip_cycle=skip_cycle - ) + kw_ultraplot, kw_matplotlib = self._get_item_dicts(key, value) rc_ultraplot.update(kw_ultraplot) rc_matplotlib.update(kw_matplotlib) @@ -853,7 +855,7 @@ def __exit__(self, *args): # noqa: U100 rc_matplotlib.update(kw_matplotlib) del self._context[-1] - def _init(self, *, local, user, default, skip_cycle=False): + def _init(self, *, local, user, default): """ Initialize the configurator. """ @@ -867,9 +869,7 @@ def _init(self, *, local, user, default, skip_cycle=False): rc_matplotlib.update(rcsetup._rc_matplotlib_default) rc_ultraplot.update(rcsetup._rc_ultraplot_default) for key, value in rc_ultraplot.items(): - kw_ultraplot, kw_matplotlib = self._get_item_dicts( - key, value, skip_cycle=skip_cycle - ) + kw_ultraplot, kw_matplotlib = self._get_item_dicts(key, value) rc_matplotlib.update(kw_matplotlib) rc_ultraplot.update(kw_ultraplot) @@ -878,7 +878,7 @@ def _init(self, *, local, user, default, skip_cycle=False): if user: user_path = self.user_file() if os.path.isfile(user_path): - self.load(user_path, skip_cycle=skip_cycle) + self.load(user_path) # Update from local paths if local: @@ -886,7 +886,7 @@ def _init(self, *, local, user, default, skip_cycle=False): for path in local_paths: if path == user_path: # local files always have precedence continue - self.load(path, skip_cycle=skip_cycle) + self.load(path) @staticmethod def _validate_key(key, value=None): @@ -954,7 +954,7 @@ def _get_item_context(self, key, mode=None): if mode == 0: # otherwise return None raise KeyError(f"Invalid rc setting {key!r}.") - def _get_item_dicts(self, key, value, skip_cycle=False): + def _get_item_dicts(self, key, value): """ Return dictionaries for updating the `rc_ultraplot` and `rc_matplotlib` properties associated with this key. Used when setting items, entering @@ -974,13 +974,13 @@ def _get_item_dicts(self, key, value, skip_cycle=False): with warnings.catch_warnings(): warnings.simplefilter("ignore", mpl.MatplotlibDeprecationWarning) warnings.simplefilter("ignore", warnings.UltraPlotWarning) - for key in keys: - if key in rc_matplotlib: - kw_matplotlib[key] = value - elif key in rc_ultraplot: - kw_ultraplot[key] = value + for key_i in keys: + if key_i in rc_matplotlib: + kw_matplotlib[key_i] = value + elif key_i in rc_ultraplot: + kw_ultraplot[key_i] = value else: - raise KeyError(f"Invalid rc setting {key!r}.") + raise KeyError(f"Invalid rc setting {key_i!r}.") # Special key: configure inline backend if contains("inlineformat"): @@ -993,16 +993,9 @@ def _get_item_dicts(self, key, value, skip_cycle=False): kw_matplotlib.update(ikw_matplotlib) kw_ultraplot.update(_infer_ultraplot_dict(ikw_matplotlib)) - # Cycler - # NOTE: Have to skip this step during initial ultraplot import - elif contains("cycle"): - kw_ultraplot["cycle"] = value - if not skip_cycle: - from .colors import _get_cmap_subtype - - cmap = _get_cmap_subtype(value, "discrete") - kw_matplotlib["axes.prop_cycle"] = cycler.cycler("color", cmap.colors) - kw_matplotlib["patch.facecolor"] = "C0" + # Generic handler for special properties + if key in self._setting_handlers: + kw_matplotlib.update(self._setting_handlers[key](value)) # Turning bounding box on should turn border off and vice versa elif contains("abc.bbox", "title.bbox", "abc.border", "title.border"): @@ -1669,7 +1662,7 @@ def _load_file(self, path): return rcdict - def load(self, path, *, skip_cycle=False): + def load(self, path): """ Load settings from the specified file. @@ -1684,11 +1677,7 @@ def load(self, path, *, skip_cycle=False): """ rcdict = self._load_file(path) for key, value in rcdict.items(): - if skip_cycle and "cycle" in key: - global _deferred_rc_settings - _deferred_rc_settings[key] = value - continue - self.__setitem__(key, value, skip_cycle=skip_cycle) + self.__setitem__(key, value) @staticmethod def _save_rst(path): @@ -1830,7 +1819,7 @@ def changed(self): #: Instance of `Configurator`. This controls both `rc_matplotlib` and `rc_ultraplot` #: settings. See the :ref:`configuration guide ` for details. -rc = Configurator(skip_cycle=True) +rc = Configurator() # Deprecated RcConfigurator = warnings._rename_objs( From ee8e0b712ceb73d6407a9285a6314daf87e49432 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Wed, 8 Oct 2025 19:03:06 +0200 Subject: [PATCH 06/10] update test --- ultraplot/tests/test_config.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/ultraplot/tests/test_config.py b/ultraplot/tests/test_config.py index 1e5981e7..7867bdd1 100644 --- a/ultraplot/tests/test_config.py +++ b/ultraplot/tests/test_config.py @@ -20,21 +20,15 @@ def test_wrong_keyword_reset(): fig.canvas.draw() -def test_cycle_in_rc_file(tmp_path, monkeypatch): +def test_cycle_in_rc_file(tmp_path): """ - Test that setting the cycle in an rc file does not cause a circular import. + Test that loading an rc file correctly overwrites the cycle setting. """ rc_content = "cycle: colorblind" - rc_file = tmp_path / ".ultraplotrc" + rc_file = tmp_path / "test.rc" rc_file.write_text(rc_content) - monkeypatch.setattr(uplt.config.Configurator, "user_file", lambda: str(rc_file)) + # Load the file directly. This should overwrite any existing settings. + uplt.rc.load(str(rc_file)) - try: - # We need to reload ultraplot and its config to trigger the initialization - # logic that reads the rc file. - importlib.reload(uplt) - importlib.reload(uplt.config) - except ImportError as e: - pytest.fail(f"Setting 'cycle' in rc file raised an import error: {e}") assert uplt.rc["cycle"] == "colorblind" From 79b45dec284c034d9d84ae28128ec86fae5f6a37 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Wed, 8 Oct 2025 19:03:15 +0200 Subject: [PATCH 07/10] add handler tests --- ultraplot/tests/test_handlers.py | 35 ++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 ultraplot/tests/test_handlers.py diff --git a/ultraplot/tests/test_handlers.py b/ultraplot/tests/test_handlers.py new file mode 100644 index 00000000..6e519f86 --- /dev/null +++ b/ultraplot/tests/test_handlers.py @@ -0,0 +1,35 @@ +import pytest +import ultraplot as uplt + + +def test_handler_override(): + """ + Test that a handler can be overridden and is executed when the setting is changed. + """ + # Get the original handler to restore it later + original_handler = uplt.rc._setting_handlers.get("cycle") + + # Define a dummy handler + _handler_was_called = False + + def dummy_handler(value): + nonlocal _handler_was_called + _handler_was_called = True + return {} + + # Register the dummy handler, overriding the original one + uplt.rc.register_handler("cycle", dummy_handler) + + try: + # Change the setting to trigger the handler + uplt.rc["cycle"] = "colorblind" + + # Assert that our dummy handler was called + assert _handler_was_called, "Dummy handler was not called." + + finally: + # Restore the original handler + if original_handler: + uplt.rc.register_handler("cycle", original_handler) + else: + del uplt.rc._setting_handlers["cycle"] From 4241b117435e5992cc9e02b1e8e83b8e1e63f9e2 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Wed, 8 Oct 2025 19:11:51 +0200 Subject: [PATCH 08/10] defer handling and use internal cycler --- ultraplot/colors.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/ultraplot/colors.py b/ultraplot/colors.py index 3bddc6ee..e8601c8d 100644 --- a/ultraplot/colors.py +++ b/ultraplot/colors.py @@ -30,19 +30,14 @@ import numpy.ma as ma from .config import rc -import cycler def _cycle_handler(value): - """ - Handler for the 'cycle' rc setting. - Registration is deferred as it causes a a circular import loop - """ - from .colors import _get_cmap_subtype + """Handler for the 'cycle' rc setting.""" + from .constructor import Cycle - cmap = _get_cmap_subtype(value, "discrete") return { - "axes.prop_cycle": cycler.cycler("color", cmap.colors), + "axes.prop_cycle": Cycle(value), "patch.facecolor": "C0", } From 0eb9d977e3bbff587a5f06f53322c3a65dc0938b Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Wed, 8 Oct 2025 19:27:32 +0200 Subject: [PATCH 09/10] add docstring to register_handler --- ultraplot/config.py | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/ultraplot/config.py b/ultraplot/config.py index 5c230f20..1bdcd4d7 100644 --- a/ultraplot/config.py +++ b/ultraplot/config.py @@ -27,6 +27,7 @@ import matplotlib.style.core as mstyle import numpy as np from matplotlib import RcParams +from typing import Callable, Any, Dict from .internals import ic # noqa: F401 from .internals import ( @@ -768,8 +769,36 @@ def __init__(self, local=True, user=True, default=True, **kwargs): self._setting_handlers = {} self._init(local=local, user=user, default=default, **kwargs) - def register_handler(self, name, func): - """Register a handler for a specific setting.""" + def register_handler( + self, name: str, func: Callable[[Any], Dict[str, Any]] + ) -> None: + """ + Register a callback function to be executed when a setting is modified. + + This is an extension point for "special" settings that require complex + logic or have side-effects, such as updating other matplotlib settings. + It is used internally to decouple the configuration system from other + subsystems and avoid circular imports. + + Parameters + ---------- + name : str + The name of the setting (e.g., ``'cycle'``). + func : callable + The handler function to be executed. The function must accept a + single positional argument, which is the new `value` of the + setting, and must return a dictionary. The keys of the dictionary + should be valid ``matplotlib`` rc setting names, and the values + will be applied to the ``rc_matplotlib`` object. + + Example + ------- + >>> def _cycle_handler(value): + ... # ... logic to create a cycler object from the value ... + ... return {'axes.prop_cycle': new_cycler} + >>> rc.register_handler('cycle', _cycle_handler) + """ + self._setting_handlers[name] = func def __getitem__(self, key): From ca97577a824fa61232707a672594018fe913072e Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Wed, 8 Oct 2025 19:29:55 +0200 Subject: [PATCH 10/10] make docstring compat with api --- ultraplot/config.py | 54 ++++++++++++++++++++++++--------------------- 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/ultraplot/config.py b/ultraplot/config.py index 1bdcd4d7..388285bc 100644 --- a/ultraplot/config.py +++ b/ultraplot/config.py @@ -160,6 +160,34 @@ def get_ipython(): docstring._snippet_manager["rc.cmap_exts"] = _cmap_exts_docstring docstring._snippet_manager["rc.cycle_exts"] = _cycle_exts_docstring +_rc_register_handler_docstring = """ + Register a callback function to be executed when a setting is modified. + + This is an extension point for "special" settings that require complex + logic or have side-effects, such as updating other matplotlib settings. + It is used internally to decouple the configuration system from other + subsystems and avoid circular imports. + + Parameters + ---------- + name : str + The name of the setting (e.g., ``'cycle'``). + func : callable + The handler function to be executed. The function must accept a + single positional argument, which is the new `value` of the + setting, and must return a dictionary. The keys of the dictionary + should be valid ``matplotlib`` rc setting names, and the values + will be applied to the ``rc_matplotlib`` object. + + Example + ------- + >>> def _cycle_handler(value): + ... # ... logic to create a cycler object from the value ... + ... return {'axes.prop_cycle': new_cycler} + >>> rc.register_handler('cycle', _cycle_handler) + """ +docstring._snippet_manager["rc.register_handler"] = _rc_register_handler_docstring + def _init_user_file(): """ @@ -773,32 +801,8 @@ def register_handler( self, name: str, func: Callable[[Any], Dict[str, Any]] ) -> None: """ - Register a callback function to be executed when a setting is modified. - - This is an extension point for "special" settings that require complex - logic or have side-effects, such as updating other matplotlib settings. - It is used internally to decouple the configuration system from other - subsystems and avoid circular imports. - - Parameters - ---------- - name : str - The name of the setting (e.g., ``'cycle'``). - func : callable - The handler function to be executed. The function must accept a - single positional argument, which is the new `value` of the - setting, and must return a dictionary. The keys of the dictionary - should be valid ``matplotlib`` rc setting names, and the values - will be applied to the ``rc_matplotlib`` object. - - Example - ------- - >>> def _cycle_handler(value): - ... # ... logic to create a cycler object from the value ... - ... return {'axes.prop_cycle': new_cycler} - >>> rc.register_handler('cycle', _cycle_handler) + %(rc.register_handler)s """ - self._setting_handlers[name] = func def __getitem__(self, key):