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