diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py index 1a32af922342..77d2234312ae 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -1435,6 +1435,19 @@ def __clear(self): self.xaxis.set_clip_path(self.patch) self.yaxis.set_clip_path(self.patch) + # Ensure spines have the correct transform before any subsequent + # layout or draw. Spine.__init__ installs self.axes.transData as + # a placeholder; the real blended transform is set by + # Spine.set_position via _ensure_position_is_set(). Historically + # this fired as a side effect of tick materialization during + # clear; with lazy tick lists that cascade no longer runs, so + # nudge it here for spines that still carry the placeholder + # (projections like polar or secondary axes install custom + # transforms and are skipped). + for spine in self.spines.values(): + if spine._position is None and spine._transform is self.transData: + spine._ensure_position_is_set() + if self._sharex is not None: self.xaxis.set_visible(xaxis_visible) self.patch.set_visible(patch_visible) diff --git a/lib/matplotlib/axis.py b/lib/matplotlib/axis.py index 0ddfee2d537c..466dd6b5779b 100644 --- a/lib/matplotlib/axis.py +++ b/lib/matplotlib/axis.py @@ -2,6 +2,7 @@ Classes for the ticks and x- and y-axis. """ +import contextlib import datetime import functools import logging @@ -243,6 +244,18 @@ def set_clip_path(self, path, transform=None): self.gridline.set_clip_path(path, transform) self.stale = True + def _configure_for_axis(self, axis): + """ + Copy the Axis clip state onto this Tick and its gridline. + + Used by `_LazyTickList` to stamp clip state set via + `Axis.set_clip_path` onto a lazily-materialized Tick. + """ + for artist in (self, self.gridline): + artist.clipbox = axis.clipbox + artist._clippath = axis._clippath + artist._clipon = axis._clipon + def contains(self, mouseevent): """ Test whether the mouse event occurred in the Tick marks. @@ -536,6 +549,26 @@ def formatter(self, formatter): self._formatter = formatter +@contextlib.contextmanager +def _rc_context_raw(snapshot): + """ + Like ``mpl.rc_context(snapshot)`` but bypasses ``RcParams`` validators + on entry and exit; re-applying a snapshot to its own values must not + re-trigger one-shot validator warnings (e.g. ``toolbar='toolmanager'``). + ``snapshot=None`` is a no-op. + """ + if snapshot is None: + yield + return + rc = mpl.rcParams + orig = dict(rc) + rc._update_raw(snapshot) + try: + yield + finally: + rc._update_raw(orig) + + class _LazyTickList: """ A descriptor for lazy instantiation of tick lists. @@ -548,26 +581,39 @@ def __init__(self, major): self._major = major def __get__(self, instance, owner): + """Materialize the descriptor to a list with one configured tick.""" if instance is None: return self - else: - # instance._get_tick() can itself try to access the majorTicks - # attribute (e.g. in certain projection classes which override - # e.g. get_xaxis_text1_transform). In order to avoid infinite - # recursion, first set the majorTicks on the instance temporarily - # to an empty list. Then create the tick; note that _get_tick() - # may call reset_ticks(). Therefore, the final tick list is - # created and assigned afterwards. - if self._major: - instance.majorTicks = [] - tick = instance._get_tick(major=True) - instance.majorTicks = [tick] - return instance.majorTicks - else: - instance.minorTicks = [] - tick = instance._get_tick(major=False) - instance.minorTicks = [tick] - return instance.minorTicks + # instance._get_tick() can itself try to access the majorTicks + # attribute (e.g. in certain projection classes which override + # e.g. get_xaxis_text1_transform). To avoid infinite recursion, + # bind the attribute to an empty list before calling _get_tick(). + # _get_tick() may also call reset_ticks(), which pops the attribute + # from the instance dict; the final setattr below re-binds the + # (now non-empty) list so subsequent accesses skip the descriptor. + attr = 'majorTicks' if self._major else 'minorTicks' + tick_list = [] + setattr(instance, attr, tick_list) + # Build the Tick (and its sub-artists) under the rcParams captured + # at the last Axis.clear() so that a lazily-materialized Tick + # matches an eager (pre-lazy) Tick (see Axis._tick_rcParams). + with _rc_context_raw(instance._tick_rcParams): + tick = instance._get_tick(major=self._major) + # Re-apply any set_tick_params() overrides to the fresh Tick. + # Subclasses of Axis (e.g. the SkewXAxis in the skewt gallery + # example) sometimes override _get_tick() without forwarding + # _{major,minor}_tick_kw; calling _apply_params() here guarantees + # those overrides still take effect, matching the pre-lazy + # behaviour where the first tick was materialized eagerly and + # updated in place by set_tick_params(). + tick_kw = (instance._major_tick_kw if self._major + else instance._minor_tick_kw) + if tick_kw: + tick._apply_params(**tick_kw) + tick._configure_for_axis(instance) + tick_list.append(tick) + setattr(instance, attr, tick_list) + return tick_list class Axis(martist.Artist): @@ -672,6 +718,15 @@ def __init__(self, axes, *, pickradius=15, clear=True): # Initialize here for testing; later add API self._major_tick_kw = dict() self._minor_tick_kw = dict() + # Snapshot of rcParams at the time of the last Axis.clear() (or + # set_tick_params(reset=True)). _LazyTickList re-applies these + # when it lazily creates a Tick so that the Tick and its + # sub-artists see the same rcParams an eager (pre-lazy) + # materialization would have seen. Kept separate from + # _major_tick_kw/_minor_tick_kw, which hold user-provided + # set_tick_params() overrides rather than ambient rcParams. See + # Tick._configure_for_axis() for the clip-state counterpart. + self._tick_rcParams = None if clear: self.clear() @@ -851,12 +906,14 @@ def _reset_major_tick_kw(self): self._major_tick_kw['gridOn'] = ( mpl.rcParams['axes.grid'] and mpl.rcParams['axes.grid.which'] in ('both', 'major')) + self._tick_rcParams = dict(mpl.rcParams) def _reset_minor_tick_kw(self): self._minor_tick_kw.clear() self._minor_tick_kw['gridOn'] = ( mpl.rcParams['axes.grid'] and mpl.rcParams['axes.grid.which'] in ('both', 'minor')) + self._tick_rcParams = dict(mpl.rcParams) def clear(self): """ @@ -887,6 +944,11 @@ def clear(self): # Clear the callback registry for this axis, or it may "leak" self.callbacks = cbook.CallbackRegistry(signals=["units"]) + # Snapshot current rcParams so that a Tick materialized later by + # _LazyTickList (possibly outside any rc_context() active now) + # sees the same rcParams an eager pre-lazy tick would have. + self._tick_rcParams = dict(mpl.rcParams) + # whether the grids are on self._major_tick_kw['gridOn'] = ( mpl.rcParams['axes.grid'] and @@ -907,19 +969,46 @@ def reset_ticks(self): Each list starts with a single fresh Tick. """ - # Restore the lazy tick lists. - try: - del self.majorTicks - except AttributeError: - pass - try: - del self.minorTicks - except AttributeError: - pass - try: - self.set_clip_path(self.axes.patch) - except AttributeError: - pass + # Drop any materialized tick lists so the _LazyTickList descriptor is + # reactivated on next access. If ticks were already materialized, + # re-apply the axes-patch clip path; otherwise skip. + had_major = bool(self.__dict__.pop('majorTicks', None)) + had_minor = bool(self.__dict__.pop('minorTicks', None)) + if had_major or had_minor: + try: + self.set_clip_path(self.axes.patch) + except AttributeError: + pass + + def _existing_ticks(self, major=None): + """ + Yield already-materialized ticks without triggering the lazy descriptor. + + `majorTicks` and `minorTicks` are `_LazyTickList` descriptors that + create a fresh `.Tick` on first access. Several internal methods + (`set_clip_path`, `set_tick_params`) need to touch every + *already-materialized* tick without forcing materialization, because + doing so would + + (a) create throwaway Tick objects during ``Axes.__init__`` and + ``Axes.__clear`` + (b) risk re-entering the + ``Spine.set_position -> Axis.reset_ticks -> Axis.set_clip_path + -> _LazyTickList.__get__ -> Tick.__init__ -> Spine.set_position`` + cascade. + + Reading the instance ``__dict__`` directly bypasses the descriptor. + + Parameters + ---------- + major : bool, optional + If True, yield only major ticks; if False, only minor ticks; + if None (default), yield major followed by minor. + """ + if major is None or major: + yield from self.__dict__.get('majorTicks', ()) + if major is None or not major: + yield from self.__dict__.get('minorTicks', ()) def minorticks_on(self): """ @@ -988,11 +1077,11 @@ def set_tick_params(self, which='major', reset=False, **kwargs): else: if which in ['major', 'both']: self._major_tick_kw.update(kwtrans) - for tick in self.majorTicks: + for tick in self._existing_ticks(major=True): tick._apply_params(**kwtrans) if which in ['minor', 'both']: self._minor_tick_kw.update(kwtrans) - for tick in self.minorTicks: + for tick in self._existing_ticks(major=False): tick._apply_params(**kwtrans) # labelOn and labelcolor also apply to the offset text. if 'label1On' in kwtrans or 'label2On' in kwtrans: @@ -1131,7 +1220,7 @@ def _translate_tick_params(cls, kw, reverse=False): def set_clip_path(self, path, transform=None): super().set_clip_path(path, transform) - for child in self.majorTicks + self.minorTicks: + for child in self._existing_ticks(): child.set_clip_path(path, transform) self.stale = True