Skip to content

Commit d514aa1

Browse files
authored
Fix circular import rc setting (#365)
* skip cycle on initial read * add unittest * add deferred settings * rm deferred dict * add handlers to configurator * update test * add handler tests * defer handling and use internal cycler * add docstring to register_handler * make docstring compat with api
1 parent 9cc88a7 commit d514aa1

File tree

4 files changed

+116
-20
lines changed

4 files changed

+116
-20
lines changed

ultraplot/colors.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,20 @@
3030
import numpy.ma as ma
3131

3232
from .config import rc
33+
34+
35+
def _cycle_handler(value):
36+
"""Handler for the 'cycle' rc setting."""
37+
from .constructor import Cycle
38+
39+
return {
40+
"axes.prop_cycle": Cycle(value),
41+
"patch.facecolor": "C0",
42+
}
43+
44+
45+
rc.register_handler("cycle", _cycle_handler)
46+
3347
from .internals import ic # noqa: F401
3448
from .internals import (
3549
_kwargs_to_args,

ultraplot/config.py

Lines changed: 52 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from collections.abc import MutableMapping
1919
from numbers import Real
2020

21+
2122
import cycler
2223
import matplotlib as mpl
2324
import matplotlib.colors as mcolors
@@ -26,6 +27,7 @@
2627
import matplotlib.style.core as mstyle
2728
import numpy as np
2829
from matplotlib import RcParams
30+
from typing import Callable, Any, Dict
2931

3032
from .internals import ic # noqa: F401
3133
from .internals import (
@@ -158,6 +160,34 @@ def get_ipython():
158160
docstring._snippet_manager["rc.cmap_exts"] = _cmap_exts_docstring
159161
docstring._snippet_manager["rc.cycle_exts"] = _cycle_exts_docstring
160162

163+
_rc_register_handler_docstring = """
164+
Register a callback function to be executed when a setting is modified.
165+
166+
This is an extension point for "special" settings that require complex
167+
logic or have side-effects, such as updating other matplotlib settings.
168+
It is used internally to decouple the configuration system from other
169+
subsystems and avoid circular imports.
170+
171+
Parameters
172+
----------
173+
name : str
174+
The name of the setting (e.g., ``'cycle'``).
175+
func : callable
176+
The handler function to be executed. The function must accept a
177+
single positional argument, which is the new `value` of the
178+
setting, and must return a dictionary. The keys of the dictionary
179+
should be valid ``matplotlib`` rc setting names, and the values
180+
will be applied to the ``rc_matplotlib`` object.
181+
182+
Example
183+
-------
184+
>>> def _cycle_handler(value):
185+
... # ... logic to create a cycler object from the value ...
186+
... return {'axes.prop_cycle': new_cycler}
187+
>>> rc.register_handler('cycle', _cycle_handler)
188+
"""
189+
docstring._snippet_manager["rc.register_handler"] = _rc_register_handler_docstring
190+
161191

162192
def _init_user_file():
163193
"""
@@ -764,8 +794,17 @@ def __init__(self, local=True, user=True, default=True, **kwargs):
764794
%(rc.params)s
765795
"""
766796
self._context = []
797+
self._setting_handlers = {}
767798
self._init(local=local, user=user, default=default, **kwargs)
768799

800+
def register_handler(
801+
self, name: str, func: Callable[[Any], Dict[str, Any]]
802+
) -> None:
803+
"""
804+
%(rc.register_handler)s
805+
"""
806+
self._setting_handlers[name] = func
807+
769808
def __getitem__(self, key):
770809
"""
771810
Return an `rc_matplotlib` or `rc_ultraplot` setting using dictionary notation
@@ -849,7 +888,7 @@ def __exit__(self, *args): # noqa: U100
849888
rc_matplotlib.update(kw_matplotlib)
850889
del self._context[-1]
851890

852-
def _init(self, *, local, user, default, skip_cycle=False):
891+
def _init(self, *, local, user, default):
853892
"""
854893
Initialize the configurator.
855894
"""
@@ -863,9 +902,7 @@ def _init(self, *, local, user, default, skip_cycle=False):
863902
rc_matplotlib.update(rcsetup._rc_matplotlib_default)
864903
rc_ultraplot.update(rcsetup._rc_ultraplot_default)
865904
for key, value in rc_ultraplot.items():
866-
kw_ultraplot, kw_matplotlib = self._get_item_dicts(
867-
key, value, skip_cycle=skip_cycle
868-
)
905+
kw_ultraplot, kw_matplotlib = self._get_item_dicts(key, value)
869906
rc_matplotlib.update(kw_matplotlib)
870907
rc_ultraplot.update(kw_ultraplot)
871908

@@ -950,7 +987,7 @@ def _get_item_context(self, key, mode=None):
950987
if mode == 0: # otherwise return None
951988
raise KeyError(f"Invalid rc setting {key!r}.")
952989

953-
def _get_item_dicts(self, key, value, skip_cycle=False):
990+
def _get_item_dicts(self, key, value):
954991
"""
955992
Return dictionaries for updating the `rc_ultraplot` and `rc_matplotlib`
956993
properties associated with this key. Used when setting items, entering
@@ -970,13 +1007,13 @@ def _get_item_dicts(self, key, value, skip_cycle=False):
9701007
with warnings.catch_warnings():
9711008
warnings.simplefilter("ignore", mpl.MatplotlibDeprecationWarning)
9721009
warnings.simplefilter("ignore", warnings.UltraPlotWarning)
973-
for key in keys:
974-
if key in rc_matplotlib:
975-
kw_matplotlib[key] = value
976-
elif key in rc_ultraplot:
977-
kw_ultraplot[key] = value
1010+
for key_i in keys:
1011+
if key_i in rc_matplotlib:
1012+
kw_matplotlib[key_i] = value
1013+
elif key_i in rc_ultraplot:
1014+
kw_ultraplot[key_i] = value
9781015
else:
979-
raise KeyError(f"Invalid rc setting {key!r}.")
1016+
raise KeyError(f"Invalid rc setting {key_i!r}.")
9801017

9811018
# Special key: configure inline backend
9821019
if contains("inlineformat"):
@@ -989,14 +1026,9 @@ def _get_item_dicts(self, key, value, skip_cycle=False):
9891026
kw_matplotlib.update(ikw_matplotlib)
9901027
kw_ultraplot.update(_infer_ultraplot_dict(ikw_matplotlib))
9911028

992-
# Cycler
993-
# NOTE: Have to skip this step during initial ultraplot import
994-
elif contains("cycle") and not skip_cycle:
995-
from .colors import _get_cmap_subtype
996-
997-
cmap = _get_cmap_subtype(value, "discrete")
998-
kw_matplotlib["axes.prop_cycle"] = cycler.cycler("color", cmap.colors)
999-
kw_matplotlib["patch.facecolor"] = "C0"
1029+
# Generic handler for special properties
1030+
if key in self._setting_handlers:
1031+
kw_matplotlib.update(self._setting_handlers[key](value))
10001032

10011033
# Turning bounding box on should turn border off and vice versa
10021034
elif contains("abc.bbox", "title.bbox", "abc.border", "title.border"):
@@ -1820,7 +1852,7 @@ def changed(self):
18201852

18211853
#: Instance of `Configurator`. This controls both `rc_matplotlib` and `rc_ultraplot`
18221854
#: settings. See the :ref:`configuration guide <ug_config>` for details.
1823-
rc = Configurator(skip_cycle=True)
1855+
rc = Configurator()
18241856

18251857
# Deprecated
18261858
RcConfigurator = warnings._rename_objs(

ultraplot/tests/test_config.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import ultraplot as uplt, pytest
2+
import importlib
23

34

45
def test_wrong_keyword_reset():
@@ -17,3 +18,17 @@ def test_wrong_keyword_reset():
1718
fig, ax = uplt.subplots(proj="cyl")
1819
ax.format(coastcolor="black")
1920
fig.canvas.draw()
21+
22+
23+
def test_cycle_in_rc_file(tmp_path):
24+
"""
25+
Test that loading an rc file correctly overwrites the cycle setting.
26+
"""
27+
rc_content = "cycle: colorblind"
28+
rc_file = tmp_path / "test.rc"
29+
rc_file.write_text(rc_content)
30+
31+
# Load the file directly. This should overwrite any existing settings.
32+
uplt.rc.load(str(rc_file))
33+
34+
assert uplt.rc["cycle"] == "colorblind"

ultraplot/tests/test_handlers.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import pytest
2+
import ultraplot as uplt
3+
4+
5+
def test_handler_override():
6+
"""
7+
Test that a handler can be overridden and is executed when the setting is changed.
8+
"""
9+
# Get the original handler to restore it later
10+
original_handler = uplt.rc._setting_handlers.get("cycle")
11+
12+
# Define a dummy handler
13+
_handler_was_called = False
14+
15+
def dummy_handler(value):
16+
nonlocal _handler_was_called
17+
_handler_was_called = True
18+
return {}
19+
20+
# Register the dummy handler, overriding the original one
21+
uplt.rc.register_handler("cycle", dummy_handler)
22+
23+
try:
24+
# Change the setting to trigger the handler
25+
uplt.rc["cycle"] = "colorblind"
26+
27+
# Assert that our dummy handler was called
28+
assert _handler_was_called, "Dummy handler was not called."
29+
30+
finally:
31+
# Restore the original handler
32+
if original_handler:
33+
uplt.rc.register_handler("cycle", original_handler)
34+
else:
35+
del uplt.rc._setting_handlers["cycle"]

0 commit comments

Comments
 (0)