From d0bb8ff8b7a56943f3cfc062dedc92448a3fe3b9 Mon Sep 17 00:00:00 2001 From: Pieter Eendebak Date: Sat, 18 Apr 2026 13:23:17 +0200 Subject: [PATCH 1/7] Defer tick materialization during Axes init/clear Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/matplotlib/axes/_base.py | 14 ++++++++ lib/matplotlib/axis.py | 69 ++++++++++++++++++++++++++++-------- 2 files changed, 68 insertions(+), 15 deletions(-) diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py index 1a32af922342..1ecc8f442012 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -1435,6 +1435,20 @@ 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._position = ('outward', 0.0) + spine.set_transform(spine.get_spine_transform()) + 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..64a9f06fd86d 100644 --- a/lib/matplotlib/axis.py +++ b/lib/matplotlib/axis.py @@ -561,11 +561,23 @@ def __get__(self, instance, owner): if self._major: instance.majorTicks = [] tick = instance._get_tick(major=True) + tick.clipbox = instance.clipbox + tick._clippath = instance._clippath + tick._clipon = instance._clipon + tick.gridline.clipbox = instance.clipbox + tick.gridline._clippath = instance._clippath + tick.gridline._clipon = instance._clipon instance.majorTicks = [tick] return instance.majorTicks else: instance.minorTicks = [] tick = instance._get_tick(major=False) + tick.clipbox = instance.clipbox + tick._clippath = instance._clippath + tick._clipon = instance._clipon + tick.gridline.clipbox = instance.clipbox + tick.gridline._clippath = instance._clippath + tick.gridline._clipon = instance._clipon instance.minorTicks = [tick] return instance.minorTicks @@ -908,18 +920,45 @@ 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 + 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`` -- defeating the whole point of the lazy lists -- + and + + (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 +1027,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 +1170,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 From 3ac82247369a25bd5cb4d91d0b494f6f460dcf31 Mon Sep 17 00:00:00 2001 From: Pieter Eendebak Date: Sat, 18 Apr 2026 14:21:32 +0200 Subject: [PATCH 2/7] fix --- lib/matplotlib/axis.py | 93 +++++++++++++++++++++++++++--------------- 1 file changed, 61 insertions(+), 32 deletions(-) diff --git a/lib/matplotlib/axis.py b/lib/matplotlib/axis.py index 64a9f06fd86d..add91f361cce 100644 --- a/lib/matplotlib/axis.py +++ b/lib/matplotlib/axis.py @@ -550,36 +550,36 @@ def __init__(self, major): def __get__(self, instance, owner): if instance is None: return self + # 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. + attr = 'majorTicks' if self._major else 'minorTicks' + setattr(instance, attr, []) + # Build the Tick (and its sub-artists) under the rcParams snapshot + # taken at the last ``Axis.clear`` so that a lazily-materialized + # Tick matches the values an eager (pre-lazy) Tick would have had + # (see ``Axis._rc_snapshot``). Use ``_update_raw`` rather than + # ``rc_context`` to bypass validators that would otherwise fire + # spurious warnings when re-applying settings like + # ``rcParams['toolbar'] = 'toolmanager'``. + snapshot = instance._rc_snapshot + if snapshot is not None: + rc = mpl.rcParams + orig = dict(rc) + rc._update_raw(snapshot) + try: + tick = instance._get_tick(major=self._major) + finally: + rc._update_raw(orig) 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) - tick.clipbox = instance.clipbox - tick._clippath = instance._clippath - tick._clipon = instance._clipon - tick.gridline.clipbox = instance.clipbox - tick.gridline._clippath = instance._clippath - tick.gridline._clipon = instance._clipon - instance.majorTicks = [tick] - return instance.majorTicks - else: - instance.minorTicks = [] - tick = instance._get_tick(major=False) - tick.clipbox = instance.clipbox - tick._clippath = instance._clippath - tick._clipon = instance._clipon - tick.gridline.clipbox = instance.clipbox - tick.gridline._clippath = instance._clippath - tick.gridline._clipon = instance._clipon - instance.minorTicks = [tick] - return instance.minorTicks + tick = instance._get_tick(major=self._major) + instance._propagate_axis_state_to_tick(tick) + setattr(instance, attr, [tick]) + return getattr(instance, attr) class Axis(martist.Artist): @@ -684,6 +684,13 @@ 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`` applies this + # via ``rc_context`` 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. See ``_propagate_axis_state_to_tick`` + # for the clip-state counterpart. + self._rc_snapshot = None if clear: self.clear() @@ -858,17 +865,36 @@ def get_children(self): return [self.label, self.offsetText, *self.get_major_ticks(), *self.get_minor_ticks()] + def _propagate_axis_state_to_tick(self, tick): + """ + Copy Axis clip state onto a lazily-created Tick. + + `Axis.set_clip_path` runs during ``Axes.__clear`` before any Tick + exists under the lazy-init refactor, so the clip stored on the + Axis must be re-stamped onto the first Tick when it materializes + (only the Tick itself and its gridline are clipped, matching + `Tick.set_clip_path`). Per-Artist rcParams like ``path.sketch`` / + ``path.effects`` are handled by the ``rc_context`` wrapping in + `_LazyTickList.__get__`, not here. + """ + for artist in (tick, tick.gridline): + artist.clipbox = self.clipbox + artist._clippath = self._clippath + artist._clipon = self._clipon + def _reset_major_tick_kw(self): self._major_tick_kw.clear() self._major_tick_kw['gridOn'] = ( mpl.rcParams['axes.grid'] and mpl.rcParams['axes.grid.which'] in ('both', 'major')) + self._rc_snapshot = 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._rc_snapshot = dict(mpl.rcParams) def clear(self): """ @@ -899,6 +925,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._rc_snapshot = dict(mpl.rcParams) + # whether the grids are on self._major_tick_kw['gridOn'] = ( mpl.rcParams['axes.grid'] and @@ -939,9 +970,7 @@ def _existing_ticks(self, major=None): doing so would (a) create throwaway Tick objects during ``Axes.__init__`` and - ``Axes.__clear`` -- defeating the whole point of the lazy lists -- - 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`` From ceef40cc5e0ffba2d8a4cde204352b095ea3b917 Mon Sep 17 00:00:00 2001 From: Pieter Eendebak Date: Sat, 18 Apr 2026 15:01:49 +0200 Subject: [PATCH 3/7] fix --- lib/matplotlib/axis.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/lib/matplotlib/axis.py b/lib/matplotlib/axis.py index add91f361cce..8cb0e9f1ff0d 100644 --- a/lib/matplotlib/axis.py +++ b/lib/matplotlib/axis.py @@ -577,6 +577,17 @@ def __get__(self, instance, owner): rc._update_raw(orig) else: 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) instance._propagate_axis_state_to_tick(tick) setattr(instance, attr, [tick]) return getattr(instance, attr) From fe563e39b2327212172fe030b36a6256689b9965 Mon Sep 17 00:00:00 2001 From: Pieter Eendebak Date: Tue, 21 Apr 2026 22:03:45 +0200 Subject: [PATCH 4/7] review comments --- lib/matplotlib/axis.py | 45 +++++++++++++++++++++++------------------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/lib/matplotlib/axis.py b/lib/matplotlib/axis.py index 8cb0e9f1ff0d..f15c0730209f 100644 --- a/lib/matplotlib/axis.py +++ b/lib/matplotlib/axis.py @@ -559,18 +559,19 @@ def __get__(self, instance, owner): # created and assigned afterwards. attr = 'majorTicks' if self._major else 'minorTicks' setattr(instance, attr, []) - # Build the Tick (and its sub-artists) under the rcParams snapshot - # taken at the last ``Axis.clear`` so that a lazily-materialized - # Tick matches the values an eager (pre-lazy) Tick would have had - # (see ``Axis._rc_snapshot``). Use ``_update_raw`` rather than - # ``rc_context`` to bypass validators that would otherwise fire - # spurious warnings when re-applying settings like - # ``rcParams['toolbar'] = 'toolmanager'``. - snapshot = instance._rc_snapshot - if snapshot is not None: + # 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``). + # We avoid ``rc_context`` here because it re-applies rcParams via + # ``RcParams.__setitem__``, whose validators emit warnings on every + # assignment for keys like ``toolbar='toolmanager'`` -- re-setting + # the snapshot to its own (identical) values would spuriously + # re-trigger those warnings. ``_update_raw`` bypasses the validators + # on both entry and exit. + if instance._tick_rcParams is not None: rc = mpl.rcParams orig = dict(rc) - rc._update_raw(snapshot) + rc._update_raw(instance._tick_rcParams) try: tick = instance._get_tick(major=self._major) finally: @@ -696,12 +697,14 @@ def __init__(self, axes, *, pickradius=15, clear=True): 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`` applies this - # via ``rc_context`` 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. See ``_propagate_axis_state_to_tick`` - # for the clip-state counterpart. - self._rc_snapshot = None + # ``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 + # ``_propagate_axis_state_to_tick`` for the clip-state counterpart. + self._tick_rcParams = None if clear: self.clear() @@ -898,14 +901,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._rc_snapshot = dict(mpl.rcParams) + 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._rc_snapshot = dict(mpl.rcParams) + self._tick_rcParams = dict(mpl.rcParams) def clear(self): """ @@ -939,7 +942,7 @@ def clear(self): # 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._rc_snapshot = dict(mpl.rcParams) + self._tick_rcParams = dict(mpl.rcParams) # whether the grids are on self._major_tick_kw['gridOn'] = ( @@ -961,7 +964,9 @@ def reset_ticks(self): Each list starts with a single fresh Tick. """ - # Restore the lazy tick lists. + # 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: From 1b72b8b40478fa28644d296c40c05187ba7f7d8a Mon Sep 17 00:00:00 2001 From: Pieter Eendebak Date: Thu, 23 Apr 2026 21:15:46 +0200 Subject: [PATCH 5/7] review comments: docstrings, comment style, spine helper Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/matplotlib/axes/_base.py | 17 ++++++----- lib/matplotlib/axis.py | 56 ++++++++++++++++++------------------ 2 files changed, 36 insertions(+), 37 deletions(-) diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py index 1ecc8f442012..77d2234312ae 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -1436,18 +1436,17 @@ def __clear(self): 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 + # 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). + # 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._position = ('outward', 0.0) - spine.set_transform(spine.get_spine_transform()) + spine._ensure_position_is_set() if self._sharex is not None: self.xaxis.set_visible(xaxis_visible) diff --git a/lib/matplotlib/axis.py b/lib/matplotlib/axis.py index f15c0730209f..4692e490f1a9 100644 --- a/lib/matplotlib/axis.py +++ b/lib/matplotlib/axis.py @@ -548,6 +548,7 @@ 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 # instance._get_tick() can itself try to access the majorTicks @@ -560,13 +561,13 @@ def __get__(self, instance, owner): attr = 'majorTicks' if self._major else 'minorTicks' setattr(instance, attr, []) # 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``). - # We avoid ``rc_context`` here because it re-applies rcParams via - # ``RcParams.__setitem__``, whose validators emit warnings on every - # assignment for keys like ``toolbar='toolmanager'`` -- re-setting + # at the last Axis.clear() so that a lazily-materialized Tick + # matches an eager (pre-lazy) Tick (see Axis._tick_rcParams). + # We avoid rc_context() here because it re-applies rcParams via + # RcParams.__setitem__, whose validators emit warnings on every + # assignment for keys like toolbar='toolmanager' -- re-setting # the snapshot to its own (identical) values would spuriously - # re-trigger those warnings. ``_update_raw`` bypasses the validators + # re-trigger those warnings. _update_raw() bypasses the validators # on both entry and exit. if instance._tick_rcParams is not None: rc = mpl.rcParams @@ -578,13 +579,13 @@ def __get__(self, instance, owner): rc._update_raw(orig) else: 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``. + # 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: @@ -696,14 +697,14 @@ 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 + # 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 - # ``_propagate_axis_state_to_tick`` for the clip-state counterpart. + # _major_tick_kw/_minor_tick_kw, which hold user-provided + # set_tick_params() overrides rather than ambient rcParams. See + # _propagate_axis_state_to_tick() for the clip-state counterpart. self._tick_rcParams = None if clear: @@ -883,13 +884,12 @@ def _propagate_axis_state_to_tick(self, tick): """ Copy Axis clip state onto a lazily-created Tick. - `Axis.set_clip_path` runs during ``Axes.__clear`` before any Tick - exists under the lazy-init refactor, so the clip stored on the - Axis must be re-stamped onto the first Tick when it materializes - (only the Tick itself and its gridline are clipped, matching - `Tick.set_clip_path`). Per-Artist rcParams like ``path.sketch`` / - ``path.effects`` are handled by the ``rc_context`` wrapping in - `_LazyTickList.__get__`, not here. + `Axis.set_clip_path` can run before any Tick exists, so the clip + stored on the Axis must be re-stamped onto the first Tick when + it materializes (only the Tick itself and its gridline are + clipped, matching `Tick.set_clip_path`). Per-Artist rcParams + like ``path.sketch`` / ``path.effects`` are handled by the + rcParams restore in `_LazyTickList.__get__`, not here. """ for artist in (tick, tick.gridline): artist.clipbox = self.clipbox @@ -940,8 +940,8 @@ def clear(self): 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. + # _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 From e80c08e5789006b6f6b417ded9aad7d69ec46773 Mon Sep 17 00:00:00 2001 From: Pieter Eendebak Date: Thu, 23 Apr 2026 21:17:46 +0200 Subject: [PATCH 6/7] extract _rc_context_raw; simplify _LazyTickList.__get__ Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/matplotlib/axis.py | 55 ++++++++++++++++++++++++------------------ 1 file changed, 32 insertions(+), 23 deletions(-) diff --git a/lib/matplotlib/axis.py b/lib/matplotlib/axis.py index 4692e490f1a9..8f487e6fe8ad 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 @@ -536,6 +537,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. @@ -553,31 +574,18 @@ def __get__(self, instance, owner): return self # 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. + # 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' - setattr(instance, attr, []) + 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). - # We avoid rc_context() here because it re-applies rcParams via - # RcParams.__setitem__, whose validators emit warnings on every - # assignment for keys like toolbar='toolmanager' -- re-setting - # the snapshot to its own (identical) values would spuriously - # re-trigger those warnings. _update_raw() bypasses the validators - # on both entry and exit. - if instance._tick_rcParams is not None: - rc = mpl.rcParams - orig = dict(rc) - rc._update_raw(instance._tick_rcParams) - try: - tick = instance._get_tick(major=self._major) - finally: - rc._update_raw(orig) - else: + 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 @@ -591,8 +599,9 @@ def __get__(self, instance, owner): if tick_kw: tick._apply_params(**tick_kw) instance._propagate_axis_state_to_tick(tick) - setattr(instance, attr, [tick]) - return getattr(instance, attr) + tick_list.append(tick) + setattr(instance, attr, tick_list) + return tick_list class Axis(martist.Artist): From d0ed04c9341862c390a2b70b034c9fa96867f417 Mon Sep 17 00:00:00 2001 From: Pieter Eendebak Date: Thu, 23 Apr 2026 21:27:30 +0200 Subject: [PATCH 7/7] move clip-state propagation onto Tick._configure_for_axis Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/matplotlib/axis.py | 32 ++++++++++++++------------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/lib/matplotlib/axis.py b/lib/matplotlib/axis.py index 8f487e6fe8ad..466dd6b5779b 100644 --- a/lib/matplotlib/axis.py +++ b/lib/matplotlib/axis.py @@ -244,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. @@ -598,7 +610,7 @@ def __get__(self, instance, owner): else instance._minor_tick_kw) if tick_kw: tick._apply_params(**tick_kw) - instance._propagate_axis_state_to_tick(tick) + tick._configure_for_axis(instance) tick_list.append(tick) setattr(instance, attr, tick_list) return tick_list @@ -713,7 +725,7 @@ def __init__(self, axes, *, pickradius=15, clear=True): # 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 - # _propagate_axis_state_to_tick() for the clip-state counterpart. + # Tick._configure_for_axis() for the clip-state counterpart. self._tick_rcParams = None if clear: @@ -889,22 +901,6 @@ def get_children(self): return [self.label, self.offsetText, *self.get_major_ticks(), *self.get_minor_ticks()] - def _propagate_axis_state_to_tick(self, tick): - """ - Copy Axis clip state onto a lazily-created Tick. - - `Axis.set_clip_path` can run before any Tick exists, so the clip - stored on the Axis must be re-stamped onto the first Tick when - it materializes (only the Tick itself and its gridline are - clipped, matching `Tick.set_clip_path`). Per-Artist rcParams - like ``path.sketch`` / ``path.effects`` are handled by the - rcParams restore in `_LazyTickList.__get__`, not here. - """ - for artist in (tick, tick.gridline): - artist.clipbox = self.clipbox - artist._clippath = self._clippath - artist._clipon = self._clipon - def _reset_major_tick_kw(self): self._major_tick_kw.clear() self._major_tick_kw['gridOn'] = (