Skip to content

Commit ae58960

Browse files
committed
Prevent reuse of certain Locators and Formatters over multiple Axises.
The call to _wrap_locator_formatter was deleted from ThetaAxis.gca() because it is redundant with the call in ThetaAxis._set_scale (which gca() calls), and would cause double wrapping with _AxisWrapper, causing issues when checking for axis equality.
1 parent ac07e81 commit ae58960

File tree

5 files changed

+103
-14
lines changed

5 files changed

+103
-14
lines changed
+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
Axis-dependent Locators and Formatters explicitly error out when used over multiple Axis
2+
````````````````````````````````````````````````````````````````````````````````````````
3+
4+
Certain Locators and Formatters (e.g. the default `AutoLocator` and
5+
`ScalarFormatter`) can only be used meaningfully on one Axis object at a time
6+
(i.e., attempting to use a single `AutoLocator` instance on the x and the y
7+
axis of an Axes, or the x axis of two different Axes, would result in
8+
nonsensical results).
9+
10+
Such "double-use" is now detected and raises a RuntimeError *at canvas draw
11+
time*. The exception is not raised when the second Axis is registered in order
12+
to avoid incorrectly raising exceptions for the Locators and Formatters that
13+
*can* be used on multiple Axis objects simultaneously (e.g. `NullLocator` and
14+
`FuncFormatter`).
15+
16+
In case a Locator or a Formatter really needs to be reassigned from one axis to
17+
another, first set its axis to None to bypass this protection.

lib/matplotlib/figure.py

