Skip to content

Commit 3f5a15e

Browse files
authored
Refresh constructor registries after ticker reload (#645)
1 parent b212288 commit 3f5a15e

File tree

2 files changed

+180
-92
lines changed

2 files changed

+180
-92
lines changed

ultraplot/constructor.py

Lines changed: 168 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import re
1717
from functools import partial
1818
from numbers import Number
19+
from typing import Callable, Iterator, TypeVar
1920

2021
import cycler
2122
import matplotlib.colors as mcolors
@@ -68,60 +69,177 @@
6869
DEFAULT_CYCLE_SAMPLES = 10
6970
DEFAULT_CYCLE_LUMINANCE = 90
7071

72+
_RegistryValue = TypeVar("_RegistryValue")
73+
74+
75+
class _RefreshingRegistry(dict[str, _RegistryValue]):
76+
"""
77+
Dictionary-like registry that rebuilds itself before reads.
78+
79+
This keeps constructor registries aligned with modules that may be reloaded
80+
in-place during tests or interactive use.
81+
"""
82+
83+
def __init__(self, factory: Callable[[], dict[str, _RegistryValue]]) -> None:
84+
self._factory = factory
85+
super().__init__(factory())
86+
87+
def _refresh(self) -> None:
88+
super().clear()
89+
super().update(self._factory())
90+
91+
def __contains__(self, key: object) -> bool:
92+
self._refresh()
93+
return super().__contains__(key)
94+
95+
def __getitem__(self, key: str) -> _RegistryValue:
96+
self._refresh()
97+
return super().__getitem__(key)
98+
99+
def __iter__(self) -> Iterator[str]:
100+
self._refresh()
101+
return super().__iter__()
102+
103+
def __len__(self) -> int:
104+
self._refresh()
105+
return super().__len__()
106+
107+
def get(
108+
self, key: str, default: _RegistryValue | None = None
109+
) -> _RegistryValue | None:
110+
self._refresh()
111+
return super().get(key, default)
112+
113+
def items(self): # type: ignore[override]
114+
self._refresh()
115+
return super().items()
116+
117+
def keys(self): # type: ignore[override]
118+
self._refresh()
119+
return super().keys()
120+
121+
def values(self): # type: ignore[override]
122+
self._refresh()
123+
return super().values()
124+
125+
def copy(self) -> dict[str, _RegistryValue]:
126+
self._refresh()
127+
return dict(super().items())
128+
129+
130+
def _build_norm_registry() -> dict[str, type[mcolors.Normalize]]:
131+
registry: dict[str, type[mcolors.Normalize]] = {
132+
"none": mcolors.NoNorm,
133+
"null": mcolors.NoNorm,
134+
"div": pcolors.DivergingNorm,
135+
"diverging": pcolors.DivergingNorm,
136+
"segmented": pcolors.SegmentedNorm,
137+
"segments": pcolors.SegmentedNorm,
138+
"log": mcolors.LogNorm,
139+
"linear": mcolors.Normalize,
140+
"power": mcolors.PowerNorm,
141+
"symlog": mcolors.SymLogNorm,
142+
}
143+
if hasattr(mcolors, "TwoSlopeNorm"):
144+
registry["twoslope"] = mcolors.TwoSlopeNorm
145+
return registry
146+
147+
148+
def _build_locator_registry() -> dict[str, object]:
149+
registry = {
150+
"none": mticker.NullLocator,
151+
"null": mticker.NullLocator,
152+
"auto": mticker.AutoLocator,
153+
"log": mticker.LogLocator,
154+
"maxn": mticker.MaxNLocator,
155+
"linear": mticker.LinearLocator,
156+
"multiple": mticker.MultipleLocator,
157+
"fixed": mticker.FixedLocator,
158+
"index": pticker.IndexLocator,
159+
"discrete": pticker.DiscreteLocator,
160+
"discreteminor": partial(pticker.DiscreteLocator, minor=True),
161+
"symlog": mticker.SymmetricalLogLocator,
162+
"logit": mticker.LogitLocator,
163+
"minor": mticker.AutoMinorLocator,
164+
"date": mdates.AutoDateLocator,
165+
"microsecond": mdates.MicrosecondLocator,
166+
"second": mdates.SecondLocator,
167+
"minute": mdates.MinuteLocator,
168+
"hour": mdates.HourLocator,
169+
"day": mdates.DayLocator,
170+
"weekday": mdates.WeekdayLocator,
171+
"month": mdates.MonthLocator,
172+
"year": mdates.YearLocator,
173+
"lon": partial(pticker.LongitudeLocator, dms=False),
174+
"lat": partial(pticker.LatitudeLocator, dms=False),
175+
"deglon": partial(pticker.LongitudeLocator, dms=False),
176+
"deglat": partial(pticker.LatitudeLocator, dms=False),
177+
}
178+
if hasattr(mpolar, "ThetaLocator"):
179+
registry["theta"] = mpolar.ThetaLocator
180+
if _version_cartopy >= "0.18":
181+
registry["dms"] = partial(pticker.DegreeLocator, dms=True)
182+
registry["dmslon"] = partial(pticker.LongitudeLocator, dms=True)
183+
registry["dmslat"] = partial(pticker.LatitudeLocator, dms=True)
184+
return registry
185+
186+
187+
def _build_formatter_registry() -> dict[str, object]:
188+
registry = { # note default LogFormatter uses ugly e+00 notation
189+
"none": mticker.NullFormatter,
190+
"null": mticker.NullFormatter,
191+
"auto": pticker.AutoFormatter,
192+
"date": mdates.AutoDateFormatter,
193+
"scalar": mticker.ScalarFormatter,
194+
"simple": pticker.SimpleFormatter,
195+
"fixed": mticker.FixedLocator,
196+
"index": pticker.IndexFormatter,
197+
"sci": pticker.SciFormatter,
198+
"sigfig": pticker.SigFigFormatter,
199+
"frac": pticker.FracFormatter,
200+
"func": mticker.FuncFormatter,
201+
"strmethod": mticker.StrMethodFormatter,
202+
"formatstr": mticker.FormatStrFormatter,
203+
"datestr": mdates.DateFormatter,
204+
"log": mticker.LogFormatterSciNotation,
205+
"logit": mticker.LogitFormatter,
206+
"eng": mticker.EngFormatter,
207+
"percent": mticker.PercentFormatter,
208+
"e": partial(pticker.FracFormatter, symbol=r"$e$", number=np.e),
209+
"pi": partial(pticker.FracFormatter, symbol=r"$\pi$", number=np.pi),
210+
"tau": partial(pticker.FracFormatter, symbol=r"$\tau$", number=2 * np.pi),
211+
"lat": partial(pticker.SimpleFormatter, negpos="SN"),
212+
"lon": partial(pticker.SimpleFormatter, negpos="WE", wraprange=(-180, 180)),
213+
"deg": partial(pticker.SimpleFormatter, suffix="\N{DEGREE SIGN}"),
214+
"deglat": partial(
215+
pticker.SimpleFormatter, suffix="\N{DEGREE SIGN}", negpos="SN"
216+
),
217+
"deglon": partial(
218+
pticker.SimpleFormatter,
219+
suffix="\N{DEGREE SIGN}",
220+
negpos="WE",
221+
wraprange=(-180, 180),
222+
),
223+
"math": mticker.LogFormatterMathtext,
224+
}
225+
if hasattr(mpolar, "ThetaFormatter"):
226+
registry["theta"] = mpolar.ThetaFormatter
227+
if hasattr(mdates, "ConciseDateFormatter"):
228+
registry["concise"] = mdates.ConciseDateFormatter
229+
if _version_cartopy >= "0.18":
230+
registry["dms"] = partial(pticker.DegreeFormatter, dms=True)
231+
registry["dmslon"] = partial(pticker.LongitudeFormatter, dms=True)
232+
registry["dmslat"] = partial(pticker.LatitudeFormatter, dms=True)
233+
return registry
234+
235+
71236
# Normalizer registry
72-
NORMS = {
73-
"none": mcolors.NoNorm,
74-
"null": mcolors.NoNorm,
75-
"div": pcolors.DivergingNorm,
76-
"diverging": pcolors.DivergingNorm,
77-
"segmented": pcolors.SegmentedNorm,
78-
"segments": pcolors.SegmentedNorm,
79-
"log": mcolors.LogNorm,
80-
"linear": mcolors.Normalize,
81-
"power": mcolors.PowerNorm,
82-
"symlog": mcolors.SymLogNorm,
83-
}
84-
if hasattr(mcolors, "TwoSlopeNorm"):
85-
NORMS["twoslope"] = mcolors.TwoSlopeNorm
237+
NORMS = _RefreshingRegistry(_build_norm_registry)
86238

87239
# Locator registry
88240
# NOTE: Will raise error when you try to use degree-minute-second
89241
# locators with cartopy < 0.18.
90-
LOCATORS = {
91-
"none": mticker.NullLocator,
92-
"null": mticker.NullLocator,
93-
"auto": mticker.AutoLocator,
94-
"log": mticker.LogLocator,
95-
"maxn": mticker.MaxNLocator,
96-
"linear": mticker.LinearLocator,
97-
"multiple": mticker.MultipleLocator,
98-
"fixed": mticker.FixedLocator,
99-
"index": pticker.IndexLocator,
100-
"discrete": pticker.DiscreteLocator,
101-
"discreteminor": partial(pticker.DiscreteLocator, minor=True),
102-
"symlog": mticker.SymmetricalLogLocator,
103-
"logit": mticker.LogitLocator,
104-
"minor": mticker.AutoMinorLocator,
105-
"date": mdates.AutoDateLocator,
106-
"microsecond": mdates.MicrosecondLocator,
107-
"second": mdates.SecondLocator,
108-
"minute": mdates.MinuteLocator,
109-
"hour": mdates.HourLocator,
110-
"day": mdates.DayLocator,
111-
"weekday": mdates.WeekdayLocator,
112-
"month": mdates.MonthLocator,
113-
"year": mdates.YearLocator,
114-
"lon": partial(pticker.LongitudeLocator, dms=False),
115-
"lat": partial(pticker.LatitudeLocator, dms=False),
116-
"deglon": partial(pticker.LongitudeLocator, dms=False),
117-
"deglat": partial(pticker.LatitudeLocator, dms=False),
118-
}
119-
if hasattr(mpolar, "ThetaLocator"):
120-
LOCATORS["theta"] = mpolar.ThetaLocator
121-
if _version_cartopy >= "0.18":
122-
LOCATORS["dms"] = partial(pticker.DegreeLocator, dms=True)
123-
LOCATORS["dmslon"] = partial(pticker.LongitudeLocator, dms=True)
124-
LOCATORS["dmslat"] = partial(pticker.LatitudeLocator, dms=True)
242+
LOCATORS = _RefreshingRegistry(_build_locator_registry)
125243

126244
# Formatter registry
127245
# NOTE: Critical to use SimpleFormatter for cardinal formatters rather than
@@ -130,49 +248,7 @@
130248
# is their distinguishing feature relative to ultraplot formatter.
131249
# NOTE: Will raise error when you try to use degree-minute-second
132250
# formatters with cartopy < 0.18.
133-
FORMATTERS = { # note default LogFormatter uses ugly e+00 notation
134-
"none": mticker.NullFormatter,
135-
"null": mticker.NullFormatter,
136-
"auto": pticker.AutoFormatter,
137-
"date": mdates.AutoDateFormatter,
138-
"scalar": mticker.ScalarFormatter,
139-
"simple": pticker.SimpleFormatter,
140-
"fixed": mticker.FixedLocator,
141-
"index": pticker.IndexFormatter,
142-
"sci": pticker.SciFormatter,
143-
"sigfig": pticker.SigFigFormatter,
144-
"frac": pticker.FracFormatter,
145-
"func": mticker.FuncFormatter,
146-
"strmethod": mticker.StrMethodFormatter,
147-
"formatstr": mticker.FormatStrFormatter,
148-
"datestr": mdates.DateFormatter,
149-
"log": mticker.LogFormatterSciNotation, # NOTE: this is subclass of Mathtext class
150-
"logit": mticker.LogitFormatter,
151-
"eng": mticker.EngFormatter,
152-
"percent": mticker.PercentFormatter,
153-
"e": partial(pticker.FracFormatter, symbol=r"$e$", number=np.e),
154-
"pi": partial(pticker.FracFormatter, symbol=r"$\pi$", number=np.pi),
155-
"tau": partial(pticker.FracFormatter, symbol=r"$\tau$", number=2 * np.pi),
156-
"lat": partial(pticker.SimpleFormatter, negpos="SN"),
157-
"lon": partial(pticker.SimpleFormatter, negpos="WE", wraprange=(-180, 180)),
158-
"deg": partial(pticker.SimpleFormatter, suffix="\N{DEGREE SIGN}"),
159-
"deglat": partial(pticker.SimpleFormatter, suffix="\N{DEGREE SIGN}", negpos="SN"),
160-
"deglon": partial(
161-
pticker.SimpleFormatter,
162-
suffix="\N{DEGREE SIGN}",
163-
negpos="WE",
164-
wraprange=(-180, 180),
165-
), # noqa: E501
166-
"math": mticker.LogFormatterMathtext, # deprecated (use SciNotation subclass)
167-
}
168-
if hasattr(mpolar, "ThetaFormatter"):
169-
FORMATTERS["theta"] = mpolar.ThetaFormatter
170-
if hasattr(mdates, "ConciseDateFormatter"):
171-
FORMATTERS["concise"] = mdates.ConciseDateFormatter
172-
if _version_cartopy >= "0.18":
173-
FORMATTERS["dms"] = partial(pticker.DegreeFormatter, dms=True)
174-
FORMATTERS["dmslon"] = partial(pticker.LongitudeFormatter, dms=True)
175-
FORMATTERS["dmslat"] = partial(pticker.LatitudeFormatter, dms=True)
251+
FORMATTERS = _RefreshingRegistry(_build_formatter_registry)
176252

177253
# Scale registry and presets
178254
SCALES = mscale._scale_mapping

ultraplot/tests/test_constructor_helpers_extra.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
#!/usr/bin/env python3
22
"""Additional branch coverage for constructor helpers."""
33

4+
import importlib
5+
46
import cycler
57
import matplotlib.colors as mcolors
68
import matplotlib.dates as mdates
@@ -167,6 +169,16 @@ def test_norm_locator_formatter_and_scale_branches():
167169
constructor.Scale(object())
168170

169171

172+
def test_formatter_registry_refreshes_after_ticker_reload():
173+
import ultraplot.ticker
174+
175+
importlib.reload(ultraplot.ticker)
176+
177+
assert constructor.FORMATTERS["sigfig"] is pticker.SigFigFormatter
178+
formatter = constructor.Formatter(("sigfig", 3))
179+
assert isinstance(formatter, pticker.SigFigFormatter)
180+
181+
170182
def test_proj_constructor_branches():
171183
ccrs = pytest.importorskip("cartopy.crs")
172184

0 commit comments

Comments
 (0)