From 4df17b88795a00308afeea7639e4e11e6d77bcdf Mon Sep 17 00:00:00 2001
From: Ruth Comer <10599679+rcomer@users.noreply.github.com>
Date: Sat, 23 May 2026 16:00:16 +0100
Subject: [PATCH 1/4] MNT: bump minimum python to 3.12
---
.appveyor.yml | 2 +-
.github/workflows/cibuildwheel.yml | 20 +------------
.github/workflows/linting.yml | 4 +--
.github/workflows/mypy-stubtest.yml | 4 +--
.github/workflows/tests.yml | 20 ++-----------
azure-pipelines.yml | 5 +---
doc/devel/min_dep_policy.rst | 1 +
doc/devel/testing.rst | 2 +-
doc/install/dependencies.rst | 2 +-
environment.yml | 2 +-
lib/matplotlib/_api/__init__.py | 30 ++++---------------
lib/matplotlib/_api/deprecation.pyi | 5 +---
lib/matplotlib/ft2font.pyi | 9 ++----
.../tests/test_backends_interactive.py | 13 --------
lib/matplotlib/tests/test_cbook.py | 18 +++++------
lib/matplotlib/tests/test_pickle.py | 7 -----
pyproject.toml | 3 +-
tox.ini | 2 +-
18 files changed, 32 insertions(+), 117 deletions(-)
diff --git a/.appveyor.yml b/.appveyor.yml
index 4521bc876a8f..10109c9f80f7 100644
--- a/.appveyor.yml
+++ b/.appveyor.yml
@@ -28,7 +28,7 @@ environment:
--cov-report= --cov=lib --log-level=DEBUG
matrix:
- - PYTHON_VERSION: "3.11"
+ - PYTHON_VERSION: "3.12"
# We always use a 64-bit machine, but can build x86 distributions
# with the PYTHON_ARCH variable
diff --git a/.github/workflows/cibuildwheel.yml b/.github/workflows/cibuildwheel.yml
index 2bb7f9544902..36ec0e404f1f 100644
--- a/.github/workflows/cibuildwheel.yml
+++ b/.github/workflows/cibuildwheel.yml
@@ -50,7 +50,7 @@ jobs:
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
name: Install Python
with:
- python-version: '3.11'
+ python-version: '3.12'
# Something changed somewhere that prevents the downloaded-at-build-time
# licenses from being included in built wheels, so pre-download them so
@@ -176,24 +176,6 @@ jobs:
CIBW_BUILD: "cp312-*"
CIBW_ARCHS: ${{ matrix.cibw_archs }}
- - name: Build wheels for CPython 3.11
- uses: pypa/cibuildwheel@8d2b08b68458a16aeb24b64e68a09ab1c8e82084 # v3.4.1
- with:
- package-dir: dist/${{ needs.build_sdist.outputs.SDIST_NAME }}
- env:
- CIBW_BUILD: "cp311-*"
- CIBW_ARCHS: ${{ matrix.cibw_archs }}
-
- - name: Build wheels for PyPy
- uses: pypa/cibuildwheel@8d2b08b68458a16aeb24b64e68a09ab1c8e82084 # v3.4.1
- with:
- package-dir: dist/${{ needs.build_sdist.outputs.SDIST_NAME }}
- env:
- CIBW_BUILD: "pp311-*"
- CIBW_ARCHS: ${{ matrix.cibw_archs }}
- CIBW_ENABLE: pypy
- if: matrix.cibw_archs != 'aarch64' && matrix.os != 'windows-latest' && matrix.os != 'windows-11-arm'
-
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: cibw-wheels-${{ runner.os }}-${{ matrix.cibw_archs }}
diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml
index 51593f607653..33744048d219 100644
--- a/.github/workflows/linting.yml
+++ b/.github/workflows/linting.yml
@@ -36,7 +36,7 @@ jobs:
- name: Set up Python 3
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
- python-version: '3.11'
+ python-version: '3.12'
- name: Install ruff
run: pip3 install ruff
@@ -66,7 +66,7 @@ jobs:
- name: Set up Python 3
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
- python-version: '3.11'
+ python-version: '3.12'
- name: Install mypy
run: pip3 install --group build --group typing
diff --git a/.github/workflows/mypy-stubtest.yml b/.github/workflows/mypy-stubtest.yml
index 81fcd48462e8..da3ff7610901 100644
--- a/.github/workflows/mypy-stubtest.yml
+++ b/.github/workflows/mypy-stubtest.yml
@@ -19,7 +19,7 @@ jobs:
- name: Set up Python 3
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
- python-version: '3.11'
+ python-version: '3.12'
- name: Set up reviewdog
uses: reviewdog/action-setup@d8a7baabd7f3e8544ee4dbde3ee41d0011c3a93f # v1.5.0
@@ -33,7 +33,7 @@ jobs:
run: |
set -o pipefail
tox -e stubtest | \
- sed -e "s!.tox/stubtest/lib/python3.11/site-packages!lib!g" | \
+ sed -e "s!.tox/stubtest/lib/python3.12/site-packages!lib!g" | \
reviewdog \
-efm '%Eerror: %m' \
-efm '%CStub: in file %f:%l' \
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index a3a9def4bd40..442f4facbbb9 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -51,13 +51,13 @@ jobs:
include:
- name-suffix: "(Minimum Versions)"
os: ubuntu-22.04
- python-version: '3.11'
+ python-version: '3.12'
extra-requirements: '-c ci/minver-requirements.txt'
delete-font-cache: true
# https://github.com/matplotlib/matplotlib/issues/29844
pygobject-ver: '<3.52.0'
- os: ubuntu-22.04
- python-version: '3.11'
+ python-version: '3.12'
CFLAGS: "-fno-lto" # Ensure that disabling LTO works.
extra-requirements: '--group test-extra'
# https://github.com/matplotlib/matplotlib/issues/29844
@@ -73,16 +73,10 @@ jobs:
python-version: '3.13t'
# https://github.com/matplotlib/matplotlib/issues/29844
pygobject-ver: '<3.52.0'
- - os: ubuntu-24.04
- python-version: '3.12'
- os: ubuntu-24.04
python-version: '3.14'
- os: ubuntu-24.04-arm
python-version: '3.12'
- - os: macos-14 # This runner is on M1 (arm64) chips.
- python-version: '3.11'
- # https://github.com/matplotlib/matplotlib/issues/29732
- pygobject-ver: '<3.52.0'
- os: macos-14 # This runner is on M1 (arm64) chips.
python-version: '3.12'
# https://github.com/matplotlib/matplotlib/issues/29732
@@ -267,16 +261,6 @@ jobs:
echo 'PyQt5 is available' ||
echo 'PyQt5 is not available'
fi
- # Even though PySide2 wheels can be installed on Python 3.12+, they are broken and since PySide2 is
- # deprecated, they are unlikely to be fixed. For the same deprecation reason, there are no wheels
- # on M1 macOS, so don't bother there either.
- if [[ "${{ matrix.os }}" != 'macos-14' && "${{ matrix.python-version }}" == '3.11'
- ]]; then
- python -mpip install --upgrade pyside2 &&
- python -c 'import PySide2.QtCore' &&
- echo 'PySide2 is available' ||
- echo 'PySide2 is not available'
- fi
python -mpip install --upgrade --only-binary :all: pyqt6 &&
python -c 'import PyQt6.QtCore' &&
echo 'PyQt6 is available' ||
diff --git a/azure-pipelines.yml b/azure-pipelines.yml
index 829a1c7b9005..c8df751f2419 100644
--- a/azure-pipelines.yml
+++ b/azure-pipelines.yml
@@ -49,11 +49,8 @@ stages:
- job: Pytest
strategy:
matrix:
- Windows_py311:
- vmImage: 'windows-2022' # Keep one job pinned to the oldest image
- python.version: '3.11'
Windows_py312:
- vmImage: 'windows-latest'
+ vmImage: 'windows-2022' # Keep one job pinned to the oldest image
python.version: '3.12'
Windows_py313:
vmImage: 'windows-latest'
diff --git a/doc/devel/min_dep_policy.rst b/doc/devel/min_dep_policy.rst
index 517cc872139e..d8e4902fda6c 100644
--- a/doc/devel/min_dep_policy.rst
+++ b/doc/devel/min_dep_policy.rst
@@ -115,6 +115,7 @@ specification of the dependencies.
========== ======== ======
Matplotlib Python NumPy
========== ======== ======
+3.12 3.12 1.25.0
3.11 3.11 1.25.0
`3.10`_ 3.10 1.23.0
`3.9`_ 3.9 1.23.0
diff --git a/doc/devel/testing.rst b/doc/devel/testing.rst
index 990b9d0b6493..27594ffe7dd4 100644
--- a/doc/devel/testing.rst
+++ b/doc/devel/testing.rst
@@ -331,7 +331,7 @@ You can also run tox on a subset of environments:
.. code-block:: bash
- $ tox -e py310,py311
+ $ tox -e py312,py314
Tox processes environments sequentially by default,
which can be slow when testing multiple environments.
diff --git a/doc/install/dependencies.rst b/doc/install/dependencies.rst
index 578fab93ed5a..ad5187cd2d86 100644
--- a/doc/install/dependencies.rst
+++ b/doc/install/dependencies.rst
@@ -20,7 +20,7 @@ When installing through a package manager like ``pip`` or ``conda``, the
mandatory dependencies are automatically installed. This list is mainly for
reference.
-* `Python `_ (>= 3.11)
+* `Python `_ (>= 3.12)
* `contourpy `_ (>= 1.0.1)
* `cycler `_ (>= 0.10.0)
* `dateutil `_ (>= 2.7)
diff --git a/environment.yml b/environment.yml
index 313ab11f7e6f..c546409449fd 100644
--- a/environment.yml
+++ b/environment.yml
@@ -26,7 +26,7 @@ dependencies:
- pygobject
- pyparsing>=3
- pyqt
- - python>=3.11
+ - python>=3.12
- python-dateutil>=2.1
- setuptools_scm<10
- wxpython
diff --git a/lib/matplotlib/_api/__init__.py b/lib/matplotlib/_api/__init__.py
index 444e9c76b5b3..d164f7f6d12a 100644
--- a/lib/matplotlib/_api/__init__.py
+++ b/lib/matplotlib/_api/__init__.py
@@ -14,8 +14,6 @@
import functools
import itertools
import pathlib
-import re
-import sys
import warnings
from .deprecation import ( # noqa: F401
@@ -470,25 +468,9 @@ def warn_external(message, category=None):
warnings.warn`` (or ``functools.partial(warnings.warn, stacklevel=2)``,
etc.).
"""
- kwargs = {}
- if sys.version_info[:2] >= (3, 12):
- # Go to Python's `site-packages` or `lib` from an editable install.
- basedir = pathlib.Path(__file__).parents[2]
- kwargs['skip_file_prefixes'] = (str(basedir / 'matplotlib'),
- str(basedir / 'mpl_toolkits'))
- else:
- frame = sys._getframe()
- for stacklevel in itertools.count(1):
- if frame is None:
- # when called in embedded context may hit frame is None
- kwargs['stacklevel'] = stacklevel
- break
- if not re.match(r"\A(matplotlib|mpl_toolkits)(\Z|\.(?!tests\.))",
- # Work around sphinx-gallery not setting __name__.
- frame.f_globals.get("__name__", "")):
- kwargs['stacklevel'] = stacklevel
- break
- frame = frame.f_back
- # preemptively break reference cycle between locals and the frame
- del frame
- warnings.warn(message, category, **kwargs)
+ # Go to Python's `site-packages` or `lib` from an editable install.
+ basedir = pathlib.Path(__file__).parents[2]
+ skip_file_prefixes = (str(basedir / 'matplotlib'),
+ str(basedir / 'mpl_toolkits'))
+
+ warnings.warn(message, category, skip_file_prefixes=skip_file_prefixes)
diff --git a/lib/matplotlib/_api/deprecation.pyi b/lib/matplotlib/_api/deprecation.pyi
index e050290662d9..30034c0f7f31 100644
--- a/lib/matplotlib/_api/deprecation.pyi
+++ b/lib/matplotlib/_api/deprecation.pyi
@@ -1,9 +1,6 @@
from collections.abc import Callable
import contextlib
-from typing import Any, Literal, ParamSpec, TypedDict, TypeVar, overload
-from typing_extensions import (
- Unpack, # < Py 3.11
-)
+from typing import Any, Literal, ParamSpec, TypedDict, TypeVar, Unpack, overload
_P = ParamSpec("_P")
_R = TypeVar("_R")
diff --git a/lib/matplotlib/ft2font.pyi b/lib/matplotlib/ft2font.pyi
index f8057742b376..ea47811a1822 100644
--- a/lib/matplotlib/ft2font.pyi
+++ b/lib/matplotlib/ft2font.pyi
@@ -1,8 +1,7 @@
+from collections.abc import Buffer
from enum import Enum, Flag
from os import PathLike
-import sys
from typing import BinaryIO, Literal, NewType, NotRequired, TypeAlias, TypedDict, cast, final, overload
-from typing_extensions import Buffer # < Py 3.12
import numpy as np
from numpy.typing import NDArray
@@ -242,8 +241,7 @@ class FT2Font(Buffer):
_kerning_factor: int | None = ...,
_warn_if_used: bool = ...,
) -> None: ...
- if sys.version_info[:2] >= (3, 12):
- def __buffer__(self, /, flags: int) -> memoryview: ...
+ def __buffer__(self, flags: int, /) -> memoryview: ...
def _layout(
self,
text: str,
@@ -348,8 +346,7 @@ class FT2Font(Buffer):
class FT2Image(Buffer):
def __init__(self, width: int, height: int) -> None: ...
def draw_rect_filled(self, x0: int, y0: int, x1: int, y1: int) -> None: ...
- if sys.version_info[:2] >= (3, 12):
- def __buffer__(self, /, flags: int) -> memoryview: ...
+ def __buffer__(self, flags: int, /) -> memoryview: ...
@final
class Glyph:
diff --git a/lib/matplotlib/tests/test_backends_interactive.py b/lib/matplotlib/tests/test_backends_interactive.py
index 5d76300054d7..3da5f708cb28 100644
--- a/lib/matplotlib/tests/test_backends_interactive.py
+++ b/lib/matplotlib/tests/test_backends_interactive.py
@@ -326,11 +326,6 @@ def _test_thread_impl():
reason='PyPy does not support Tkinter threading: '
'https://foss.heptapod.net/pypy/pypy/-/issues/1929',
strict=True))
- elif (backend == 'tkagg' and
- ('TF_BUILD' in os.environ or 'GITHUB_ACTION' in os.environ) and
- sys.platform == 'darwin' and sys.version_info[:2] < (3, 11)):
- param.marks.append( # https://github.com/actions/setup-python/issues/649
- pytest.mark.xfail('Tk version mismatch on Azure macOS CI'))
@pytest.mark.parametrize("env", _thread_safe_backends)
@@ -610,14 +605,6 @@ def _test_number_of_draws_script():
elif backend == "wx":
param.marks.append(
pytest.mark.skip("wx does not support blitting"))
- elif (backend == 'tkagg' and
- ('TF_BUILD' in os.environ or 'GITHUB_ACTION' in os.environ) and
- sys.platform == 'darwin' and
- sys.version_info[:2] < (3, 11)
- ):
- param.marks.append( # https://github.com/actions/setup-python/issues/649
- pytest.mark.xfail('Tk version mismatch on Azure macOS CI')
- )
@pytest.mark.parametrize("env", _blit_backends)
diff --git a/lib/matplotlib/tests/test_cbook.py b/lib/matplotlib/tests/test_cbook.py
index 2db0d66ccbb5..0c0373217ea0 100644
--- a/lib/matplotlib/tests/test_cbook.py
+++ b/lib/matplotlib/tests/test_cbook.py
@@ -643,17 +643,13 @@ def get_a(self): return None
def test_warn_external(recwarn):
_api.warn_external("oops")
assert len(recwarn) == 1
- if sys.version_info[:2] >= (3, 12):
- # With Python 3.12, we let Python figure out the stacklevel using the
- # `skip_file_prefixes` argument, which cannot exempt tests, so just confirm
- # the filename is not in the package.
- basedir = pathlib.Path(__file__).parents[2]
- assert not recwarn[0].filename.startswith((str(basedir / 'matplotlib'),
- str(basedir / 'mpl_toolkits')))
- else:
- # On older Python versions, we manually calculated the stacklevel, and had an
- # exception for our own tests.
- assert recwarn[0].filename == __file__
+ # Since Python 3.12, we let Python figure out the stacklevel using the
+ # `skip_file_prefixes` argument, which cannot exempt tests, so just confirm
+ # the filename is not in the package.
+ basedir = pathlib.Path(__file__).parents[2]
+ assert not recwarn[0].filename.startswith((str(basedir / 'matplotlib'),
+ str(basedir / 'mpl_toolkits')))
+
def test_warn_external_frame_embedded_python():
diff --git a/lib/matplotlib/tests/test_pickle.py b/lib/matplotlib/tests/test_pickle.py
index 3494dceffe5d..27111aa29030 100644
--- a/lib/matplotlib/tests/test_pickle.py
+++ b/lib/matplotlib/tests/test_pickle.py
@@ -1,7 +1,6 @@
from io import BytesIO
import ast
import os
-import sys
import pickle
import pickletools
@@ -124,7 +123,6 @@ def test_complete(fig_test, fig_ref):
def _pickle_load_subprocess():
- import os
import pickle
path = os.environ['PICKLE_FILE_PATH']
@@ -318,11 +316,6 @@ def _test_axeswidget_interactive():
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,
diff --git a/pyproject.toml b/pyproject.toml
index eef7f82fb810..5232a6bdd5aa 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -16,7 +16,6 @@ classifiers=[
"License :: OSI Approved :: Python Software Foundation License",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
- "Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
@@ -42,7 +41,7 @@ dependencies = [
"python-dateutil >= 2.7",
]
# Also keep in sync with find_program of meson.build.
-requires-python = ">=3.11"
+requires-python = ">=3.12"
[project.urls]
"Homepage" = "https://matplotlib.org"
diff --git a/tox.ini b/tox.ini
index 956e4050cfa9..64c54ed88725 100644
--- a/tox.ini
+++ b/tox.ini
@@ -4,7 +4,7 @@
# and then run "tox" from this directory.
[tox]
-envlist = py311, py312, py313, stubtest
+envlist = py312, py313, py314, stubtest
[testenv]
changedir = /tmp
From 748713eb353a18eb22084205416af0b64b2493b4 Mon Sep 17 00:00:00 2001
From: Ruth Comer <10599679+rcomer@users.noreply.github.com>
Date: Sat, 23 May 2026 16:42:04 +0100
Subject: [PATCH 2/4] MNT: bump minimum numpy to 2.0
---
ci/minver-requirements.txt | 4 ++--
doc/devel/min_dep_policy.rst | 2 +-
doc/install/dependencies.rst | 4 ++--
environment.yml | 4 ++--
galleries/examples/units/basic_units.py | 7 ++-----
lib/matplotlib/tests/test_axes.py | 3 ---
lib/matplotlib/tests/test_contour.py | 4 +---
lib/matplotlib/tests/test_ticker.py | 7 ++-----
lib/mpl_toolkits/mplot3d/tests/test_axes3d.py | 4 +---
pyproject.toml | 8 ++++----
10 files changed, 17 insertions(+), 30 deletions(-)
diff --git a/ci/minver-requirements.txt b/ci/minver-requirements.txt
index 3b6aea9e7ca3..6fdcbe5cfafc 100644
--- a/ci/minver-requirements.txt
+++ b/ci/minver-requirements.txt
@@ -1,13 +1,13 @@
# Extra pip requirements for the minimum-version CI run
-contourpy==1.0.1
+contourpy==1.2.1
cycler==0.10
fonttools==4.22.0
importlib-resources==3.2.0
kiwisolver==1.3.2
meson-python==0.13.2
meson==1.1.0
-numpy==1.25.0
+numpy==2.0.0
packaging==20.0
pillow==9.0.1
pyparsing==3.0.0
diff --git a/doc/devel/min_dep_policy.rst b/doc/devel/min_dep_policy.rst
index d8e4902fda6c..f0dc0438c8e4 100644
--- a/doc/devel/min_dep_policy.rst
+++ b/doc/devel/min_dep_policy.rst
@@ -115,7 +115,7 @@ specification of the dependencies.
========== ======== ======
Matplotlib Python NumPy
========== ======== ======
-3.12 3.12 1.25.0
+3.12 3.12 2.0.0
3.11 3.11 1.25.0
`3.10`_ 3.10 1.23.0
`3.9`_ 3.9 1.23.0
diff --git a/doc/install/dependencies.rst b/doc/install/dependencies.rst
index ad5187cd2d86..1b61a8591a5c 100644
--- a/doc/install/dependencies.rst
+++ b/doc/install/dependencies.rst
@@ -21,12 +21,12 @@ mandatory dependencies are automatically installed. This list is mainly for
reference.
* `Python `_ (>= 3.12)
-* `contourpy `_ (>= 1.0.1)
+* `contourpy `_ (>= 1.2.1)
* `cycler `_ (>= 0.10.0)
* `dateutil `_ (>= 2.7)
* `fontTools `_ (>= 4.22.0)
* `kiwisolver `_ (>= 1.3.1)
-* `NumPy `_ (>= 1.25)
+* `NumPy `_ (>= 2.0)
* `packaging `_ (>= 20.0)
* `Pillow `_ (>= 9.0)
* `pyparsing `_ (>= 3)
diff --git a/environment.yml b/environment.yml
index c546409449fd..068bb10588db 100644
--- a/environment.yml
+++ b/environment.yml
@@ -13,14 +13,14 @@ dependencies:
- cairocffi
- c-compiler
- cxx-compiler
- - contourpy>=1.0.1
+ - contourpy>=1.2.1
- cycler>=0.10.0
- fonttools>=4.22.0
- importlib-resources>=3.2.0
- kiwisolver>=1.3.1
- pybind11>=2.13.2
- meson-python>=0.13.1
- - numpy>=1.25
+ - numpy>=2.0
- pillow>=9
- pkg-config
- pygobject
diff --git a/galleries/examples/units/basic_units.py b/galleries/examples/units/basic_units.py
index f7bdcc18b0dc..fe60f44d3677 100644
--- a/galleries/examples/units/basic_units.py
+++ b/galleries/examples/units/basic_units.py
@@ -18,8 +18,6 @@
import itertools
import math
-from packaging.version import parse as parse_version
-
import numpy as np
import matplotlib.ticker as ticker
@@ -170,9 +168,8 @@ def __str__(self):
def __len__(self):
return len(self.value)
- if parse_version(np.__version__) >= parse_version('1.20'):
- def __getitem__(self, key):
- return TaggedValue(self.value[key], self.unit)
+ def __getitem__(self, key):
+ return TaggedValue(self.value[key], self.unit)
def __iter__(self):
# Return a generator expression rather than use `yield`, so that
diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py
index 209593aee15e..43babac20897 100644
--- a/lib/matplotlib/tests/test_axes.py
+++ b/lib/matplotlib/tests/test_axes.py
@@ -1609,9 +1609,6 @@ def test_pcolor_log_scale(fig_test, fig_ref):
when using pcolor.
"""
x = np.linspace(0, 1, 11)
- # Ensuring second x value always falls slightly above 0.1 prevents flakiness with
- # numpy v1 #30882. This can be removed once we require numpy >= 2.
- x[1] += 0.00001
y = np.linspace(1, 2, 5)
X, Y = np.meshgrid(x, y)
C = X[:-1, :-1] + Y[:-1, :-1]
diff --git a/lib/matplotlib/tests/test_contour.py b/lib/matplotlib/tests/test_contour.py
index 3c810b026fce..53921e70046c 100644
--- a/lib/matplotlib/tests/test_contour.py
+++ b/lib/matplotlib/tests/test_contour.py
@@ -11,7 +11,6 @@
from matplotlib.colors import LogNorm, same_color
import matplotlib.patches as mpatches
from matplotlib.testing.decorators import check_figures_equal, image_comparison
-from packaging.version import parse as parse_version
import pytest
@@ -257,8 +256,7 @@ def test_contour_datetime_axis():
@image_comparison(['contour_test_label_transforms.png'],
remove_text=True, style='mpl20',
- tol=1 if parse_version(np.version.version).major < 2 else
- 0 if platform.machine() == 'x86_64' else 0.005)
+ tol=0 if platform.machine() == 'x86_64' else 0.005)
def test_labels():
# Adapted from pylab_examples example code: contour_demo.py
# see issues #2475, #2843, and #2818 for explanation
diff --git a/lib/matplotlib/tests/test_ticker.py b/lib/matplotlib/tests/test_ticker.py
index 0d01677acc8c..c595e9989822 100644
--- a/lib/matplotlib/tests/test_ticker.py
+++ b/lib/matplotlib/tests/test_ticker.py
@@ -6,7 +6,7 @@
from packaging.version import parse as parse_version
import numpy as np
-from numpy.testing import assert_almost_equal, assert_array_equal, assert_allclose
+from numpy.testing import assert_almost_equal, assert_array_equal
import pytest
import matplotlib as mpl
@@ -1951,10 +1951,7 @@ def test_bad_locator_subs(sub):
@mpl.style.context('default')
def test_small_range_loglocator(numticks, lims, ticks):
ll = mticker.LogLocator(numticks=numticks)
- if parse_version(np.version.version).major < 2:
- assert_allclose(ll.tick_values(*lims), ticks, rtol=2e-16)
- else:
- assert_array_equal(ll.tick_values(*lims), ticks)
+ assert_array_equal(ll.tick_values(*lims), ticks)
@mpl.style.context('default')
diff --git a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py
index 2a5593a641c9..4baea580df47 100644
--- a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py
+++ b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py
@@ -3,7 +3,6 @@
import platform
import sys
-from packaging.version import parse as parse_version
import pytest
from mpl_toolkits.mplot3d import Axes3D, axes3d, proj3d, art3d
@@ -182,8 +181,7 @@ def test_bar3d_shaded():
fig.canvas.draw()
-@mpl3d_image_comparison(['bar3d_notshaded.png'], style='mpl20',
- tol=0.01 if parse_version(np.version.version).major < 2 else 0)
+@mpl3d_image_comparison(['bar3d_notshaded.png'], style='mpl20')
def test_bar3d_notshaded():
fig = plt.figure()
ax = fig.add_subplot(projection='3d')
diff --git a/pyproject.toml b/pyproject.toml
index 5232a6bdd5aa..1a5d42c1b782 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -30,11 +30,11 @@ classifiers=[
# - doc/devel/dependencies.rst
# - environment.yml
dependencies = [
- "contourpy >= 1.0.1",
+ "contourpy >= 1.2.1",
"cycler >= 0.10",
"fonttools >= 4.22.0",
"kiwisolver >= 1.3.1",
- "numpy >= 1.25",
+ "numpy >= 2.0",
"packaging >= 20.0",
"pillow >= 9",
"pyparsing >= 3",
@@ -68,11 +68,11 @@ requires = [
[dependency-groups]
build = [
# Should be the same as `[project] dependencies` above.
- "contourpy >= 1.0.1",
+ "contourpy >= 1.2.1",
"cycler >= 0.10",
"fonttools >= 4.22.0",
"kiwisolver >= 1.3.1",
- "numpy >= 1.25",
+ "numpy >= 2.0",
"packaging >= 20.0",
"pillow >= 9",
"pyparsing >= 3",
From ec53e3623733eaf9ae3ba69fcf18f03943a02434 Mon Sep 17 00:00:00 2001
From: Ruth Comer <10599679+rcomer@users.noreply.github.com>
Date: Mon, 25 May 2026 10:30:08 +0100
Subject: [PATCH 3/4] MNT: remove pyside2 support
pyside2 does not support python 3.12+
---
.github/workflows/cygwin.yml | 4 ----
doc/api/backend_qt_api.rst | 19 ++++++++--------
doc/install/dependencies.rst | 4 +---
.../user_interfaces/embedding_in_qt_sgskip.py | 2 +-
galleries/examples/user_interfaces/mplcvd.py | 2 +-
galleries/users_explain/figure/backends.rst | 2 +-
lib/matplotlib/backends/backend_qt.py | 9 +++-----
lib/matplotlib/backends/backend_qtagg.py | 6 -----
lib/matplotlib/backends/backend_qtcairo.py | 7 +-----
lib/matplotlib/backends/qt_compat.py | 22 ++++---------------
lib/matplotlib/cbook.py | 1 -
lib/matplotlib/tests/test_backend_qt.py | 2 +-
.../tests/test_backends_interactive.py | 18 ++++++---------
13 files changed, 29 insertions(+), 69 deletions(-)
diff --git a/.github/workflows/cygwin.yml b/.github/workflows/cygwin.yml
index 4b790ea52a7d..8e970ea1120c 100644
--- a/.github/workflows/cygwin.yml
+++ b/.github/workflows/cygwin.yml
@@ -186,10 +186,6 @@ jobs:
python -c 'import PyQt5.QtCore' &&
echo 'PyQt5 is available' ||
echo 'PyQt5 is not available'
- python -mpip install --upgrade pyside2 &&
- python -c 'import PySide2.QtCore' &&
- echo 'PySide2 is available' ||
- echo 'PySide2 is not available'
python -m pip uninstall --yes wxpython || echo 'wxPython already uninstalled'
- name: Install Matplotlib
diff --git a/doc/api/backend_qt_api.rst b/doc/api/backend_qt_api.rst
index ebfeedceb6e1..2f950adb672a 100644
--- a/doc/api/backend_qt_api.rst
+++ b/doc/api/backend_qt_api.rst
@@ -22,10 +22,9 @@ a dependency to building the docs.
Qt Bindings
-----------
-There are currently 2 actively supported Qt versions, Qt5 and Qt6, and two
-supported Python bindings per version -- `PyQt5
-`_ and `PySide2
-`_ for Qt5 and `PyQt6
+There are currently 2 actively supported Qt versions, Qt5 and Qt6. `PyQt5
+`_ is the supported
+Python binding for Qt5 and there are both `PyQt6
`_ and `PySide6
`_ for Qt6 [#]_. Matplotlib's
qtagg and qtcairo backends (``matplotlib.backends.backend_qtagg`` and
@@ -35,13 +34,12 @@ parts factored out in the ``matplotlib.backends.backend_qt`` module.
At runtime, these backends select the actual binding used as follows:
1. If a binding's ``QtCore`` subpackage is already imported, that binding is
- selected (the order for the check is ``PyQt6``, ``PySide6``, ``PyQt5``,
- ``PySide2``).
+ selected (the order for the check is ``PyQt6``, ``PySide6``, ``PyQt5``).
2. If the :envvar:`QT_API` environment variable is set to one of "PyQt6",
- "PySide6", "PyQt5", "PySide2" (case-insensitive), that binding is selected.
+ "PySide6", "PyQt5" (case-insensitive), that binding is selected.
(See also the documentation on :ref:`environment-variables`.)
3. Otherwise, the first available backend in the order ``PyQt6``, ``PySide6``,
- ``PyQt5``, ``PySide2`` is selected.
+ ``PyQt5`` is selected.
In the past, Matplotlib used to have separate backends for each version of Qt
(e.g. qt4agg/``matplotlib.backends.backend_qt4agg`` and
@@ -62,8 +60,9 @@ change without warning [#]_.
.. [#] There is also `PyQt4
`_ and `PySide
- `_ for Qt4 but these are no
- longer supported by Matplotlib and upstream support for Qt4 ended
+ `_ for Qt4 and `PySide2
+ `_ for Qt5 but these are
+ no longer supported by Matplotlib. Upstream support for Qt4 ended
in 2015.
.. [#] Despite the slight API differences, the more important distinction
between the PyQt and Qt for Python series of bindings is licensing.
diff --git a/doc/install/dependencies.rst b/doc/install/dependencies.rst
index 1b61a8591a5c..319b60cb4fb8 100644
--- a/doc/install/dependencies.rst
+++ b/doc/install/dependencies.rst
@@ -62,8 +62,7 @@ and the capabilities they provide.
* Tk_ (>= 8.5, != 8.6.0 or 8.6.1): for the Tk-based backends. Tk is part of
most standard Python installations, but it's not part of Python itself and
thus may not be present in rare cases.
-* PyQt6_ (>= 6.1), PySide6_, PyQt5_ (>= 5.12), or PySide2_: for the Qt-based
- backends.
+* PyQt6_ (>= 6.1), PySide6_, or PyQt5_ (>= 5.12): for the Qt-based backends.
* PyGObject_ and pycairo_ (>= 1.14.0): for the GTK-based backends. If using pip
(but not conda or system package manager) PyGObject must be built from
source; see `pygobject documentation
@@ -78,7 +77,6 @@ and the capabilities they provide.
.. _Tk: https://docs.python.org/3/library/tk.html
.. _PyQt5: https://pypi.org/project/PyQt5/
-.. _PySide2: https://pypi.org/project/PySide2/
.. _PyQt6: https://pypi.org/project/PyQt6/
.. _PySide6: https://pypi.org/project/PySide6/
.. _PyGObject: https://pygobject.readthedocs.io/en/latest/
diff --git a/galleries/examples/user_interfaces/embedding_in_qt_sgskip.py b/galleries/examples/user_interfaces/embedding_in_qt_sgskip.py
index c19d24ff163d..98bf38f4fafe 100644
--- a/galleries/examples/user_interfaces/embedding_in_qt_sgskip.py
+++ b/galleries/examples/user_interfaces/embedding_in_qt_sgskip.py
@@ -4,7 +4,7 @@
===========
Simple Qt application embedding Matplotlib canvases. This program will work
-equally well using any Qt binding (PyQt6, PySide6, PyQt5, PySide2). The
+equally well using any Qt binding (PyQt6, PySide6, PyQt5). The
binding can be selected by setting the :envvar:`QT_API` environment variable to
the binding name, or by first importing it.
"""
diff --git a/galleries/examples/user_interfaces/mplcvd.py b/galleries/examples/user_interfaces/mplcvd.py
index 967cb7a38779..88cdd0c647e0 100644
--- a/galleries/examples/user_interfaces/mplcvd.py
+++ b/galleries/examples/user_interfaces/mplcvd.py
@@ -104,7 +104,7 @@ def setup(figure):
break
if pkg == "gi":
_setup_gtk(tb)
- elif pkg in ("PyQt5", "PySide2", "PyQt6", "PySide6"):
+ elif pkg in ("PyQt5", "PyQt6", "PySide6"):
_setup_qt(tb)
elif pkg == "tkinter":
_setup_tk(tb)
diff --git a/galleries/users_explain/figure/backends.rst b/galleries/users_explain/figure/backends.rst
index 69f6d61dc563..98cf6740cf21 100644
--- a/galleries/users_explain/figure/backends.rst
+++ b/galleries/users_explain/figure/backends.rst
@@ -321,7 +321,7 @@ program that can be run to test basic functionality. If this test fails, try re
QtAgg, QtCairo, Qt5Agg, and Qt5Cairo
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
-Test ``PyQt6`` (if you have ``PyQt5``, ``PySide2`` or ``PySide6`` installed
+Test ``PyQt6`` (if you have ``PyQt5`` or ``PySide6`` installed
rather than ``PyQt6``, just change the import accordingly):
.. code-block:: bash
diff --git a/lib/matplotlib/backends/backend_qt.py b/lib/matplotlib/backends/backend_qt.py
index 58a1468b6b33..d7d8726c12f4 100644
--- a/lib/matplotlib/backends/backend_qt.py
+++ b/lib/matplotlib/backends/backend_qt.py
@@ -104,9 +104,9 @@ def _create_qApp():
# Check to make sure a QApplication from a different major version
# of Qt is not instantiated in the process
if QT_API in {'PyQt6', 'PySide6'}:
- other_bindings = ('PyQt5', 'PySide2')
+ other_bindings = ('PyQt5',)
qt_version = 6
- elif QT_API in {'PyQt5', 'PySide2'}:
+ elif QT_API == 'PyQt5':
other_bindings = ('PyQt6', 'PySide6')
qt_version = 5
else:
@@ -195,10 +195,7 @@ def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def __del__(self):
- # The check for deletedness is needed to avoid an error at animation
- # shutdown with PySide2.
- if not _isdeleted(self._timer):
- self._timer_stop()
+ self._timer_stop()
def _timer_set_single_shot(self):
self._timer.setSingleShot(self._single)
diff --git a/lib/matplotlib/backends/backend_qtagg.py b/lib/matplotlib/backends/backend_qtagg.py
index 256e50a3d1c3..54efb134c2b1 100644
--- a/lib/matplotlib/backends/backend_qtagg.py
+++ b/lib/matplotlib/backends/backend_qtagg.py
@@ -2,7 +2,6 @@
Render to qt from agg.
"""
-import ctypes
from matplotlib.transforms import Bbox
@@ -62,11 +61,6 @@ def paintEvent(self, event):
# set origin using original QT coordinates
origin = QtCore.QPoint(rect.left(), rect.top())
painter.drawImage(origin, qimage)
- # Adjust the buf reference count to work around a memory
- # leak bug in QImage under PySide.
- if QT_API == "PySide2" and QtCore.__version_info__ < (5, 12):
- ctypes.c_long.from_address(id(buf)).value = 1
-
self._draw_rect_callback(painter)
finally:
painter.end()
diff --git a/lib/matplotlib/backends/backend_qtcairo.py b/lib/matplotlib/backends/backend_qtcairo.py
index 72eb2dc70b90..866f16e3ae5b 100644
--- a/lib/matplotlib/backends/backend_qtcairo.py
+++ b/lib/matplotlib/backends/backend_qtcairo.py
@@ -1,8 +1,7 @@
-import ctypes
from .backend_cairo import cairo, FigureCanvasCairo
from .backend_qt import _BackendQT, FigureCanvasQT
-from .qt_compat import QT_API, QtCore, QtGui
+from .qt_compat import QT_API, QtGui
class FigureCanvasQTCairo(FigureCanvasCairo, FigureCanvasQT):
@@ -29,10 +28,6 @@ def paintEvent(self, event):
qimage = QtGui.QImage(
ptr, width, height,
QtGui.QImage.Format.Format_ARGB32_Premultiplied)
- # Adjust the buf reference count to work around a memory leak bug in
- # QImage under PySide.
- if QT_API == "PySide2" and QtCore.__version_info__ < (5, 12):
- ctypes.c_long.from_address(id(buf)).value = 1
qimage.setDevicePixelRatio(self.device_pixel_ratio)
painter = QtGui.QPainter(self)
painter.eraseRect(event.rect())
diff --git a/lib/matplotlib/backends/qt_compat.py b/lib/matplotlib/backends/qt_compat.py
index 8f666c734b06..382c4529b5ca 100644
--- a/lib/matplotlib/backends/qt_compat.py
+++ b/lib/matplotlib/backends/qt_compat.py
@@ -2,7 +2,7 @@
Qt binding and backend selector.
The selection logic is as follows:
-- if any of PyQt6, PySide6, PyQt5, or PySide2 have already been
+- if any of PyQt6, PySide6, or PyQt5 have already been
imported (checked in that order), use it;
- otherwise, if the QT_API environment variable (used by Enthought) is set, use
it to determine which binding to use;
@@ -23,13 +23,12 @@
QT_API_PYQT6 = "PyQt6"
QT_API_PYSIDE6 = "PySide6"
QT_API_PYQT5 = "PyQt5"
-QT_API_PYSIDE2 = "PySide2"
QT_API_ENV = os.environ.get("QT_API")
if QT_API_ENV is not None:
QT_API_ENV = QT_API_ENV.lower()
_ETS = { # Mapping of QT_API_ENV to requested binding.
"pyqt6": QT_API_PYQT6, "pyside6": QT_API_PYSIDE6,
- "pyqt5": QT_API_PYQT5, "pyside2": QT_API_PYSIDE2,
+ "pyqt5": QT_API_PYQT5,
}
# First, check if anything is already imported.
if sys.modules.get("PyQt6.QtCore"):
@@ -38,15 +37,13 @@
QT_API = QT_API_PYSIDE6
elif sys.modules.get("PyQt5.QtCore"):
QT_API = QT_API_PYQT5
-elif sys.modules.get("PySide2.QtCore"):
- QT_API = QT_API_PYSIDE2
# Otherwise, check the QT_API environment variable (from Enthought). This can
# only override the binding, not the backend (in other words, we check that the
# requested backend actually matches). Use _get_backend_or_none to avoid
# triggering backend resolution (which can result in a partially but
# incompletely imported backend_qt5).
elif (mpl.rcParams._get_backend_or_none() or "").lower().startswith("qt5"):
- if QT_API_ENV in ["pyqt5", "pyside2"]:
+ if QT_API_ENV == "pyqt5":
QT_API = _ETS[QT_API_ENV]
else:
_QT_FORCE_QT5_BINDING = True # noqa: F811
@@ -92,33 +89,22 @@ def _isdeleted(obj): return not shiboken6.isValid(obj)
QtCore.Property = QtCore.pyqtProperty
_isdeleted = sip.isdeleted
_to_int = int
- elif QT_API == QT_API_PYSIDE2:
- from PySide2 import QtCore, QtGui, QtWidgets, QtSvg, __version__
- try:
- from PySide2 import shiboken2
- except ImportError:
- import shiboken2
- def _isdeleted(obj):
- return not shiboken2.isValid(obj)
- _to_int = int
else:
raise AssertionError(f"Unexpected QT_API: {QT_API}")
-if QT_API in [QT_API_PYQT6, QT_API_PYQT5, QT_API_PYSIDE6, QT_API_PYSIDE2]:
+if QT_API in [QT_API_PYQT6, QT_API_PYQT5, QT_API_PYSIDE6]:
_setup_pyqt5plus()
elif QT_API is None: # See above re: dict.__getitem__.
if _QT_FORCE_QT5_BINDING:
_candidates = [
(_setup_pyqt5plus, QT_API_PYQT5),
- (_setup_pyqt5plus, QT_API_PYSIDE2),
]
else:
_candidates = [
(_setup_pyqt5plus, QT_API_PYQT6),
(_setup_pyqt5plus, QT_API_PYSIDE6),
(_setup_pyqt5plus, QT_API_PYQT5),
- (_setup_pyqt5plus, QT_API_PYSIDE2),
]
for _setup, QT_API in _candidates:
try:
diff --git a/lib/matplotlib/cbook.py b/lib/matplotlib/cbook.py
index ef7f88db50d3..ffb8c730f611 100644
--- a/lib/matplotlib/cbook.py
+++ b/lib/matplotlib/cbook.py
@@ -76,7 +76,6 @@ def _get_running_interactive_framework():
sys.modules.get("PyQt6.QtWidgets")
or sys.modules.get("PySide6.QtWidgets")
or sys.modules.get("PyQt5.QtWidgets")
- or sys.modules.get("PySide2.QtWidgets")
)
if QtWidgets and QtWidgets.QApplication.instance():
return "qt"
diff --git a/lib/matplotlib/tests/test_backend_qt.py b/lib/matplotlib/tests/test_backend_qt.py
index fda0f978ea02..ae24effe505f 100644
--- a/lib/matplotlib/tests/test_backend_qt.py
+++ b/lib/matplotlib/tests/test_backend_qt.py
@@ -306,7 +306,7 @@ def _get_testable_qt_backends():
envs = []
for deps, env in [
([qt_api], {"MPLBACKEND": "qtagg", "QT_API": qt_api})
- for qt_api in ["PyQt6", "PySide6", "PyQt5", "PySide2"]
+ for qt_api in ["PyQt6", "PySide6", "PyQt5"]
]:
reason = None
missing = [dep for dep in deps if not importlib.util.find_spec(dep)]
diff --git a/lib/matplotlib/tests/test_backends_interactive.py b/lib/matplotlib/tests/test_backends_interactive.py
index 3da5f708cb28..2e2713f15ee1 100644
--- a/lib/matplotlib/tests/test_backends_interactive.py
+++ b/lib/matplotlib/tests/test_backends_interactive.py
@@ -63,10 +63,10 @@ def _get_available_interactive_backends():
for deps, env in [
*[([qt_api],
{"MPLBACKEND": "qtagg", "QT_API": qt_api})
- for qt_api in ["PyQt6", "PySide6", "PyQt5", "PySide2"]],
+ for qt_api in ["PyQt6", "PySide6", "PyQt5"]],
*[([qt_api, "cairocffi"],
{"MPLBACKEND": "qtcairo", "QT_API": qt_api})
- for qt_api in ["PyQt6", "PySide6", "PyQt5", "PySide2"]],
+ for qt_api in ["PyQt6", "PySide6", "PyQt5"]],
*[(["cairo", "gi"], {"MPLBACKEND": f"gtk{version}{renderer}"})
for version in [3, 4] for renderer in ["agg", "cairo"]],
(["tkinter"], {"MPLBACKEND": "tkagg"}),
@@ -317,9 +317,6 @@ def _test_thread_impl():
param.marks.append(
pytest.mark.xfail(raises=subprocess.TimeoutExpired,
strict=True))
- elif param.values[0].get("QT_API") == "PySide2":
- param.marks.append(
- pytest.mark.xfail(raises=subprocess.CalledProcessError))
elif backend == "tkagg" and platform.python_implementation() != 'CPython':
param.marks.append(
pytest.mark.xfail(
@@ -360,7 +357,7 @@ def _implqt5agg():
assert 'PyQt6' not in sys.modules
assert 'pyside6' not in sys.modules
- assert 'PyQt5' in sys.modules or 'pyside2' in sys.modules
+ assert 'PyQt5' in sys.modules
def _implcairo():
@@ -369,7 +366,7 @@ def _implcairo():
assert 'PyQt6' not in sys.modules
assert 'pyside6' not in sys.modules
- assert 'PyQt5' in sys.modules or 'pyside2' in sys.modules
+ assert 'PyQt5' in sys.modules
def _implcore():
@@ -378,12 +375,12 @@ def _implcore():
assert 'PyQt6' not in sys.modules
assert 'pyside6' not in sys.modules
- assert 'PyQt5' in sys.modules or 'pyside2' in sys.modules
+ assert 'PyQt5' in sys.modules
def test_qt5backends_uses_qt5():
qt5_bindings = [
- dep for dep in ['PyQt5', 'pyside2']
+ dep for dep in ['PyQt5']
if importlib.util.find_spec(dep) is not None
]
qt6_bindings = [
@@ -403,7 +400,6 @@ def _impl_missing():
# Simulate uninstalled
sys.modules["PyQt6"] = None
sys.modules["PyQt5"] = None
- sys.modules["PySide2"] = None
sys.modules["PySide6"] = None
import matplotlib.pyplot as plt
@@ -438,7 +434,7 @@ def _impl_test_cross_Qt_imports():
def qt5_and_qt6_pairs():
qt5_bindings = [
- dep for dep in ['PyQt5', 'PySide2']
+ dep for dep in ['PyQt5']
if importlib.util.find_spec(dep) is not None
]
qt6_bindings = [
From 1549484ef28735c5ef79d8f704f275ee77980499 Mon Sep 17 00:00:00 2001
From: Ruth Comer <10599679+rcomer@users.noreply.github.com>
Date: Sat, 23 May 2026 15:14:55 +0100
Subject: [PATCH 4/4] Introduce ArtistList for FigureBase
Following #18216 for Axes artists, combine all figure artists except
axes and subfigures into a single list and deprecate modifying the lists
directly.
---
.../deprecations/XXXXX_REC.rst | 7 +
galleries/tutorials/artists.py | 40 +++---
lib/matplotlib/_api/__init__.py | 9 +-
lib/matplotlib/artist.py | 70 ++++++++++
lib/matplotlib/axes/_base.py | 71 +---------
lib/matplotlib/figure.py | 131 ++++++++++++++----
lib/matplotlib/tests/test_bbox_tight.py | 2 +-
lib/matplotlib/tests/test_cbook.py | 24 ++++
lib/matplotlib/tests/test_figure.py | 69 ++++++++-
lib/matplotlib/tests/test_legend.py | 2 +-
10 files changed, 308 insertions(+), 117 deletions(-)
create mode 100644 doc/api/next_api_changes/deprecations/XXXXX_REC.rst
diff --git a/doc/api/next_api_changes/deprecations/XXXXX_REC.rst b/doc/api/next_api_changes/deprecations/XXXXX_REC.rst
new file mode 100644
index 000000000000..8fd479dd2d8a
--- /dev/null
+++ b/doc/api/next_api_changes/deprecations/XXXXX_REC.rst
@@ -0,0 +1,7 @@
+Direct modification of ``(Sub)Figure`` artist lists
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+Previously it was possible to modify the ``artists``, ``images``, ``lines``,
+``legends``, ``patches`` and ``texts`` attributes of `.Figure` and `.SubFigure`
+instances using standard `list` functionality. This is now deprecated.
+Instead use `~.Figure.add_artist` to add an artist to the figure, or use the
+artist's `remove` method to remove it.
diff --git a/galleries/tutorials/artists.py b/galleries/tutorials/artists.py
index b3440d71fe7f..5e4d6adf50d4 100644
--- a/galleries/tutorials/artists.py
+++ b/galleries/tutorials/artists.py
@@ -314,24 +314,26 @@ class in the Matplotlib API, and the one you will be working with most
#
#
# The figure also has its own ``images``, ``lines``, ``patches`` and ``text``
-# attributes, which you can use to add primitives directly. When doing so, the
-# default coordinate system for the ``Figure`` will simply be in pixels (which
-# is not usually what you want). If you instead use Figure-level methods to add
-# Artists (e.g., using `.Figure.text` to add text), then the default coordinate
-# system will be "figure coordinates" where (0, 0) is the bottom-left of the
-# figure and (1, 1) is the top-right of the figure.
-#
-# As with all ``Artist``\s, you can control this coordinate system by setting
-# the transform property. You can explicitly use "figure coordinates" by
-# setting the ``Artist`` transform to :attr:`!fig.transFigure`:
+# attributes, which you can use to access any primitives that are its direct
+# children. Adding images and text is usually achieved with the
+# `~.Figure.figimage` and `~.Figure.text` methods. Other artists may be added
+# with the `~.Figure.add_artist` method.
+#
+# As with all ``Artist``\s, you can control the coordinate system by setting
+# the transform property (see :ref:`transforms_tutorial`). When using
+# `~.Figure.figimage`, the default coordinate system is simply pixels. When
+# using `~.Figure.text` or `~.Figure.add_artist`, the default coordinate system
+# will be "figure coordinates" where (0, 0) is the bottom-left of the figure
+# and (1, 1) is the top-right of the figure.
import matplotlib.lines as lines
fig = plt.figure()
-l1 = lines.Line2D([0, 1], [0, 1], transform=fig.transFigure, figure=fig)
-l2 = lines.Line2D([0, 1], [1, 0], transform=fig.transFigure, figure=fig)
-fig.lines.extend([l1, l2])
+line1 = lines.Line2D([0, 1], [0, 1])
+line2 = lines.Line2D([0, 1], [1, 0])
+for line in line1, line2:
+ fig.add_artist(line)
plt.show()
@@ -342,16 +344,18 @@ class in the Matplotlib API, and the one you will be working with most
# Figure attribute Description
# ================ ============================================================
# axes A list of `~.axes.Axes` instances
+# subfigures A list of `.SubFigure` instances
# patch The `.Rectangle` background
-# images A list of `.FigureImage` patches -
+# images An `.ArtistList` of `.FigureImage` patches -
# useful for raw pixel display
-# legends A list of Figure `.Legend` instances
+# legends An `.ArtistList` of Figure `.Legend` instances
# (different from ``Axes.get_legend()``)
-# lines A list of Figure `.Line2D` instances
+# lines An `.ArtistList` of Figure `.Line2D` instances
# (rarely used, see ``Axes.lines``)
-# patches A list of Figure `.Patch`\s
+# patches An `.ArtistList` of Figure `.Patch`\s
# (rarely used, see ``Axes.patches``)
-# texts A list Figure `.Text` instances
+# texts An `.ArtistList` of Figure `.Text` instances
+# artists An `.ArtistList` of all other `.Artist` instances
# ================ ============================================================
#
# .. _axes-container:
diff --git a/lib/matplotlib/_api/__init__.py b/lib/matplotlib/_api/__init__.py
index d164f7f6d12a..4fff1e958123 100644
--- a/lib/matplotlib/_api/__init__.py
+++ b/lib/matplotlib/_api/__init__.py
@@ -470,7 +470,12 @@ def warn_external(message, category=None):
"""
# Go to Python's `site-packages` or `lib` from an editable install.
basedir = pathlib.Path(__file__).parents[2]
- skip_file_prefixes = (str(basedir / 'matplotlib'),
- str(basedir / 'mpl_toolkits'))
+ skip_file_prefixes = (
+ str(basedir / 'matplotlib'),
+ str(basedir / 'mpl_toolkits'),
+ # If we subclass a collections.abc class, the user may call an abc method that
+ # calls our method. For example if we warn within insert on a MutableSequence,
+ # and the user calls append or extend.
+ '')
warnings.warn(message, category, skip_file_prefixes=skip_file_prefixes)
diff --git a/lib/matplotlib/artist.py b/lib/matplotlib/artist.py
index 88e38634b5b1..e7dfee245c10 100644
--- a/lib/matplotlib/artist.py
+++ b/lib/matplotlib/artist.py
@@ -1,4 +1,5 @@
from collections import namedtuple
+from collections.abc import Sequence
import contextlib
from functools import cache, reduce, wraps
import inspect
@@ -1789,6 +1790,75 @@ def pprint_getters(self):
return lines
+class ArtistList(Sequence):
+ """
+ A sublist of Axes or Figure children based on their type.
+
+ The Axes' type-specific children sublists were made immutable in Matplotlib
+ 3.7. In the future these artist lists may be replaced by tuples. Use
+ as if this is a tuple already.
+ """
+ def __init__(self, parent, prop_name, valid_types=None, invalid_types=None):
+ """
+ Parameters
+ ----------
+ parent : `~matplotlib.axes.Axes` or `~matplotlib.figure.FigureBase`
+ The Axes or (Sub)Figure from which this sublist will pull the children
+ Artists.
+ prop_name : str
+ The property name used to access this sublist from the parent;
+ used to generate deprecation warnings.
+ valid_types : list of type, optional
+ A list of types that determine which children will be returned
+ by this sublist. If specified, then the Artists in the sublist
+ must be instances of any of these types. If unspecified, then
+ any type of Artist is valid (unless limited by
+ *invalid_types*.)
+ invalid_types : tuple, optional
+ A list of types that determine which children will *not* be
+ returned by this sublist. If specified, then Artists in the
+ sublist will never be an instance of these types. Otherwise, no
+ types will be excluded.
+ """
+ self._parent = parent
+ self._prop_name = prop_name
+ self._type_check = lambda artist: (
+ (not valid_types or isinstance(artist, valid_types)) and
+ (not invalid_types or not isinstance(artist, invalid_types))
+ )
+
+ def __repr__(self):
+ parent_type = self._parent.__class__.__name__
+ return f'<{parent_type}.ArtistList of {len(self)} {self._prop_name}>'
+
+ def __len__(self):
+ return sum(self._type_check(artist) for artist in self._parent._children)
+
+ def __iter__(self):
+ for artist in list(self._parent._children):
+ if self._type_check(artist):
+ yield artist
+
+ def __getitem__(self, key):
+ return [artist
+ for artist in self._parent._children
+ if self._type_check(artist)][key]
+
+ def __add__(self, other):
+ if isinstance(other, (list, ArtistList)):
+ return [*self, *other]
+ if isinstance(other, (tuple, ArtistList)):
+ return (*self, *other)
+ return NotImplemented
+
+ def __radd__(self, other):
+ if isinstance(other, list):
+ return other + list(self)
+ if isinstance(other, tuple):
+ return other + tuple(self)
+ return NotImplemented
+
+
def getp(obj, property=None):
"""
Return the value of an `.Artist`'s *property*, or print all of them.
diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py
index d2589cfc74d3..457b34c39ce4 100644
--- a/lib/matplotlib/axes/_base.py
+++ b/lib/matplotlib/axes/_base.py
@@ -1,4 +1,4 @@
-from collections.abc import Iterable, Sequence
+from collections.abc import Iterable
from contextlib import ExitStack
import functools
import inspect
@@ -1481,74 +1481,7 @@ def cla(self):
else:
self.clear()
- class ArtistList(Sequence):
- """
- A sublist of Axes children based on their type.
-
- The type-specific children sublists were made immutable in Matplotlib
- 3.7. In the future these artist lists may be replaced by tuples. Use
- as if this is a tuple already.
- """
- def __init__(self, axes, prop_name,
- valid_types=None, invalid_types=None):
- """
- Parameters
- ----------
- axes : `~matplotlib.axes.Axes`
- The Axes from which this sublist will pull the children
- Artists.
- prop_name : str
- The property name used to access this sublist from the Axes;
- used to generate deprecation warnings.
- valid_types : list of type, optional
- A list of types that determine which children will be returned
- by this sublist. If specified, then the Artists in the sublist
- must be instances of any of these types. If unspecified, then
- any type of Artist is valid (unless limited by
- *invalid_types*.)
- invalid_types : tuple, optional
- A list of types that determine which children will *not* be
- returned by this sublist. If specified, then Artists in the
- sublist will never be an instance of these types. Otherwise, no
- types will be excluded.
- """
- self._axes = axes
- self._prop_name = prop_name
- self._type_check = lambda artist: (
- (not valid_types or isinstance(artist, valid_types)) and
- (not invalid_types or not isinstance(artist, invalid_types))
- )
-
- def __repr__(self):
- return f''
-
- def __len__(self):
- return sum(self._type_check(artist)
- for artist in self._axes._children)
-
- def __iter__(self):
- for artist in list(self._axes._children):
- if self._type_check(artist):
- yield artist
-
- def __getitem__(self, key):
- return [artist
- for artist in self._axes._children
- if self._type_check(artist)][key]
-
- def __add__(self, other):
- if isinstance(other, (list, _AxesBase.ArtistList)):
- return [*self, *other]
- if isinstance(other, (tuple, _AxesBase.ArtistList)):
- return (*self, *other)
- return NotImplemented
-
- def __radd__(self, other):
- if isinstance(other, list):
- return other + list(self)
- if isinstance(other, tuple):
- return other + tuple(self)
- return NotImplemented
+ ArtistList = martist.ArtistList
@property
def artists(self):
diff --git a/lib/matplotlib/figure.py b/lib/matplotlib/figure.py
index ad0206e0db5c..a1d4c9bb8b1f 100644
--- a/lib/matplotlib/figure.py
+++ b/lib/matplotlib/figure.py
@@ -26,6 +26,7 @@
:ref:`figure-intro`.
"""
+from collections.abc import MutableSequence
from contextlib import ExitStack
import inspect
import itertools
@@ -39,7 +40,7 @@
import matplotlib as mpl
from matplotlib import _blocking_input, backend_bases, _docstring, projections
from matplotlib.artist import (
- Artist, allow_rasterization, _finalize_rasterization)
+ Artist, ArtistList, allow_rasterization, _finalize_rasterization)
from matplotlib.backend_bases import (
DrawEvent, FigureCanvasBase, NonGuiException, MouseButton, _get_renderer)
import matplotlib._api as _api
@@ -54,9 +55,10 @@
PlaceHolderLayoutEngine
)
import matplotlib.legend as mlegend
-from matplotlib.patches import Rectangle
+from matplotlib.lines import Line2D
+from matplotlib.patches import Patch, Rectangle
from matplotlib.text import Text
-from matplotlib.transforms import (Affine2D, Bbox, BboxTransformTo,
+from matplotlib.transforms import (Affine2D, Bbox, BboxTransformTo, IdentityTransform,
TransformedBbox)
_log = logging.getLogger(__name__)
@@ -142,17 +144,101 @@ def __init__(self, **kwargs):
}
self._localaxes = [] # track all Axes
- self.artists = []
- self.lines = []
- self.patches = []
- self.texts = []
- self.images = []
- self.legends = []
self.subfigs = []
+ self._children = [] # All artists except SubFigure and Axes
self.stale = True
self.suppressComposite = None
self.set(**kwargs)
+ class ArtistList(ArtistList, MutableSequence):
+ """
+ A sublist of Figure children based on their type. In the future these artist
+ lists may be replaced by tuples. Use as if this is a tuple already.
+
+ .. deprecated:: 3.12
+ In the future, this will be immutable. For now, it generates
+ warnings when modified.
+ """
+ def insert(self, index, item):
+ _api.warn_deprecated(
+ '3.12',
+ message=f'modification of the (Sub)Figure.{self._prop_name} property',
+ obj_type='property',
+ alternative='(Sub)Figure.add_artist')
+ try:
+ index = self._parent._children.index(self[index])
+ except IndexError:
+ index = None
+ self._parent.add_artist(item)
+ if index is not None:
+ # Move new item to the specified index, if there's something to
+ # put it before.
+ self._parent._children[index:index] = self._parent._children[-1:]
+ del self._parent._children[-1]
+
+ def __setitem__(self, key, item):
+ _api.warn_deprecated(
+ '3.12',
+ message=f'modification of the (Sub)Figure.{self._prop_name} property',
+ alternative='Artist.remove() and (Sub)Figure.add_artist')
+ del self[key]
+ if isinstance(key, slice):
+ key = key.start
+ if not np.iterable(item):
+ self.insert(key, item)
+ return
+
+ try:
+ index = self._parent._children.index(self[key])
+ except IndexError:
+ index = None
+ for i, artist in enumerate(item):
+ self._parent.add_artist(artist)
+ if index is not None:
+ # Move new items to the specified index, if there's something
+ # to put it before.
+ i = -(i + 1)
+ self._parent._children[index:index] = self._parent._children[i:]
+ del self._parent._children[i:]
+
+ def __delitem__(self, key):
+ _api.warn_deprecated(
+ '3.12',
+ message=f'modification of the (Sub)Figure.{self._prop_name} property',
+ alternative='Artist.remove()')
+ if isinstance(key, slice):
+ for artist in self[key]:
+ artist.remove()
+ else:
+ self[key].remove()
+
+ @property
+ def artists(self):
+ return self.ArtistList(self, 'artists', invalid_types=(
+ mimage.FigureImage, mlegend.Legend, Line2D, Patch, Text))
+
+ @property
+ def images(self):
+ return self.ArtistList(self, 'images', valid_types=mimage.FigureImage)
+
+ @property
+ def legends(self):
+ return self.ArtistList(self, 'legends', valid_types=mlegend.Legend)
+
+ @property
+ def lines(self):
+ return self.ArtistList(self, 'lines', valid_types=Line2D)
+
+ @property
+ def patches(self):
+ return self.ArtistList(self, 'patches', valid_types=Patch)
+
+ @property
+ def texts(self):
+ return self.ArtistList(self, 'texts', valid_types=Text)
+
+
+
def _get_draw_artists(self, renderer):
"""Also runs apply_aspect"""
artists = self.get_children()
@@ -384,7 +470,7 @@ def _suplabels(self, t, info, **kwargs):
return suplab
def _remove_suplabel(self, label, name):
- self.texts.remove(label)
+ self._children.remove(label)
setattr(self, name, None)
@_docstring.Substitution(x0=0.5, y0=0.98, name='super title', ha='center',
@@ -523,8 +609,8 @@ def add_artist(self, artist, clip=False):
The added artist.
"""
artist.set_figure(self)
- self.artists.append(artist)
- artist._remove_method = self.artists.remove
+ self._children.append(artist)
+ artist._remove_method = self._children.remove
if not artist.is_transform_set():
artist.set_transform(self.transSubfigure)
@@ -990,12 +1076,7 @@ def clear(self, keep_observers=False):
ax.clear()
self.delaxes(ax) # Remove ax from self._axstack.
- self.artists = []
- self.lines = []
- self.patches = []
- self.texts = []
- self.images = []
- self.legends = []
+ self._children = []
self.subplotpars.reset()
if not keep_observers:
self._axobservers = cbook.CallbackRegistry()
@@ -1143,8 +1224,8 @@ def legend(self, *args, **kwargs):
# explicitly set the bbox transform if the user hasn't.
kwargs.setdefault("bbox_transform", self.transSubfigure)
l = mlegend.Legend(self, handles, labels, **kwargs)
- self.legends.append(l)
- l._remove_method = self.legends.remove
+ self.add_artist(l)
+ l._remove_method = self._children.remove
self.stale = True
return l
@@ -1193,8 +1274,8 @@ def text(self, x, y, s, fontdict=None, **kwargs):
text.set_figure(self)
text.stale_callback = _stale_figure_callback
- self.texts.append(text)
- text._remove_method = self.texts.remove
+ self.add_artist(text)
+ text._remove_method = self._children.remove
self.stale = True
return text
@@ -3110,6 +3191,8 @@ def figimage(self, X, xo=0, yo=0, alpha=None, norm=None, cmap=None,
figsize = [x / dpi for x in (X.shape[1], X.shape[0])]
self.set_size_inches(figsize, forward=True)
+ kwargs.setdefault('transform', IdentityTransform())
+
im = mimage.FigureImage(self, cmap=cmap, norm=norm,
colorizer=colorizer,
offsetx=xo, offsety=yo,
@@ -3121,8 +3204,8 @@ def figimage(self, X, xo=0, yo=0, alpha=None, norm=None, cmap=None,
if norm is None:
im._check_exclusionary_keywords(colorizer, vmin=vmin, vmax=vmax)
im.set_clim(vmin, vmax)
- self.images.append(im)
- im._remove_method = self.images.remove
+ self.add_artist(im)
+ im._remove_method = self._children.remove
self.stale = True
return im
diff --git a/lib/matplotlib/tests/test_bbox_tight.py b/lib/matplotlib/tests/test_bbox_tight.py
index 167e966012ab..9addfb20f89f 100644
--- a/lib/matplotlib/tests/test_bbox_tight.py
+++ b/lib/matplotlib/tests/test_bbox_tight.py
@@ -108,7 +108,7 @@ def test_bbox_inches_tight_clipping():
path = mpath.Path.unit_regular_star(5).deepcopy()
path.vertices *= 0.25
patch.set_clip_path(path, transform=ax.transAxes)
- plt.gcf().artists.append(patch)
+ plt.gcf().add_artist(patch)
@image_comparison(['bbox_inches_tight_raster'], tol=0.15, # For Ghostscript 10.06+.
diff --git a/lib/matplotlib/tests/test_cbook.py b/lib/matplotlib/tests/test_cbook.py
index 0c0373217ea0..893aabaf44fc 100644
--- a/lib/matplotlib/tests/test_cbook.py
+++ b/lib/matplotlib/tests/test_cbook.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+from collections.abc import MutableSequence
import itertools
import pathlib
import pickle
@@ -651,6 +652,29 @@ def test_warn_external(recwarn):
str(basedir / 'mpl_toolkits')))
+def test_warn_external_collections_abc(recwarn):
+ # Subclassing a collections ABC can mean users call a method we didn't directly
+ # implement, which in turn calls one we did. E.g. here append calls insert.
+ class UselessSequence(MutableSequence):
+ def __len__(self):
+ return 2
+ def __getitem__(self):
+ return 'foo'
+ def __delitem__(self, index):
+ pass
+ def __setitem__(self, key, item):
+ pass
+ def insert(self, index, item):
+ _api.warn_external("This won't do anything")
+
+ myseq = UselessSequence()
+ myseq.append(5)
+
+ assert len(recwarn) == 1
+ # Confirm that the warning does not go to the collections.abc module
+ assert 'collection' not in recwarn[0].filename
+
+
def test_warn_external_frame_embedded_python():
with patch.object(cbook, "sys") as mock_sys:
diff --git a/lib/matplotlib/tests/test_figure.py b/lib/matplotlib/tests/test_figure.py
index fbfb2515f42e..2528c5a6661f 100644
--- a/lib/matplotlib/tests/test_figure.py
+++ b/lib/matplotlib/tests/test_figure.py
@@ -24,6 +24,10 @@
from matplotlib.ticker import AutoMinorLocator, FixedFormatter, ScalarFormatter
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
+import matplotlib.lines as mlines
+import matplotlib.patches as mpatch
+from matplotlib.offsetbox import AnchoredOffsetbox, TextArea
+import matplotlib.transforms as mtransforms
@image_comparison(['figure_align_labels'], extensions=['png', 'svg'], style='mpl20',
@@ -395,8 +399,9 @@ def test_alpha():
fig = plt.figure(figsize=[2, 1])
fig.set_facecolor((0, 1, 0.4))
fig.patch.set_alpha(0.4)
- fig.patches.append(mpl.patches.CirclePolygon(
- [20, 20], radius=15, alpha=0.6, facecolor='red'))
+ fig.add_artist(mpl.patches.CirclePolygon(
+ [20, 20], radius=15, alpha=0.6, facecolor='red',
+ transform=mtransforms.IdentityTransform()))
def test_too_many_figures():
@@ -1900,3 +1905,63 @@ def test_figsize_both_none():
def test_figsize_invalid_unit():
with pytest.raises(ValueError, match="Invalid unit 'um'"):
plt.figure(figsize=(6, 4, "um"))
+
+
+def test_artist_sublists():
+ # The ArtistList functionality is covered in test_axes.py::test_artist_sublists.
+ # Here we simply check that the artists go to the correct sublist for their type.
+ fig = plt.figure()
+
+ im = fig.figimage(np.arange(25).reshape(5, 5))
+ txt = fig.text(0.5, 0.5, 'foo')
+
+ line = mlines.Line2D([0, 1], [0, 1])
+ patch = mpatch.Rectangle((0, 0), 0.5, 0.5)
+ box = AnchoredOffsetbox(child=TextArea('bar'), loc='upper left')
+
+ for artist in line, patch, box:
+ fig.add_artist(artist)
+
+ leg = fig.legend([line], ['baz'])
+
+ assert list(fig.images) == [im]
+ assert list(fig.texts) == [txt]
+ assert list(fig.lines) == [line]
+ assert list(fig.patches) == [patch]
+ assert list(fig.legends) == [leg]
+ assert list(fig.artists) == [box]
+
+
+def test_artist_sublist_deprecations():
+ fig = plt.figure()
+
+ lines = [
+ mlines.Line2D(
+ [0, 1], [0, 1/n], figure=fig, transform=fig.transFigure)
+ for n in range(1, 7)]
+
+ # Adding items should warn.
+ match = r'modification of the \(Sub\)Figure.lines property'
+ with pytest.warns(mpl.MatplotlibDeprecationWarning, match=match):
+ fig.lines.append(lines[-2])
+ assert list(fig.lines) == [lines[-2]]
+ with pytest.warns(mpl.MatplotlibDeprecationWarning, match=match):
+ fig.lines.append(lines[-1])
+ assert list(fig.lines) == lines[-2:]
+ with pytest.warns(mpl.MatplotlibDeprecationWarning, match=match):
+ fig.lines.insert(-2, lines[0])
+ assert list(fig.lines) == [lines[0], lines[-2], lines[-1]]
+
+ # Modifying items should warn.
+ with pytest.warns(mpl.MatplotlibDeprecationWarning, match=match):
+ fig.lines[0] = lines[0]
+ assert list(fig.lines) == [lines[0], lines[-2], lines[-1]]
+ with pytest.warns(mpl.MatplotlibDeprecationWarning, match=match):
+ fig.lines[1:1] = lines[1:-2]
+ assert list(fig.lines) == lines
+
+ # Deleting items (multiple or single) should warn.
+ with pytest.warns(mpl.MatplotlibDeprecationWarning, match=match):
+ del fig.lines[-1]
+ with pytest.warns(mpl.MatplotlibDeprecationWarning, match=match):
+ del fig.lines[1:]
diff --git a/lib/matplotlib/tests/test_legend.py b/lib/matplotlib/tests/test_legend.py
index 67b433fe7447..2b7b08c4e699 100644
--- a/lib/matplotlib/tests/test_legend.py
+++ b/lib/matplotlib/tests/test_legend.py
@@ -295,7 +295,7 @@ def test_legend_remove():
lines = ax.plot(range(10))
leg = fig.legend(lines, ["test"])
leg.remove()
- assert fig.legends == []
+ assert list(fig.legends) == []
leg = ax.legend("test")
leg.remove()
assert ax.get_legend() is None