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
14 changes: 14 additions & 0 deletions ultraplot/colors.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,20 @@
import numpy.ma as ma

from .config import rc


def _cycle_handler(value):
"""Handler for the 'cycle' rc setting."""
from .constructor import Cycle

return {
"axes.prop_cycle": Cycle(value),
"patch.facecolor": "C0",
}


rc.register_handler("cycle", _cycle_handler)

from .internals import ic # noqa: F401
from .internals import (
_kwargs_to_args,
Expand Down
72 changes: 52 additions & 20 deletions ultraplot/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from collections.abc import MutableMapping
from numbers import Real


import cycler
import matplotlib as mpl
import matplotlib.colors as mcolors
Expand All @@ -26,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 (
Expand Down Expand Up @@ -158,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():
"""
Expand Down Expand Up @@ -764,8 +794,17 @@ 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: str, func: Callable[[Any], Dict[str, Any]]
) -> None:
"""
%(rc.register_handler)s
"""
self._setting_handlers[name] = func

def __getitem__(self, key):
"""
Return an `rc_matplotlib` or `rc_ultraplot` setting using dictionary notation
Expand Down Expand Up @@ -849,7 +888,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.
"""
Expand All @@ -863,9 +902,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)

Expand Down Expand Up @@ -950,7 +987,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
Expand All @@ -970,13 +1007,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"):
Expand All @@ -989,14 +1026,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") and 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"):
Expand Down Expand Up @@ -1820,7 +1852,7 @@ def changed(self):

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

# Deprecated
RcConfigurator = warnings._rename_objs(
Expand Down
15 changes: 15 additions & 0 deletions ultraplot/tests/test_config.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import ultraplot as uplt, pytest
import importlib


def test_wrong_keyword_reset():
Expand All @@ -17,3 +18,17 @@ 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):
"""
Test that loading an rc file correctly overwrites the cycle setting.
"""
rc_content = "cycle: colorblind"
rc_file = tmp_path / "test.rc"
rc_file.write_text(rc_content)

# Load the file directly. This should overwrite any existing settings.
uplt.rc.load(str(rc_file))

assert uplt.rc["cycle"] == "colorblind"
35 changes: 35 additions & 0 deletions ultraplot/tests/test_handlers.py
Original file line number Diff line number Diff line change
@@ -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"]