+15-7
Original file line numberDiff line numberDiff line change
@@ -1589,11 +1589,19 @@ def subplots(self, nrows=1, ncols=1, sharex=False, sharey=False,
15891589
return axarr
15901590

15911591
def _remove_ax(self, ax):
1592-
def _reset_loc_form(axis):
1593-
axis.set_major_formatter(axis.get_major_formatter())
1594-
axis.set_major_locator(axis.get_major_locator())
1595-
axis.set_minor_formatter(axis.get_minor_formatter())
1596-
axis.set_minor_locator(axis.get_minor_locator())
1592+
def _reset_tickers(axis):
1593+
major_formatter = axis.get_major_formatter()
1594+
major_formatter.set_axis(None) # Bypass prevention of axis reset.
1595+
axis.set_major_formatter(major_formatter)
1596+
major_locator = axis.get_major_locator()
1597+
major_locator.set_axis(None)
1598+
axis.set_major_locator(major_locator)
1599+
minor_formatter = axis.get_minor_formatter()
1600+
minor_formatter.set_axis(None)
1601+
axis.set_minor_formatter(minor_formatter)
1602+
minor_locator = axis.get_minor_locator()
1603+
minor_locator.set_axis(None)
1604+
axis.set_minor_locator(minor_locator)
15971605

15981606
def _break_share_link(ax, grouper):
15991607
siblings = grouper.get_siblings(ax)
@@ -1607,11 +1615,11 @@ def _break_share_link(ax, grouper):
16071615
self.delaxes(ax)
16081616
last_ax = _break_share_link(ax, ax._shared_y_axes)
16091617
if last_ax is not None:
1610-
_reset_loc_form(last_ax.yaxis)
1618+
_reset_tickers(last_ax.yaxis)
16111619

16121620
last_ax = _break_share_link(ax, ax._shared_x_axes)
16131621
if last_ax is not None:
1614-
_reset_loc_form(last_ax.xaxis)
1622+
_reset_tickers(last_ax.xaxis)
16151623

16161624
def clf(self, keep_observers=False):
16171625
"""

lib/matplotlib/projections/polar.py

+11-3
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,14 @@ class _AxisWrapper(object):
198198
def __init__(self, axis):
199199
self._axis = axis
200200

201+
def __eq__(self, other):
202+
# Needed so that assignment, as the locator.axis attribute, of another
203+
# _AxisWrapper wrapping the same axis works.
204+
return self._axis == other._axis
205+
206+
def __hash__(self):
207+
return hash((type(self), *sorted(vars(self).items())))
208+
201209
def get_view_interval(self):
202210
return np.rad2deg(self._axis.get_view_interval())
203211

@@ -227,10 +235,11 @@ class ThetaLocator(mticker.Locator):
227235
"""
228236
def __init__(self, base):
229237
self.base = base
230-
self.axis = self.base.axis = _AxisWrapper(self.base.axis)
238+
self.set_axis(self.base.axis)
231239

232240
def set_axis(self, axis):
233-
self.axis = _AxisWrapper(axis)
241+
super().set_axis(_AxisWrapper(axis))
242+
self.base.set_axis(None) # Bypass prevention of axis resetting.
234243
self.base.set_axis(self.axis)
235244

236245
def __call__(self):
@@ -383,7 +392,6 @@ def _wrap_locator_formatter(self):
383392
def cla(self):
384393
super().cla()
385394
self.set_ticks_position('none')
386-
self._wrap_locator_formatter()
387395

388396
def _set_scale(self, value, **kwargs):
389397
super()._set_scale(value, **kwargs)

lib/matplotlib/tests/test_ticker.py

+26
Original file line numberDiff line numberDiff line change
@@ -897,3 +897,29 @@ def minorticksubplot(xminor, yminor, i):
897897
minorticksubplot(True, False, 2)
898898
minorticksubplot(False, True, 3)
899899
minorticksubplot(True, True, 4)
900+
901+
902+
def test_multiple_assignment():
903+
fig = plt.figure()
904+
905+
ax = fig.subplots()
906+
fmt = mticker.NullFormatter()
907+
ax.xaxis.set_major_formatter(fmt)
908+
ax.yaxis.set_major_formatter(fmt)
909+
fig.canvas.draw() # No error.
910+
fig.clf()
911+
912+
ax = fig.subplots()
913+
fmt = mticker.ScalarFormatter()
914+
ax.xaxis.set_major_formatter(fmt)
915+
ax.xaxis.set_minor_formatter(fmt)
916+
fig.canvas.draw() # No error.
917+
fig.clf()
918+
919+
ax = fig.subplots()
920+
fmt = mticker.ScalarFormatter()
921+
ax.xaxis.set_major_formatter(fmt)
922+
ax.yaxis.set_major_formatter(fmt)
923+
with pytest.raises(RuntimeError):
924+
fig.canvas.draw()
925+
fig.clf()

lib/matplotlib/ticker.py

+34-4
Original file line numberDiff line numberDiff line change
@@ -217,11 +217,41 @@ def get_tick_space(self):
217217
return 9
218218

219219

220-
class TickHelper(object):
221-
axis = None
220+
class TickHelper:
221+
# TickHelpers that access their axis attribute can only be assigned to
222+
# one Axis at a time, but we don't know a priori whether they will (e.g.,
223+
# NullFormatter doesn't, but ScalarFormatter does). So keep track of all
224+
# Axises that a TickHelper is assigned to (in a set: a TickHelper could be
225+
# assigned both as major and minor helper on a single axis), but only error
226+
# out after multiple assignment when the attribute is accessed.
222227

223-
def set_axis(self, axis):
224-
self.axis = axis
228+
# As an escape hatch, allow resetting the axis by first setting it to None.
229+
230+
@property
231+
def axis(self):
232+
# We can't set the '_set_axises' attribute in TickHelper.__init__
233+
# (without a deprecation period) because subclasses didn't have to call
234+
# super().__init__ so far so they likely didn't.
235+
set_axises = getattr(self, "_set_axises", set())
236+
if len(set_axises) == 0:
237+
return None
238+
elif len(set_axises) == 1:
239+
axis, = set_axises
240+
return axis
241+
else:
242+
raise RuntimeError(
243+
f"The 'axis' attribute of this {type(self).__name__} object "
244+
f"has been set multiple times, but a {type(self).__name__} "
245+
f"can only be used for one Axis at a time")
246+
247+
@axis.setter
248+
def axis(self, axis):
249+
if not hasattr(self, "_set_axises") or axis is None:
250+
self._set_axises = set()
251+
if axis is not None:
252+
self._set_axises.add(axis)
253+
254+
set_axis = axis.fset
225255

226256
def create_dummy_axis(self, **kwargs):
227257
if self.axis is None:

0 commit comments

Comments
 (0)