diff --git a/lib/matplotlib/testing/__init__.py b/lib/matplotlib/testing/__init__.py index 8e60267ed608..19113d399626 100644 --- a/lib/matplotlib/testing/__init__.py +++ b/lib/matplotlib/testing/__init__.py @@ -211,3 +211,24 @@ def ipython_in_subprocess(requested_backend_or_gui_framework, all_expected_backe ) assert proc.stdout.strip().endswith(f"'{expected_backend}'") + + +def is_ci_environment(): + # Common CI variables + ci_environment_variables = [ + 'CI', # Generic CI environment variable + 'CONTINUOUS_INTEGRATION', # Generic CI environment variable + 'TRAVIS', # Travis CI + 'CIRCLECI', # CircleCI + 'JENKINS', # Jenkins + 'GITLAB_CI', # GitLab CI + 'GITHUB_ACTIONS', # GitHub Actions + 'TEAMCITY_VERSION' # TeamCity + # Add other CI environment variables as needed + ] + + for env_var in ci_environment_variables: + if os.getenv(env_var): + return True + + return False diff --git a/lib/matplotlib/testing/__init__.pyi b/lib/matplotlib/testing/__init__.pyi index 1f52a8ccb8ee..6917b6a5a380 100644 --- a/lib/matplotlib/testing/__init__.pyi +++ b/lib/matplotlib/testing/__init__.pyi @@ -51,3 +51,4 @@ def ipython_in_subprocess( requested_backend_or_gui_framework: str, all_expected_backends: dict[tuple[int, int], str], ) -> None: ... +def is_ci_environment() -> bool: ... diff --git a/lib/matplotlib/tests/test_backends_interactive.py b/lib/matplotlib/tests/test_backends_interactive.py index 6830e7d5c845..d624b5db0ac2 100644 --- a/lib/matplotlib/tests/test_backends_interactive.py +++ b/lib/matplotlib/tests/test_backends_interactive.py @@ -19,7 +19,7 @@ import matplotlib as mpl from matplotlib import _c_internal_utils from matplotlib.backend_tools import ToolToggleBase -from matplotlib.testing import subprocess_run_helper as _run_helper +from matplotlib.testing import subprocess_run_helper as _run_helper, is_ci_environment class _WaitForStringPopen(subprocess.Popen): @@ -110,27 +110,6 @@ def _get_testable_interactive_backends(): for env, marks in _get_available_interactive_backends()] -def is_ci_environment(): - # Common CI variables - ci_environment_variables = [ - 'CI', # Generic CI environment variable - 'CONTINUOUS_INTEGRATION', # Generic CI environment variable - 'TRAVIS', # Travis CI - 'CIRCLECI', # CircleCI - 'JENKINS', # Jenkins - 'GITLAB_CI', # GitLab CI - 'GITHUB_ACTIONS', # GitHub Actions - 'TEAMCITY_VERSION' # TeamCity - # Add other CI environment variables as needed - ] - - for env_var in ci_environment_variables: - if os.getenv(env_var): - return True - - return False - - # Reasonable safe values for slower CI/Remote and local architectures. _test_timeout = 120 if is_ci_environment() else 20 diff --git a/lib/matplotlib/tests/test_pickle.py b/lib/matplotlib/tests/test_pickle.py index 0cba4f392035..1474a67d28aa 100644 --- a/lib/matplotlib/tests/test_pickle.py +++ b/lib/matplotlib/tests/test_pickle.py @@ -1,5 +1,7 @@ from io import BytesIO import ast +import os +import sys import pickle import pickletools @@ -8,7 +10,7 @@ import matplotlib as mpl from matplotlib import cm -from matplotlib.testing import subprocess_run_helper +from matplotlib.testing import subprocess_run_helper, is_ci_environment from matplotlib.testing.decorators import check_figures_equal from matplotlib.dates import rrulewrapper from matplotlib.lines import VertexSelector @@ -307,3 +309,23 @@ def test_cycler(): ax = pickle.loads(pickle.dumps(ax)) l, = ax.plot([3, 4]) assert l.get_color() == "m" + + +# Run under an interactive backend to test that we don't try to pickle the +# (interactive and non-picklable) canvas. +def _test_axeswidget_interactive(): + ax = plt.figure().add_subplot() + pickle.dumps(mpl.widgets.Button(ax, "button")) + + +@pytest.mark.xfail( # https://github.com/actions/setup-python/issues/649 + ('TF_BUILD' in os.environ or 'GITHUB_ACTION' in os.environ) and + sys.platform == 'darwin' and sys.version_info[:2] < (3, 11), + reason='Tk version mismatch on Azure macOS CI' + ) +def test_axeswidget_interactive(): + subprocess_run_helper( + _test_axeswidget_interactive, + timeout=120 if is_ci_environment() else 20, + extra_env={'MPLBACKEND': 'tkagg'} + ) diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index ed130e6854f2..a298f3ae3d6a 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -90,22 +90,6 @@ def ignore(self, event): """ return not self.active - def _changed_canvas(self): - """ - Someone has switched the canvas on us! - - This happens if `savefig` needs to save to a format the previous - backend did not support (e.g. saving a figure using an Agg based - backend saved to a vector format). - - Returns - ------- - bool - True if the canvas has been changed. - - """ - return self.canvas is not self.ax.figure.canvas - class AxesWidget(Widget): """ @@ -131,9 +115,10 @@ class AxesWidget(Widget): def __init__(self, ax): self.ax = ax - self.canvas = ax.figure.canvas self._cids = [] + canvas = property(lambda self: self.ax.figure.canvas) + def connect_event(self, event, callback): """ Connect a callback function with an event. @@ -1100,7 +1085,7 @@ def __init__(self, ax, labels, actives=None, *, useblit=True, def _clear(self, event): """Internal event handler to clear the buttons.""" - if self.ignore(event) or self._changed_canvas(): + if self.ignore(event) or self.canvas.is_saving(): return self._background = self.canvas.copy_from_bbox(self.ax.bbox) self.ax.draw_artist(self._checks) @@ -1677,7 +1662,7 @@ def __init__(self, ax, labels, active=0, activecolor=None, *, def _clear(self, event): """Internal event handler to clear the buttons.""" - if self.ignore(event) or self._changed_canvas(): + if self.ignore(event) or self.canvas.is_saving(): return self._background = self.canvas.copy_from_bbox(self.ax.bbox) self.ax.draw_artist(self._buttons) @@ -1933,7 +1918,7 @@ def __init__(self, ax, *, horizOn=True, vertOn=True, useblit=False, def clear(self, event): """Internal event handler to clear the cursor.""" - if self.ignore(event) or self._changed_canvas(): + if self.ignore(event) or self.canvas.is_saving(): return if self.useblit: self.background = self.canvas.copy_from_bbox(self.ax.bbox) @@ -2573,9 +2558,7 @@ def __init__(self, ax, onselect, direction, *, minspan=0, useblit=False, self.drag_from_anywhere = drag_from_anywhere self.ignore_event_outside = ignore_event_outside - # Reset canvas so that `new_axes` connects events. - self.canvas = None - self.new_axes(ax, _props=props) + self.new_axes(ax, _props=props, _init=True) # Setup handles self._handle_props = { @@ -2588,14 +2571,15 @@ def __init__(self, ax, onselect, direction, *, minspan=0, useblit=False, self._active_handle = None - def new_axes(self, ax, *, _props=None): + def new_axes(self, ax, *, _props=None, _init=False): """Set SpanSelector to operate on a new Axes.""" - self.ax = ax - if self.canvas is not ax.figure.canvas: + reconnect = False + if _init or self.canvas is not ax.figure.canvas: if self.canvas is not None: self.disconnect_events() - - self.canvas = ax.figure.canvas + reconnect = True + self.ax = ax + if reconnect: self.connect_default_events() # Reset diff --git a/lib/matplotlib/widgets.pyi b/lib/matplotlib/widgets.pyi index c85ad2158ee7..58adf85aae60 100644 --- a/lib/matplotlib/widgets.pyi +++ b/lib/matplotlib/widgets.pyi @@ -33,8 +33,9 @@ class Widget: class AxesWidget(Widget): ax: Axes - canvas: FigureCanvasBase | None def __init__(self, ax: Axes) -> None: ... + @property + def canvas(self) -> FigureCanvasBase | None: ... def connect_event(self, event: Event, callback: Callable) -> None: ... def disconnect_events(self) -> None: ... @@ -310,7 +311,6 @@ class SpanSelector(_SelectorWidget): grab_range: float drag_from_anywhere: bool ignore_event_outside: bool - canvas: FigureCanvasBase | None def __init__( self, ax: Axes, @@ -330,7 +330,13 @@ class SpanSelector(_SelectorWidget): ignore_event_outside: bool = ..., snap_values: ArrayLike | None = ..., ) -> None: ... - def new_axes(self, ax: Axes, *, _props: dict[str, Any] | None = ...) -> None: ... + def new_axes( + self, + ax: Axes, + *, + _props: dict[str, Any] | None = ..., + _init: bool = ..., + ) -> None: ... def connect_default_events(self) -> None: ... @property def direction(self) -> Literal["horizontal", "vertical"]: ...