diff --git a/doc/api/next_api_changes/2018-02-15-AL.rst b/doc/api/next_api_changes/2018-02-15-AL.rst new file mode 100644 index 000000000000..112b18e3e322 --- /dev/null +++ b/doc/api/next_api_changes/2018-02-15-AL.rst @@ -0,0 +1,17 @@ +Axis-dependent Locators and Formatters explicitly error out when used over multiple Axis +```````````````````````````````````````````````````````````````````````````````````````` + +Certain Locators and Formatters (e.g. the default `AutoLocator` and +`ScalarFormatter`) can only be used meaningfully on one Axis object at a time +(i.e., attempting to use a single `AutoLocator` instance on the x and the y +axis of an Axes, or the x axis of two different Axes, would result in +nonsensical results). + +Such "double-use" is now detected and raises a RuntimeError *at canvas draw +time*. The exception is not raised when the second Axis is registered in order +to avoid incorrectly raising exceptions for the Locators and Formatters that +*can* be used on multiple Axis objects simultaneously (e.g. `NullLocator` and +`FuncFormatter`). + +In case a Locator or a Formatter really needs to be reassigned from one axis to +another, first set its axis to None to bypass this protection. diff --git a/lib/matplotlib/figure.py b/lib/matplotlib/figure.py index f86c0c78936c..cf1de4f265ed 100644 --- a/lib/matplotlib/figure.py +++ b/lib/matplotlib/figure.py @@ -1589,11 +1589,19 @@ def subplots(self, nrows=1, ncols=1, sharex=False, sharey=False, return axarr def _remove_ax(self, ax): - def _reset_loc_form(axis): - axis.set_major_formatter(axis.get_major_formatter()) - axis.set_major_locator(axis.get_major_locator()) - axis.set_minor_formatter(axis.get_minor_formatter()) - axis.set_minor_locator(axis.get_minor_locator()) + def _reset_tickers(axis): + major_formatter = axis.get_major_formatter() + major_formatter.set_axis(None) # Bypass prevention of axis reset. + axis.set_major_formatter(major_formatter) + major_locator = axis.get_major_locator() + major_locator.set_axis(None) + axis.set_major_locator(major_locator) + minor_formatter = axis.get_minor_formatter() + minor_formatter.set_axis(None) + axis.set_minor_formatter(minor_formatter) + minor_locator = axis.get_minor_locator() + minor_locator.set_axis(None) + axis.set_minor_locator(minor_locator) def _break_share_link(ax, grouper): siblings = grouper.get_siblings(ax) @@ -1607,11 +1615,11 @@ def _break_share_link(ax, grouper): self.delaxes(ax) last_ax = _break_share_link(ax, ax._shared_y_axes) if last_ax is not None: - _reset_loc_form(last_ax.yaxis) + _reset_tickers(last_ax.yaxis) last_ax = _break_share_link(ax, ax._shared_x_axes) if last_ax is not None: - _reset_loc_form(last_ax.xaxis) + _reset_tickers(last_ax.xaxis) def clf(self, keep_observers=False): """ diff --git a/lib/matplotlib/projections/polar.py b/lib/matplotlib/projections/polar.py index 188ffe0708f2..fbdbdc8923a7 100644 --- a/lib/matplotlib/projections/polar.py +++ b/lib/matplotlib/projections/polar.py @@ -198,6 +198,14 @@ class _AxisWrapper(object): def __init__(self, axis): self._axis = axis + def __eq__(self, other): + # Needed so that assignment, as the locator.axis attribute, of another + # _AxisWrapper wrapping the same axis works. + return self._axis == other._axis + + def __hash__(self): + return hash((type(self), *sorted(vars(self).items()))) + def get_view_interval(self): return np.rad2deg(self._axis.get_view_interval()) @@ -227,10 +235,11 @@ class ThetaLocator(mticker.Locator): """ def __init__(self, base): self.base = base - self.axis = self.base.axis = _AxisWrapper(self.base.axis) + self.set_axis(self.base.axis) def set_axis(self, axis): - self.axis = _AxisWrapper(axis) + super().set_axis(_AxisWrapper(axis)) + self.base.set_axis(None) # Bypass prevention of axis resetting. self.base.set_axis(self.axis) def __call__(self): @@ -383,7 +392,6 @@ def _wrap_locator_formatter(self): def cla(self): super().cla() self.set_ticks_position('none') - self._wrap_locator_formatter() def _set_scale(self, value, **kwargs): super()._set_scale(value, **kwargs) diff --git a/lib/matplotlib/tests/test_ticker.py b/lib/matplotlib/tests/test_ticker.py index 9dddb69cf764..99c3b0108193 100644 --- a/lib/matplotlib/tests/test_ticker.py +++ b/lib/matplotlib/tests/test_ticker.py @@ -897,3 +897,29 @@ def minorticksubplot(xminor, yminor, i): minorticksubplot(True, False, 2) minorticksubplot(False, True, 3) minorticksubplot(True, True, 4) + + +def test_multiple_assignment(): + fig = plt.figure() + + ax = fig.subplots() + fmt = mticker.NullFormatter() + ax.xaxis.set_major_formatter(fmt) + ax.yaxis.set_major_formatter(fmt) + fig.canvas.draw() # No error. + fig.clf() + + ax = fig.subplots() + fmt = mticker.ScalarFormatter() + ax.xaxis.set_major_formatter(fmt) + ax.xaxis.set_minor_formatter(fmt) + fig.canvas.draw() # No error. + fig.clf() + + ax = fig.subplots() + fmt = mticker.ScalarFormatter() + ax.xaxis.set_major_formatter(fmt) + ax.yaxis.set_major_formatter(fmt) + with pytest.raises(RuntimeError): + fig.canvas.draw() + fig.clf() diff --git a/lib/matplotlib/ticker.py b/lib/matplotlib/ticker.py index 9c2a1fb7e2db..6b2d36a738c9 100644 --- a/lib/matplotlib/ticker.py +++ b/lib/matplotlib/ticker.py @@ -217,11 +217,41 @@ def get_tick_space(self): return 9 -class TickHelper(object): - axis = None +class TickHelper: + # TickHelpers that access their axis attribute can only be assigned to + # one Axis at a time, but we don't know a priori whether they will (e.g., + # NullFormatter doesn't, but ScalarFormatter does). So keep track of all + # Axises that a TickHelper is assigned to (in a set: a TickHelper could be + # assigned both as major and minor helper on a single axis), but only error + # out after multiple assignment when the attribute is accessed. - def set_axis(self, axis): - self.axis = axis + # As an escape hatch, allow resetting the axis by first setting it to None. + + @property + def axis(self): + # We can't set the '_set_axises' attribute in TickHelper.__init__ + # (without a deprecation period) because subclasses didn't have to call + # super().__init__ so far so they likely didn't. + set_axises = getattr(self, "_set_axises", set()) + if len(set_axises) == 0: + return None + elif len(set_axises) == 1: + axis, = set_axises + return axis + else: + raise RuntimeError( + f"The 'axis' attribute of this {type(self).__name__} object " + f"has been set multiple times, but a {type(self).__name__} " + f"can only be used for one Axis at a time") + + @axis.setter + def axis(self, axis): + if not hasattr(self, "_set_axises") or axis is None: + self._set_axises = set() + if axis is not None: + self._set_axises.add(axis) + + set_axis = axis.fset def create_dummy_axis(self, **kwargs): if self.axis is None: