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/dependabot.yml b/.github/dependabot.yml index 3943b3719321..0a6d627d8bb1 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,6 +11,8 @@ updates: actions: patterns: - "*" + labels: + - "PR: dependencies" - package-ecosystem: "pip" directory: "/" schedule: @@ -19,9 +21,17 @@ updates: default-days: 7 exclude-paths: - "ci/minver-requirements.txt" + labels: + - "PR: dependencies" - package-ecosystem: "pre-commit" directory: "/" schedule: interval: "monthly" cooldown: default-days: 7 + groups: + pre-commit: + patterns: + - "*" + labels: + - "PR: dependencies" diff --git a/.github/labeler.yml b/.github/labeler.yml index 77b79146b47f..ee49868c8d7f 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -4,6 +4,7 @@ - any-glob-to-any-file: - '.github/workflows/cibuildwheel.yml' - '.github/workflows/wasm.yml' + - 'pyproject.toml' "CI: Run cygwin": - changed-files: - any-glob-to-any-file: ['.github/workflows/cygwin.yml'] diff --git a/.github/workflows/autoclose_schedule.yml b/.github/workflows/autoclose_schedule.yml index a4eba770699c..006cb3dda986 100644 --- a/.github/workflows/autoclose_schedule.yml +++ b/.github/workflows/autoclose_schedule.yml @@ -22,10 +22,10 @@ jobs: name: autoclose labeled PRs runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: '3.13' - name: Install PyGithub diff --git a/.github/workflows/cibuildwheel.yml b/.github/workflows/cibuildwheel.yml index 2bb7f9544902..52cb21a4432b 100644 --- a/.github/workflows/cibuildwheel.yml +++ b/.github/workflows/cibuildwheel.yml @@ -42,15 +42,15 @@ jobs: SDIST_NAME: ${{ steps.sdist.outputs.SDIST_NAME }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 persist-credentials: false - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.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 @@ -97,31 +97,6 @@ jobs: permissions: contents: read runs-on: ${{ matrix.os }} - env: - CIBW_BEFORE_BUILD: >- - rm -rf {package}/build - CIBW_BEFORE_BUILD_WINDOWS: >- - pip install delvewheel && - rm -rf {package}/build - CIBW_REPAIR_WHEEL_COMMAND_WINDOWS: >- - delvewheel repair -w {dest_dir} {wheel} - CIBW_AFTER_BUILD: >- - twine check {wheel} && - python {package}/ci/check_wheel_licenses.py {wheel} - # On Windows, we explicitly request MSVC compilers (as GitHub Action runners have - # MinGW on PATH that would be picked otherwise), switch to a static build for - # runtimes, but use dynamic linking for `VCRUNTIME140.dll`, `VCRUNTIME140_1.dll`, - # and the UCRT. This avoids requiring specific versions of `MSVCP140.dll`, while - # keeping shared state with the rest of the Python process/extensions. - CIBW_CONFIG_SETTINGS_WINDOWS: >- - setup-args="--vsenv" - setup-args="-Db_vscrt=mt" - setup-args="-Dcpp_link_args=['ucrt.lib','vcruntime.lib','/nodefaultlib:libucrt.lib','/nodefaultlib:libvcruntime.lib']" - CIBW_MANYLINUX_X86_64_IMAGE: manylinux2014 - CIBW_SKIP: "*-musllinux_aarch64" - CIBW_TEST_COMMAND: >- - python {package}/ci/check_version_number.py - MACOSX_DEPLOYMENT_TARGET: "10.12" strategy: matrix: include: @@ -150,50 +125,29 @@ jobs: run: Remove-Item -Recurse C:\Strawberry - name: Build wheels for CPython 3.14 - uses: pypa/cibuildwheel@8d2b08b68458a16aeb24b64e68a09ab1c8e82084 # v3.4.1 + uses: pypa/cibuildwheel@294735312765b09d24a2fbec22660ce817587d55 # v4.1.0 with: package-dir: dist/${{ needs.build_sdist.outputs.SDIST_NAME }} env: CIBW_BUILD: "cp314-* cp314t-*" - CIBW_ENABLE: "cpython-freethreading cpython-prerelease" CIBW_ARCHS: ${{ matrix.cibw_archs }} - CIBW_MANYLINUX_X86_64_IMAGE: manylinux_2_28 - name: Build wheels for CPython 3.13 - uses: pypa/cibuildwheel@8d2b08b68458a16aeb24b64e68a09ab1c8e82084 # v3.4.1 + uses: pypa/cibuildwheel@294735312765b09d24a2fbec22660ce817587d55 # v4.1.0 with: package-dir: dist/${{ needs.build_sdist.outputs.SDIST_NAME }} env: - CIBW_BUILD: "cp313-* cp313t-*" - CIBW_ENABLE: cpython-freethreading + CIBW_BUILD: "cp313-*" CIBW_ARCHS: ${{ matrix.cibw_archs }} - name: Build wheels for CPython 3.12 - uses: pypa/cibuildwheel@8d2b08b68458a16aeb24b64e68a09ab1c8e82084 # v3.4.1 + uses: pypa/cibuildwheel@294735312765b09d24a2fbec22660ce817587d55 # v4.1.0 with: package-dir: dist/${{ needs.build_sdist.outputs.SDIST_NAME }} env: 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/circleci.yml b/.github/workflows/circleci.yml index 49dc4ea3b3ec..017bba79148d 100644 --- a/.github/workflows/circleci.yml +++ b/.github/workflows/circleci.yml @@ -31,7 +31,7 @@ jobs: runs-on: ubuntu-latest name: Post warnings/errors as review steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false diff --git a/.github/workflows/clean_pr.yml b/.github/workflows/clean_pr.yml index 75f6a451c7ee..acc1994e7a9c 100644 --- a/.github/workflows/clean_pr.yml +++ b/.github/workflows/clean_pr.yml @@ -11,7 +11,7 @@ jobs: contents: read steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: '0' persist-credentials: false diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 71425e9cc3e9..d312d60100ad 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -29,12 +29,12 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 + uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 with: languages: ${{ matrix.language }} @@ -45,4 +45,4 @@ jobs: pip install --user -v . - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 + uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 diff --git a/.github/workflows/conflictcheck.yml b/.github/workflows/conflictcheck.yml index 2058da8ca9fb..e353e47bfb79 100644 --- a/.github/workflows/conflictcheck.yml +++ b/.github/workflows/conflictcheck.yml @@ -19,7 +19,7 @@ jobs: pull-requests: write steps: - name: Check if PRs have merge conflicts - uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3 + uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0 with: dirtyLabel: "status: needs rebase" repoToken: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/cygwin.yml b/.github/workflows/cygwin.yml index 4b790ea52a7d..a114a02ea6da 100644 --- a/.github/workflows/cygwin.yml +++ b/.github/workflows/cygwin.yml @@ -80,12 +80,12 @@ jobs: - name: Fix line endings run: git config --global core.autocrlf input - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 persist-credentials: false - - uses: cygwin/cygwin-install-action@711d29f3da23c9f4a1798e369a6f01198c13b11a # v6 + - uses: cygwin/cygwin-install-action@3f0a3f9f988f7e96b8c18098ae05eaec175f5b52 # v6 with: packages: >- ccache gcc-g++ gdb git graphviz libcairo-devel libffi-devel @@ -141,21 +141,21 @@ jobs: # FreeType build fails with bash, succeeds with dash - name: Cache pip - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: C:\cygwin\home\runneradmin\.cache\pip key: Cygwin-py3.${{ matrix.python-minor-version }}-pip-${{ hashFiles('pyproject.toml') }} restore-keys: ${{ matrix.os }}-py3.${{ matrix.python-minor-version }}-pip- - name: Cache ccache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: C:\cygwin\home\runneradmin\.ccache key: Cygwin-py3.${{ matrix.python-minor-version }}-ccache-${{ hashFiles('src/*') }} restore-keys: Cygwin-py3.${{ matrix.python-minor-version }}-ccache- - name: Cache Matplotlib - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | C:\cygwin\home\runneradmin\.cache\matplotlib @@ -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/.github/workflows/linting.yml b/.github/workflows/linting.yml index 7d3784e07e91..d696ca5aa2ac 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -11,14 +11,14 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 persist-credentials: false - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.x" - - uses: j178/prek-action@6ad80277337ad479fe43bd70701c3f7f8aa74db3 # v2.0.3 + - uses: j178/prek-action@bdca6f102f98e2b4c7029491a53dfd366469e33d # v2.0.4 with: extra-args: --hook-stage manual --all-files @@ -29,14 +29,14 @@ jobs: contents: read checks: write steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Set up Python 3 - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: - python-version: '3.11' + python-version: '3.12' - name: Install ruff run: pip3 install ruff @@ -59,14 +59,14 @@ jobs: contents: read checks: write steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Set up Python 3 - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: - python-version: '3.11' + python-version: '3.12' - name: Install mypy run: pip3 install --group build --group typing @@ -90,12 +90,12 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Set up Python 3 - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: '3.14' @@ -122,7 +122,7 @@ jobs: contents: read checks: write steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false diff --git a/.github/workflows/mypy-stubtest.yml b/.github/workflows/mypy-stubtest.yml index 81fcd48462e8..bdf7e468e89f 100644 --- a/.github/workflows/mypy-stubtest.yml +++ b/.github/workflows/mypy-stubtest.yml @@ -12,14 +12,14 @@ jobs: contents: read checks: write steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Set up Python 3 - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.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/pr_welcome.yml b/.github/workflows/pr_welcome.yml index 48691e61d87b..f8b004dc5468 100644 --- a/.github/workflows/pr_welcome.yml +++ b/.github/workflows/pr_welcome.yml @@ -16,9 +16,10 @@ jobs: issues: write pull-requests: write steps: - - uses: plbstl/first-contribution@7c31f41b0e7a70adfcae06cf964679f61af6780b # v4.3.0 + - uses: plbstl/first-contribution@2c36bdb58684587f60549a69aaa3ec00b9d5f4fe # v4.3.3 with: labels: first-contribution + skip-internal-contributors: false pr-opened-msg: >+ Thank you for opening your first PR into Matplotlib! diff --git a/.github/workflows/stale-tidy.yml b/.github/workflows/stale-tidy.yml index feb1fe701d70..e7d8272bdf24 100644 --- a/.github/workflows/stale-tidy.yml +++ b/.github/workflows/stale-tidy.yml @@ -13,7 +13,7 @@ jobs: permissions: issues: write steps: - - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 + - uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} operations-per-run: 300 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 63f1a1ce3b05..4ebcdcee1f31 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -14,7 +14,7 @@ jobs: issues: write pull-requests: write steps: - - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 + - uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} operations-per-run: 20 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a3a9def4bd40..8be8e5d25a92 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 @@ -70,19 +70,13 @@ jobs: pygobject-ver: '<3.52.0' - name-suffix: "Free-threaded" os: ubuntu-22.04 - python-version: '3.13t' + python-version: '3.14t' # 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 @@ -97,13 +91,13 @@ jobs: pygobject-ver: '<3.52.0' steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: ${{ matrix.python-version }} allow-prereleases: true @@ -186,7 +180,7 @@ jobs: esac - name: Cache pip - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 if: startsWith(runner.os, 'Linux') with: path: ~/.cache/pip @@ -196,7 +190,7 @@ jobs: restore-keys: | ${{ matrix.os }}-py${{ matrix.python-version }}-pip- - name: Cache pip - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 if: startsWith(runner.os, 'macOS') with: path: ~/Library/Caches/pip @@ -204,7 +198,7 @@ jobs: restore-keys: | ${{ matrix.os }}-py${{ matrix.python-version }}-pip- - name: Cache ccache - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.ccache @@ -212,7 +206,7 @@ jobs: restore-keys: | ${{ matrix.os }}-py${{ matrix.python-version }}-ccache- - name: Cache Matplotlib - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.cache/matplotlib @@ -244,7 +238,7 @@ jobs: # Sphinx is needed to run sphinxext tests python -m pip install --upgrade sphinx!=6.1.2 - if [[ "${{ matrix.python-version }}" != '3.13t' ]]; then + if [[ "${{ matrix.python-version }}" != '3.14t' ]]; then # GUI toolkits are pip-installable only for some versions of Python # so don't fail if we can't install them. Make it easier to check # whether the install was successful by trying to import the toolkit @@ -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' || @@ -293,7 +277,7 @@ jobs: echo 'wxPython is available' || echo 'wxPython is not available' - fi # Skip backends on Python 3.13t. + fi # Skip backends on Python 3.14t. - name: Install the nightly dependencies # Only install the nightly dependencies during the scheduled event @@ -333,7 +317,7 @@ jobs: - name: Run pytest run: | - if [[ "${{ matrix.python-version }}" == '3.13t' ]]; then + if [[ "${{ matrix.python-version }}" == '3.14t' ]]; then export PYTHON_GIL=0 fi pytest -rfEsXR -n auto \ @@ -396,7 +380,7 @@ jobs: fi - name: Upload code coverage if: ${{ !cancelled() && github.event_name != 'schedule' }} - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: name: "${{ matrix.python-version }} ${{ matrix.os }} ${{ matrix.name-suffix }}" token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/triage_board.yml b/.github/workflows/triage_board.yml index 9888a68b27db..3ef26369afeb 100644 --- a/.github/workflows/triage_board.yml +++ b/.github/workflows/triage_board.yml @@ -10,6 +10,7 @@ permissions: {} jobs: pr-triage: + if: github.repository == 'matplotlib/matplotlib' runs-on: ubuntu-latest steps: - name: Update PR Triage Board diff --git a/.github/workflows/wasm.yml b/.github/workflows/wasm.yml index e1668d99af60..af6cfa5ee3a1 100644 --- a/.github/workflows/wasm.yml +++ b/.github/workflows/wasm.yml @@ -39,18 +39,18 @@ jobs: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 persist-credentials: false - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 name: Install Python with: python-version: '3.13' - name: Build wheels for wasm - uses: pypa/cibuildwheel@8d2b08b68458a16aeb24b64e68a09ab1c8e82084 # v3.4.1 + uses: pypa/cibuildwheel@294735312765b09d24a2fbec22660ce817587d55 # v4.1.0 env: CIBW_BUILD: "cp312-*" CIBW_PLATFORM: "pyodide" diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index f6e77b3d6000..c276e7a42548 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -22,9 +22,9 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Run zizmor - uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6 + uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8706ec94b3d5..6f2b94141984 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -72,7 +72,6 @@ repos: additional_dependencies: - rstcheck-core!=1.3 # https://github.com/rstcheck/rstcheck-core/pull/114#pullrequestreview-4239740896 - sphinx>=1.8.1 - - tomli - repo: https://github.com/adrienverge/yamllint rev: cba56bcde1fdd01c1deb3f945e69764c291a6530 # frozen: v1.38.0 hooks: diff --git a/LICENSE/LICENSE_JSXTOOLS_RESIZE_OBSERVER b/LICENSE/LICENSE_JSXTOOLS_RESIZE_OBSERVER deleted file mode 100644 index 0bc1fa7060b7..000000000000 --- a/LICENSE/LICENSE_JSXTOOLS_RESIZE_OBSERVER +++ /dev/null @@ -1,108 +0,0 @@ -# CC0 1.0 Universal - -## Statement of Purpose - -The laws of most jurisdictions throughout the world automatically confer -exclusive Copyright and Related Rights (defined below) upon the creator and -subsequent owner(s) (each and all, an “owner”) of an original work of -authorship and/or a database (each, a “Work”). - -Certain owners wish to permanently relinquish those rights to a Work for the -purpose of contributing to a commons of creative, cultural and scientific works -(“Commons”) that the public can reliably and without fear of later claims of -infringement build upon, modify, incorporate in other works, reuse and -redistribute as freely as possible in any form whatsoever and for any purposes, -including without limitation commercial purposes. These owners may contribute -to the Commons to promote the ideal of a free culture and the further -production of creative, cultural and scientific works, or to gain reputation or -greater distribution for their Work in part through the use and efforts of -others. - -For these and/or other purposes and motivations, and without any expectation of -additional consideration or compensation, the person associating CC0 with a -Work (the “Affirmer”), to the extent that he or she is an owner of Copyright -and Related Rights in the Work, voluntarily elects to apply CC0 to the Work and -publicly distribute the Work under its terms, with knowledge of his or her -Copyright and Related Rights in the Work and the meaning and intended legal -effect of CC0 on those rights. - -1. Copyright and Related Rights. A Work made available under CC0 may be - protected by copyright and related or neighboring rights (“Copyright and - Related Rights”). Copyright and Related Rights include, but are not limited - to, the following: - 1. the right to reproduce, adapt, distribute, perform, display, communicate, - and translate a Work; - 2. moral rights retained by the original author(s) and/or performer(s); - 3. publicity and privacy rights pertaining to a person’s image or likeness - depicted in a Work; - 4. rights protecting against unfair competition in regards to a Work, - subject to the limitations in paragraph 4(i), below; - 5. rights protecting the extraction, dissemination, use and reuse of data in - a Work; - 6. database rights (such as those arising under Directive 96/9/EC of the - European Parliament and of the Council of 11 March 1996 on the legal - protection of databases, and under any national implementation thereof, - including any amended or successor version of such directive); and - 7. other similar, equivalent or corresponding rights throughout the world - based on applicable law or treaty, and any national implementations - thereof. - -2. Waiver. To the greatest extent permitted by, but not in contravention of, - applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and - unconditionally waives, abandons, and surrenders all of Affirmer’s Copyright - and Related Rights and associated claims and causes of action, whether now - known or unknown (including existing as well as future claims and causes of - action), in the Work (i) in all territories worldwide, (ii) for the maximum - duration provided by applicable law or treaty (including future time - extensions), (iii) in any current or future medium and for any number of - copies, and (iv) for any purpose whatsoever, including without limitation - commercial, advertising or promotional purposes (the “Waiver”). Affirmer - makes the Waiver for the benefit of each member of the public at large and - to the detriment of Affirmer’s heirs and successors, fully intending that - such Waiver shall not be subject to revocation, rescission, cancellation, - termination, or any other legal or equitable action to disrupt the quiet - enjoyment of the Work by the public as contemplated by Affirmer’s express - Statement of Purpose. - -3. Public License Fallback. Should any part of the Waiver for any reason be - judged legally invalid or ineffective under applicable law, then the Waiver - shall be preserved to the maximum extent permitted taking into account - Affirmer’s express Statement of Purpose. In addition, to the extent the - Waiver is so judged Affirmer hereby grants to each affected person a - royalty-free, non transferable, non sublicensable, non exclusive, - irrevocable and unconditional license to exercise Affirmer’s Copyright and - Related Rights in the Work (i) in all territories worldwide, (ii) for the - maximum duration provided by applicable law or treaty (including future time - extensions), (iii) in any current or future medium and for any number of - copies, and (iv) for any purpose whatsoever, including without limitation - commercial, advertising or promotional purposes (the “License”). The License - shall be deemed effective as of the date CC0 was applied by Affirmer to the - Work. Should any part of the License for any reason be judged legally - invalid or ineffective under applicable law, such partial invalidity or - ineffectiveness shall not invalidate the remainder of the License, and in - such case Affirmer hereby affirms that he or she will not (i) exercise any - of his or her remaining Copyright and Related Rights in the Work or (ii) - assert any associated claims and causes of action with respect to the Work, - in either case contrary to Affirmer’s express Statement of Purpose. - -4. Limitations and Disclaimers. - 1. No trademark or patent rights held by Affirmer are waived, abandoned, - surrendered, licensed or otherwise affected by this document. - 2. Affirmer offers the Work as-is and makes no representations or warranties - of any kind concerning the Work, express, implied, statutory or - otherwise, including without limitation warranties of title, - merchantability, fitness for a particular purpose, non infringement, or - the absence of latent or other defects, accuracy, or the present or - absence of errors, whether or not discoverable, all to the greatest - extent permissible under applicable law. - 3. Affirmer disclaims responsibility for clearing rights of other persons - that may apply to the Work or any use thereof, including without - limitation any person’s Copyright and Related Rights in the Work. - Further, Affirmer disclaims responsibility for obtaining any necessary - consents, permissions or other rights required for any use of the Work. - 4. Affirmer understands and acknowledges that Creative Commons is not a - party to this document and has no duty or obligation with respect to this - CC0 or use of the Work. - -For more information, please see -http://creativecommons.org/publicdomain/zero/1.0/. 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/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/ci/mypy-stubtest-allowlist.txt b/ci/mypy-stubtest-allowlist.txt index 0e199889cb07..b8f054447de0 100644 --- a/ci/mypy-stubtest-allowlist.txt +++ b/ci/mypy-stubtest-allowlist.txt @@ -3,7 +3,6 @@ matplotlib\.backends\..* matplotlib\.tests(\..*)? matplotlib\.pylab(\..*)? matplotlib\._.* -matplotlib\.rcsetup\._listify_validator matplotlib\.rcsetup\._validate_linestyle matplotlib\.ft2font\.Glyph matplotlib\.ft2font\.LayoutItem @@ -39,11 +38,6 @@ matplotlib\.figure\.Figure\.set_tight_layout matplotlib\.tri\..*TriInterpolator\.__call__ matplotlib\.tri\..*TriInterpolator\.gradient -# TypeVar used only in type hints -matplotlib\.backend_bases\.FigureCanvasBase\._T -matplotlib\.backend_managers\.ToolManager\._T -matplotlib\.spines\.Spine\._T - # Parameter inconsistency due to 3.10 deprecation matplotlib\.figure\.FigureBase\.get_figure 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/api/next_api_changes/behavior/violinplot_empty.rst b/doc/api/next_api_changes/behavior/violinplot_empty.rst new file mode 100644 index 000000000000..dfc5ca7669c7 --- /dev/null +++ b/doc/api/next_api_changes/behavior/violinplot_empty.rst @@ -0,0 +1,4 @@ +Axes.violinplot and cbook.violin_stats ignore non-finite values +--------------------------------------------------------------- + +`~matplotlib.axes.Axes.violinplot` and `matplotlib.cbook.violin_stats` now ignore masked and non-finite (NaN and inf) values. diff --git a/doc/api/next_api_changes/deprecations/29152_REC.rst b/doc/api/next_api_changes/deprecations/29152_REC.rst new file mode 100644 index 000000000000..cedc91e81410 --- /dev/null +++ b/doc/api/next_api_changes/deprecations/29152_REC.rst @@ -0,0 +1,13 @@ +``pie`` *labels* and *labeldistance* parameters +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Currently the *labels* parameter of `~.Axes.pie` is used both for annotating the +pie wedges directly, and for automatic legend entries. For consistency +with other plotting methods, in future *labels* will only be used for the legend. + +The *labeldistance* parameter will therefore default to ``None`` from Matplotlib +3.14, when it will also be deprecated and then removed in Matplotlib 3.16. To +preserve the existing behavior for now, set ``labeldistance=1.1``. For the longer +term, to place labels on the wedges use the new *wedge_labels* and +*wedge_label_distance* parameters of `~.Axes.pie` or the `~.Axes.pie_label` method. +Note that `~.Axes.pie_label` allows for more customization of the label positions via +the *rotate* and *alignment* parameters as well as *distance*. diff --git a/doc/api/next_api_changes/deprecations/31788-AL.rst b/doc/api/next_api_changes/deprecations/31788-AL.rst new file mode 100644 index 000000000000..f36906a21b93 --- /dev/null +++ b/doc/api/next_api_changes/deprecations/31788-AL.rst @@ -0,0 +1,4 @@ +``MaxNLocator.default_params`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +... is deprecated. The default parameter values are now directly given in the +class' constructor signature. diff --git a/doc/api/next_api_changes/deprecations/31818-TH.rst b/doc/api/next_api_changes/deprecations/31818-TH.rst new file mode 100644 index 000000000000..07acca32ccd5 --- /dev/null +++ b/doc/api/next_api_changes/deprecations/31818-TH.rst @@ -0,0 +1,4 @@ +Line2D.recache_always +~~~~~~~~~~~~~~~~~~~~~ + +``recache_always()`` on `.Line2D` is deprecated. Use ``recache(always=True)`` instead. diff --git a/doc/api/next_api_changes/development/31740_REC.rst b/doc/api/next_api_changes/development/31740_REC.rst new file mode 100644 index 000000000000..8ae9bc518198 --- /dev/null +++ b/doc/api/next_api_changes/development/31740_REC.rst @@ -0,0 +1,25 @@ +Increase to minimum supported versions of dependencies +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +For Matplotlib 3.12, the :ref:`minimum supported versions ` are being +bumped: + ++-------------+-----------------+----------------+ +| Dependency | min in mpl3.11 | min in mpl3.12 | ++=============+=================+================+ +| Python | 3.11 | 3.12 | ++-------------+-----------------+----------------+ +| NumPy | 1.25 | 2.0.0 | ++-------------+-----------------+----------------+ +| Contourpy | 1.0.1 | 1.2.1 | ++-------------+-----------------+----------------+ + +This is consistent with our :ref:`min_deps_policy` and `SPEC0 +`__ + + +PySide2 support +~~~~~~~~~~~~~~~ + +Support for the `PySide2 `_ Qt5 Python +binding has been dropped because PySide2 does not support Python 3.12+. diff --git a/doc/api/prev_api_changes/api_changes_0.99.x.rst b/doc/api/prev_api_changes/api_changes_0.99.x.rst index 4736d066d43e..012e64550da7 100644 --- a/doc/api/prev_api_changes/api_changes_0.99.x.rst +++ b/doc/api/prev_api_changes/api_changes_0.99.x.rst @@ -67,7 +67,7 @@ Changes beyond 0.99.x required by the experimental traited config and are somewhat out of date. If needed, install them independently. -.. _configobj: http://www.voidspace.org.uk/python/configobj.html +.. _configobj: https://configobj.readthedocs.io/en/latest/ .. _`enthought.traits`: http://code.enthought.com/pages/traits.html * The new rc parameter ``savefig.extension`` sets the filename extension diff --git a/doc/devel/coding_guide.rst b/doc/devel/coding_guide.rst index 1ea87eaeda07..45259c61efdd 100644 --- a/doc/devel/coding_guide.rst +++ b/doc/devel/coding_guide.rst @@ -228,8 +228,8 @@ local arguments and the rest are passed on as .. _using_logging: -Using logging for debug messages -================================ +Use logging for debug messages +============================== Matplotlib uses the standard Python `logging` library to write verbose warnings, information, and debug messages. Please use it! In all those places diff --git a/doc/devel/contribute.rst b/doc/devel/contribute.rst index cf158cbe67ad..9d95f8a9808f 100644 --- a/doc/devel/contribute.rst +++ b/doc/devel/contribute.rst @@ -189,7 +189,7 @@ Use of Generative AI ==================== Generative AI tools are evolving rapidly and can be helpful. As with any tool, -the resulting contribution is the responsibility of the contributor. We +the resulting contribution is the responsibility of the human contributor. We expect dedicated and authentic engagement in our community. In particular when using AI, carefully consider what and how to communicate, question results, think things through thoroughly and make well-informed decisions. @@ -216,6 +216,9 @@ Some examples of acceptable and unacceptable AI uses are: - Solving topics that you wouldn't be able to solve yourself without AI - Using AI output without ensuring that you fully understand the output or without verifying that it is the correct approach. + - Increasing breadth of contributions, i.e. simultaneously contributing to several + projects. Instead of spreading your resources, you can provide greater value + by engaging more deeply with one or two projects. To ensure project health and preserve limited core developer capacity, we will flag and reject low-value contributions that we believe are AI generated. We may ban diff --git a/doc/devel/document.rst b/doc/devel/document.rst index a2b663746efe..01a46386d52a 100644 --- a/doc/devel/document.rst +++ b/doc/devel/document.rst @@ -154,8 +154,8 @@ for opening them in your default browser is: .. _writing-rest-pages: -Write ReST pages -================ +reStructuredText pages +====================== Most documentation is either in the docstrings of individual classes and methods, in explicit ``.rst`` files, or in examples and tutorials. @@ -243,11 +243,15 @@ nor the ````literal```` role: Do not describe ``argument`` like this. -Write mathematical expressions ------------------------------- +Mathematical expressions +------------------------ +Use sphinx's built in math support: + +- **Inline math:** Use the ``:math:`` + `role `__ +- **Math blocks:** Use the ``.. math::`` + `directive `__ -In most cases, you will likely want to use one of `Sphinx's builtin Math -extensions `__. In rare cases we want the rendering of the mathematical text in the documentation html to exactly match with the rendering of the mathematical expression in the Matplotlib figure. In these cases, you can use the @@ -257,17 +261,17 @@ expression in the Matplotlib figure. In these cases, you can use the .. _internal-section-refs: -Refer to other documents and sections -------------------------------------- +Cross-references +---------------- Sphinx_ supports internal references_: -========== =============== =========================================== -Role Links target Representation in rendered HTML -========== =============== =========================================== -|doc-dir|_ document link to a page -|ref-dir|_ reference label link to an anchor associated with a heading -========== =============== =========================================== +========== ============================== =========================================== +Role Link target Representation in rendered HTML +========== ============================== =========================================== +|doc-dir|_ :ref:`page ` link to a page +|ref-dir|_ :ref:`section ` link to an anchor associated with a heading +========== ============================== =========================================== .. The following is a hack to have a link with literal formatting See https://stackoverflow.com/a/4836544 @@ -277,63 +281,53 @@ Role Links target Representation in rendered HTML .. |ref-dir| replace:: ``:ref:`` .. _ref-dir: https://www.sphinx-doc.org/en/master/usage/restructuredtext/roles.html#role-ref -Examples: +.. _link-pages: -.. code-block:: rst +Link to pages +^^^^^^^^^^^^^ - See the :doc:`/install/index` +To cross-link to another page, use the ``:doc:`` role. We generally prefer +absolute paths, starting with ``/`` as the :file:`doc` root directory. + +Example: - See the tutorial :ref:`quick_start` +.. code-block:: rst - See the example :doc:`/gallery/lines_bars_and_markers/simple_plot` + See the :doc:`/install/index` will render as: See the :doc:`/install/index` - See the tutorial :ref:`quick_start` - - See the example :doc:`/gallery/lines_bars_and_markers/simple_plot` +.. _link-sections: -Sections can also be given reference labels. For instance from the -:doc:`/install/index` link: - -.. code-block:: rst - - .. _clean-install: - - How to completely remove Matplotlib - =================================== +Link to sections +^^^^^^^^^^^^^^^^ - Occasionally, problems with Matplotlib can be solved with a clean... +Use hyphen-separated, descriptive names for reference labels. +Do not encode the documentation hierarchy in the label as that may change; +e.g. do not prefix all *User guide* labels with ``user-``. -and refer to it using the standard reference syntax: +To cross-link a specific section, add a reference label ``.. _label-name:`` +before the section .. code-block:: rst - See :ref:`clean-install` + .. _pr-author-guidelines: -will give the following link: :ref:`clean-install` + Summary for pull request authors + ================================ -To maximize internal consistency in section labeling and references, -use hyphen separated, descriptive labels for section references. -Keep in mind that contents may be reorganized later, so -avoid top level names in references like ``user`` or ``devel`` -or ``faq`` unless necessary, because for example the FAQ "what is a -backend?" could later become part of the users guide, so the label: +and then link to with ``:ref:`label-name``` .. code-block:: rst - .. _what-is-a-backend: - -is better than: + See the :ref:`pr-author-guidelines` -.. code-block:: rst +This will render as: - .. _faq-backend: + See the :ref:`pr-author-guidelines` -In addition, since underscores are widely used by Sphinx itself, use -hyphens to separate words. .. _referring-to-other-code: @@ -461,8 +455,8 @@ For clarity, do not use relative links. .. _writing-docstrings: -Write API documentation -======================= +API documentation +================= The API reference documentation describes the library interfaces, e.g. inputs, outputs, and expected behavior. Most of the API documentation is written in docstrings. These are @@ -957,8 +951,8 @@ Example: .. _writing-examples-and-tutorials: -Write examples and tutorials -============================ +Examples and tutorials +====================== Examples and tutorials are Python scripts that are run by `Sphinx Gallery`_. Sphinx Gallery finds ``*.py`` files in source directories and runs the files to @@ -1220,10 +1214,10 @@ Format :code: The code should be about 5-10 lines with minimal customization. Plots in this gallery use the ``_mpl-gallery`` stylesheet for a uniform aesthetic. -Analytics -========== +Website analytics +================= -Documentation page analytics are available at +Analytics of our hosted documentation https://matplotlib.org is available at https://views.scientific-python.org/matplotlib.org. diff --git a/doc/devel/min_dep_policy.rst b/doc/devel/min_dep_policy.rst index 81a84491bc4a..f0dc0438c8e4 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 2.0.0 3.11 3.11 1.25.0 `3.10`_ 3.10 1.23.0 `3.9`_ 3.9 1.23.0 @@ -157,8 +158,8 @@ Matplotlib Python NumPy .. _`1.3`: https://matplotlib.org/1.3.0/users/installing.html#build-requirements -Updating Python and NumPy versions -================================== +Update Python and NumPy versions +================================ To update the minimum versions of Python we need to update: diff --git a/doc/devel/pr_guide.rst b/doc/devel/pr_guide.rst index f29475cbf8d5..8cf31db0254b 100644 --- a/doc/devel/pr_guide.rst +++ b/doc/devel/pr_guide.rst @@ -208,12 +208,21 @@ Review push changes to the contributor branch, or merge the PR and then open a new PR against upstream. -* If you push to a contributor branch leave a comment explaining what +* If you push to a contributor branch, leave a comment explaining what you did, ex "I took the liberty of pushing a small clean-up PR to your branch, thanks for your work.". If you are going to make substantial changes to the code or intent of the PR please check with the contributor first. +* If you find yourself spending too much time on a PR, or feeling frustrated, + it's ok to step back. You can ask for help from other reviewers, or if you are + the only reviewer, you can ask the contributor to find another reviewer or to + wait until you have more time. Make sure to communicate with the contributor + to set the right expectations, e.g. "I currently don't have the bandwidth to + review this PR, but will try to loop someone else in." If you feel like this + PR is not a good fit for the project, you can close it with an explanation or + add the "status: autoclose candidate" label to trigger the autoclose workflow. + .. _pr-approval: Approval diff --git a/doc/devel/release_guide.rst b/doc/devel/release_guide.rst index ccac5b4f8872..eefc31aec07c 100644 --- a/doc/devel/release_guide.rst +++ b/doc/devel/release_guide.rst @@ -45,7 +45,7 @@ versioning scheme: *macro.meso.micro*. .. _release_feature_freeze: -Making the release branch +Create the release branch ========================= .. note:: @@ -379,8 +379,8 @@ to the VER-doc branch and push to GitHub. :: .. _release_bld_bin: -Building binaries -================= +Build binaries +============== We distribute macOS, Windows, and many Linux wheels as well as a source tarball via PyPI. @@ -412,8 +412,8 @@ PyPI. .. _release_upload_bin: -Manually uploading to PyPI -========================== +Manual upload to PyPI +===================== .. note:: diff --git a/doc/devel/style_guide.rst b/doc/devel/style_guide.rst index e35112a65e42..b260872557c5 100644 --- a/doc/devel/style_guide.rst +++ b/doc/devel/style_guide.rst @@ -176,6 +176,51 @@ reliability and consistency in documentation. They are not interchangeable. .. |Axis| replace:: :class:`~matplotlib.axis.Axis` +Headings +-------- +Use sentence case for headings. + +.. table:: + :width: 100% + :widths: 50, 50 + + +------------------------------------+------------------------------------+ + | Correct | Incorrect | + +====================================+====================================+ + | Quick start guide | Quick Start Guide | + +------------------------------------+------------------------------------+ + +Noun phrases and verb phrases are both acceptable for headings. Noun phrases +are preferred for higher-level headings and descriptive sections as they +simply state the content. + +.. table:: + :width: 100% + :widths: 50, 50 + + +------------------------------------+------------------------------------+ + | Correct | Incorrect | + +====================================+====================================+ + | Bug triage and issue curation | Triage bugs and curate issues | + +------------------------------------+------------------------------------+ + +Verb phrases are preferred for instructive and action-oriented sections; in +particular when they cover steps in a process, such as the subsections in +:ref:`installing_for_devs`. + +Use the second-person imperative form of the verb rather than the gerund form. + +.. table:: + :width: 100% + :widths: 50, 50 + + +------------------------------------+------------------------------------+ + | Correct | Incorrect | + +====================================+====================================+ + | Fork the Matplotlib repository | Forking the Matplotlib repository | + +------------------------------------+------------------------------------+ + + Grammar ------- diff --git a/doc/devel/testing.rst b/doc/devel/testing.rst index cbde2bed7979..27594ffe7dd4 100644 --- a/doc/devel/testing.rst +++ b/doc/devel/testing.rst @@ -13,10 +13,8 @@ testing infrastructure are in :mod:`matplotlib.testing`. .. _pytest-xdist: https://pypi.org/project/pytest-xdist/ -.. _testing_requirements: - -Requirements ------------- +Prerequisites +------------- To run the tests you will need to :ref:`set up Matplotlib for development `. Note in @@ -34,8 +32,8 @@ particular the :ref:`additional dependencies ` for testing. .. _run_tests: -Running the tests ------------------ +Run the tests +------------- In the root directory of your development repository run:: @@ -82,8 +80,8 @@ to avoid clashes between ``pytest``'s import mode and Python's search path: python -m pytest --import-mode prepend -Viewing image test output -^^^^^^^^^^^^^^^^^^^^^^^^^ +View image test output +^^^^^^^^^^^^^^^^^^^^^^ The output of :ref:`image-based ` tests is stored in a ``result_images`` directory. These images can be compiled into one HTML page, containing @@ -100,34 +98,45 @@ to the folder where the baseline test images are stored. The triage tool require :ref:`QT ` is installed. -Writing a simple test ---------------------- +Write tests +----------- +Tests are located in :file:`lib/matplotlib/tests`. They are organized to mirror +the structure of the code in :file:`lib/matplotlib`. For example, tests for +the ``mathtext.py`` module are in :file:`lib/matplotlib/tests/test_mathtext.py`. + +Naming follows standard pytest conventions: + +- files begin with ``"test_"`` +- test functions begin with ``"test_"`` +- test classes begin with ``"Test"``. + +We prefer simple test functions, but test classes are also acceptable. +Test function names should be descriptive of what they are testing, and long names +like ``test_to_rgba_array_accepts_color_alpha_tuple_with_multiple_colors()`` are +perfectly fine. + +Unit tests +^^^^^^^^^^ -Many elements of Matplotlib can be tested using standard tests. For -example, here is a test from :file:`matplotlib/tests/test_basic.py`:: +Many elements of Matplotlib can be tested using simple unit tests, e.g. :: - def test_simple(): - """ - very simple example test - """ - assert 1 + 1 == 2 + def test_to_rgba_explicit_alpha_overrides_tuple_alpha(): + assert mcolors.to_rgba(('red', 0.1), alpha=0.9) == (1, 0, 0, 0.9) -Pytest determines which functions are tests by searching for files whose names -begin with ``"test_"`` and then within those files for functions beginning with -``"test"`` or classes beginning with ``"Test"``. +Data in tests +^^^^^^^^^^^^^ +Try to use minimal explicit data, such as +``[1, 2, 3]``, ``range(5)`` or ``np.arange(5)``, because it +makes the test more readable. -Some tests have internal side effects that need to be cleaned up after their -execution (such as created figures or modified `.rcParams`). The pytest fixture -``matplotlib.testing.conftest.mpl_test_settings`` will automatically clean -these up; there is no need to do anything further. +When you need more and non-trivial data, generate it programmatically, e.g. :: -Random data in tests --------------------- + x = np.linspace(0, 2*np.pi, 101) + y = 2 * np.sin(x) + 1 -Random data is a very convenient way to generate data for examples, -however the randomness is problematic for testing (as the tests -must be deterministic!). To work around this set the seed in each test. -For numpy's default random number generator use:: +Use random numbers only when an algorithmic way to generate the data is too +cumbersome or impossible. In this case, set the seed to a fixed value to make +the test deterministic. For numpy's default random number generator use :: import numpy as np rng = np.random.default_rng(19680801) @@ -136,10 +145,56 @@ and then use ``rng`` when generating the random numbers. The seed is :ref:`John Hunter's ` birthday. +Test cleanup +^^^^^^^^^^^^ +We often need to create figures or to modify `.rcParams` to test some functionality. +Cleanup of such side effects is handled automatically through a pytest fixture +(``matplotlib.testing.conftest.mpl_test_settings``) so that no manual cleanup is +necessary. + +In particular, you don't need to call ``plt.close()``. + +Testing with figures and Axes +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +When you need figures and/or Axes, create them through the standard methods +(``plt.figure()``, ``plt.subplots()``, etc.). + +Creating figures and Axes is rather expensive (>100ms). Only create as many as you need for +the test, and reuse them if possible. It is perfectly fine to test multiple parametrizations +or related functionality in one test; i.e. extend the classical test structure +*Arrange–Act–Assert* with multiple *Act-Assert* blocks, e.g. :: + + def test_stackplot_facecolor(): + # Test that facecolors are properly passed and take precedence over colors parameter + x = np.linspace(0, 10, 10) + y1 = 1.0 * x + y2 = 2.0 * x + 1 + + fig, ax = plt.subplots() + + facecolors = ['r', 'b'] + + colls = ax.stackplot(x, y1, y2, facecolor=facecolors, colors=['c', 'm']) + for coll, fcolor in zip(colls, facecolors): + assert mcolors.same_color(coll.get_facecolor(), fcolor) + + # Plural alias should also work + colls = ax.stackplot(x, y1, y2, facecolors=facecolors, colors=['c', 'm']) + for coll, fcolor in zip(colls, facecolors): + assert mcolors.same_color(coll.get_facecolor(), fcolor) + +Assert values rather than visual results when feasible. This is clearer, +less computationally expensive and less fragile than comparing images, e.g. :: + + def test_savefig_preserve_layout_engine(): + fig = plt.figure(layout='compressed') + fig.savefig(io.BytesIO(), bbox_inches='tight') + assert fig.get_layout_engine()._compress + .. _image-comparison: -Writing an image comparison test --------------------------------- +Testing with reference images +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Writing an image-based test is only slightly more difficult than a simple test. The main consideration is that you must specify the "baseline", or @@ -180,9 +235,8 @@ texts (labels, tick labels, etc) are not really part of what is tested, use the will lead to smaller figures and reduce possible issues with font mismatch on different platforms. - -Compare two methods of creating an image -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Testing by comparing two methods to create an image +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Baseline images take a lot of space in the Matplotlib repository. An alternative approach for image comparison tests is to use the @@ -228,15 +282,8 @@ See the documentation of `~matplotlib.testing.decorators.image_comparison` and `~matplotlib.testing.decorators.check_figures_equal` for additional information about their use. -Creating a new module in matplotlib.tests ------------------------------------------ - -We try to keep the tests categorized by the primary module they are -testing. For example, the tests related to the ``mathtext.py`` module -are in ``test_mathtext.py``. - -Using GitHub Actions for CI ---------------------------- +CI with GitHub Actions +---------------------- `GitHub Actions `_ is a hosted CI system "in the cloud". @@ -262,8 +309,8 @@ https://github.com/your_GitHub_user_name/matplotlib/actions -- here's `an example `_. -Using tox ---------- +tox: Test multiple python versions +---------------------------------- `Tox `_ is a tool for running tests against multiple Python environments, including multiple versions of Python @@ -284,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. @@ -303,8 +350,8 @@ tests are run. For more info on the ``tox.ini`` file, see the `Tox Configuration Specification `_. -Building old versions of Matplotlib ------------------------------------ +Build old versions of Matplotlib +-------------------------------- When running a ``git bisect`` to see which commit introduced a certain bug, you may (rarely) need to build very old versions of Matplotlib. The following @@ -312,8 +359,8 @@ constraints need to be taken into account: - Matplotlib 1.3 (or earlier) requires numpy 1.8 (or earlier). -Testing released versions of Matplotlib ---------------------------------------- +Test released versions of Matplotlib +------------------------------------ Running the tests on an installation of a released version (e.g. PyPI package or conda package) also requires additional setup. diff --git a/doc/devel/triage.rst b/doc/devel/triage.rst index ca06fd515c79..f151fa1faf64 100644 --- a/doc/devel/triage.rst +++ b/doc/devel/triage.rst @@ -1,9 +1,9 @@ .. _bug_triaging: -******************************* -Bug triaging and issue curation -******************************* +***************************** +Bug triage and issue curation +***************************** The `issue tracker `_ is important to communication in the project because it serves as the @@ -30,35 +30,29 @@ are not part of the Matplotlib organization do not have `permissions to change milestones, add labels, or close issue `_. -If you do not have enough GitHub permissions do something (e.g. add a -label, close an issue), please leave a comment with your -recommendations! +If you do not have enough GitHub permissions to do something (e.g. add a +label, close an issue), please leave a comment with your recommendations! The following actions are typically useful: -- documenting issues that are missing elements to reproduce the problem - such as code samples - -- suggesting better use of code formatting (e.g. triple back ticks in the - markdown). - -- suggesting to reformulate the title and description to make them more - explicit about the problem to be solved - -- linking to related issues or discussions while briefly describing +* documenting issues that are missing elements to reproduce the problem, + such as code samples; +* suggesting better use of code formatting (e.g. triple back ticks in the + markdown); +* suggesting to reformulate the title and description to make them more + explicit about the problem to be solved; +* linking to related issues or discussions while briefly describing how they are related, for instance "See also #xyz for a similar attempt at this" or "See also #xyz where the same thing was - reported" provides context and helps the discussion - -- verifying that the issue is reproducible - -- classify the issue as a feature request, a long standing bug or a - regression + reported", which provides context and helps the discussion; +* verifying that the issue is reproducible; +* classifying the issue as a feature request, a long standing bug or a + regression. .. topic:: Fruitful discussions - Online discussions may be harder than it seems at first glance, in - particular given that a person new to open-source may have a very + Online discussions may be harder than they seem at first glance, in + particular given that a person new to open source may have a very different understanding of the process than a seasoned maintainer. Overall, it is useful to stay positive and assume good will. `The @@ -73,31 +67,26 @@ Maintainers and triage team members In addition to the above, maintainers and the triage team can do the following important tasks: -- Update labels for issues and PRs: see the list of `available GitHub +* Update labels for issues and PRs: see the list of `available GitHub labels `_. +* Triage issues: -- Triage issues: - - - **reproduce the issue**, if the posted code is a bug label the issue - with "status: confirmed bug". - - - **identify regressions**, determine if the reported bug used to + * **reproduce the issue**, and if the posted code is a bug label the issue + with `status: confirmed bug `_. + * **identify regressions**, determine if the reported bug used to work as expected in a recent version of Matplotlib and if so determine the last working version. Regressions should be milestoned for the next bug-fix release and may be labeled as "Release critical". - - - **close usage questions** and politely point the reporter to use - `discourse `_ or Stack Overflow - instead and label as "community support". - - - **close duplicate issues**, after checking that they are + * **close duplicate issues**, after checking that they are indeed duplicate. Ideally, the original submitter moves the - discussion to the older, duplicate issue - - - **close issues that cannot be replicated**, after leaving time (at - least a week) to add extra information - + discussion to the older, duplicate issue. + * **close issues that cannot be replicated**, after leaving time (at + least a week) to add extra information. + * **invite contributors to engage with the community** if the issue requires + more information or discussion. These discussions can take place in the + `weekly community meetings `__, or + on `discourse `__. .. topic:: Closing issues: a tough call @@ -107,13 +96,6 @@ important tasks: question or has been considered as unclear for many years, then it should be closed. -Preparing PRs for review -======================== - -Reviewing code is also encouraged. Contributors and users are welcome to -participate to the review process following our :ref:`review guidelines -`. - .. _triage_workflow: Triage workflow @@ -127,13 +109,19 @@ The following workflow is a good way to approach issue triaging: Matplotlib project itself, beyond just using the library. As such, we want it to be a welcoming, pleasant experience. -#. Is this a usage question? If so close it with a polite message. +#. Is this a usage question? + + If so, close it with a polite message, point the reporter to use + `discourse `__ or Stack Overflow instead + and use the + `community support `__ + label, if you have the necessary permissions. #. Is the necessary information provided? Check that the poster has filled in the issue template. If crucial information (the version of Python, the version of Matplotlib used, - the OS, and the backend), is missing politely ask the original + the OS, and the backend) is missing, politely ask the original poster to provide the information. #. Is the issue minimal and reproducible? @@ -154,7 +142,7 @@ The following workflow is a good way to approach issue triaging: OS, Python, and Matplotlib versions. If we need more information from either this or the previous step - please label the issue with "status: needs clarification". + please label the issue with `status: needs clarification `_. #. Is this a regression? @@ -169,7 +157,6 @@ The following workflow is a good way to approach issue triaging: `_ to find the first commit where it was broken. - #. Is this a duplicate issue? We have many open issues. If a new issue seems to be a duplicate, @@ -182,32 +169,69 @@ The following workflow is a good way to approach issue triaging: slightly different example, add it to the original issue as a comment or an edit to the original post. - Label the closed issue with "status: duplicate" + Label the closed issue with `status: duplicate `__. #. Make sure that the title accurately reflects the issue. If you have the necessary permissions edit it yourself if it's not clear. -#. Add the relevant labels, such as "Documentation" when the issue is - about documentation, "Bug" if it is clearly a bug, "New feature" if it - is a new feature request, ... +#. Add the relevant labels, such as `Documentation `__ + when the issue is about documentation, `status: confirmed bug `__ + if it is clearly a bug, `New feature `__ + if it is a new feature request, etc. + + An additional useful step can be to tag with the relevant "topic: ..." label, + e.g. "topic: widgets/UI" or "topic: animation". + + Take some time to familiarize yourself with the available labels and their + meaning, and try to use them consistently. + +.. topic:: Good first issues + + If the issue is clearly defined, the fix seems relatively straightforward, + and there is consensus on what the solution is among maintainers, label the + issue as + `Good first issue `_ + (and possibly a description of the fix or a hint as to where in the + code base to look to get started). - If the issue is clearly defined and the fix seems relatively - straightforward, label the issue as “Good first issue” (and - possibly a description of the fix or a hint as to where in the - code base to look to get started). + Note that good first issues are intended to onboard newcomers with a genuine + interest in improving Matplotlib, in the hopes that they will continue to + participate in our development community; therefore, the use of AI tools to + resolve these issues is not appropriate. - An additional useful step can be to tag the corresponding module e.g. - the "GUI/Qt" label when relevant. +Preparing PRs for review +======================== + +Doing initial reviews of contributions is also encouraged. Contributors and +users are welcome to participate to the review process following our +:ref:`review guidelines `. In particular, if you identify a PR +that needs maintainer attention, you can add the +`status: needs review `_ +label to it, or add it to the next community meeting agenda for discussion. You +can: + +* Suggest fixes to CI check failures, such as failing tests or documentation + builds; +* Help with :ref:`rebasing instructions `; +* Suggest improvements to the PR description, including filling out the AI + Disclosure section if it is missing. + +AI-generated contributions +-------------------------- + +Make sure PRs comply with our :ref:`AI policy `. If you identify +a PR that does not comply with the policy, ask the contributor to clarify the AI +tools used and the contribution of the author, and to update the PR description +accordingly to comply with our AI policy. .. _triage_team: Triage team =========== - If you would like to join the triage team: -1. Correctly triage 2-3 issues. +1. Correctly triage 2-3 issues or review 2-3 pull requests, as described above. 2. Ask someone on in the Matplotlib organization (publicly or privately) to recommend you to the triage team (look for "Member" on the top-right of comments on GitHub). If you worked with someone on the issues triaged, they @@ -215,4 +239,15 @@ If you would like to join the triage team: 3. Responsibly exercise your new power! Anyone with commit or triage rights may nominate a user to be invited to join -the triage team by emailing matplotlib-steering-council@numfocus.org . +the triage team by nominating them through the private "Triage team nominations" +category on `Discourse `__ (Note that only +``@maintainers`` and ``@triage`` members can see this category). The nomination +will then be confirmed by the Steering Council and the user, if accepted, will +be added to the triage team on GitHub. + +If no objections are raised within one week of the nomination, a member with the ``owner`` role on GitHub will: +1. Send an invitation email to the nominee following a template. +2. Once the nominee responds affirmatively, they will add the nominee to the Triage group on GitHub, and to the ``@triage`` group on Discourse. +3. Close the Discourse thread with a confirmation that the nomination was accepted (or turned down). + +If objections are raised, no action will be taken and the nomination can be revisited in the future. diff --git a/doc/install/dependencies.rst b/doc/install/dependencies.rst index 8f638ea5ed1d..319b60cb4fb8 100644 --- a/doc/install/dependencies.rst +++ b/doc/install/dependencies.rst @@ -20,13 +20,13 @@ 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) -* `contourpy `_ (>= 1.0.1) +* `Python `_ (>= 3.12) +* `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) @@ -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/ @@ -232,7 +230,7 @@ Python ``pip`` normally builds packages using :external+pip:doc:`build isolation `, which means that ``pip`` installs the dependencies listed here for the -duration of the build process. However, build isolation is disabled via the the +duration of the build process. However, build isolation is disabled via the :external+pip:ref:`--no-build-isolation ` flag when :ref:`installing Matplotlib for development `, which means that the dependencies must be explicitly installed, either by :ref:`creating a virtual environment ` diff --git a/doc/release/next_whats_new/pie_wedge_labels.rst b/doc/release/next_whats_new/pie_wedge_labels.rst new file mode 100644 index 000000000000..9c72742e005e --- /dev/null +++ b/doc/release/next_whats_new/pie_wedge_labels.rst @@ -0,0 +1,26 @@ +New *wedge_labels* parameter for pie +------------------------------------ + +`~.Axes.pie` now accepts a *wedge_labels* parameter as a shortcut to the +`~.Axes.pie_label` method. This may be used for simple annotation of the wedges +of the pie chart. It can take + +* a list of strings, similar to the existing *labels* parameter +* a format string similar to the existing *autopct* parameter, except that it + uses the `str.format` method and it can handle absolute values as well as + fractions/percentages + +*wedge_labels* has an accompanying *wedge_label_distance* parameter, to control +the distance of the labels from the center of the pie. + + +.. plot:: + :include-source: true + :alt: Two pie charts. The chart on the left has labels 'foo' and 'bar' outside the wedges. The chart on the right has labels '1' and '2' inside the wedges. + + import matplotlib.pyplot as plt + + fig, (ax1, ax2) = plt.subplots(ncols=2, layout='constrained') + + ax1.pie([1, 2], wedge_labels=['foo', 'bar'], wedge_label_distance=1.1) + ax2.pie([1, 2], wedge_labels='{absval:d}', wedge_label_distance=0.6) diff --git a/doc/release/next_whats_new/plot_skip_execution.rst b/doc/release/next_whats_new/plot_skip_execution.rst new file mode 100644 index 000000000000..75d95bbada17 --- /dev/null +++ b/doc/release/next_whats_new/plot_skip_execution.rst @@ -0,0 +1,11 @@ +New config option for ``matplotlib.sphinxext.plot_directive``: ``plot_skip_execution`` +-------------------------------------------------------------------------------------- + +This configuration option allows users to temporarily skip the execution of all +plot directives, not running the code or generating the plots. It is intended to +be used during development to speed up building documentation that contains many +plot directives. + +It can be temporarily enabled from the command line by passing ``-D +plot_skip_execution=1`` to ``sphinx-build``, e.g.,: ``make html O="-D +plot_skip_execution=1"``. diff --git a/doc/release/next_whats_new/polar_get_rlim_thetalim.rst b/doc/release/next_whats_new/polar_get_rlim_thetalim.rst new file mode 100644 index 000000000000..57586d2a32ce --- /dev/null +++ b/doc/release/next_whats_new/polar_get_rlim_thetalim.rst @@ -0,0 +1,15 @@ +``PolarAxes.get_rlim()`` and ``get_thetalim()`` added +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:class:`~matplotlib.projections.polar.PolarAxes` now provides +`~matplotlib.projections.polar.PolarAxes.get_rlim` and +`~matplotlib.projections.polar.PolarAxes.get_thetalim` to complement the +existing `~matplotlib.projections.polar.PolarAxes.set_rlim` and +`~matplotlib.projections.polar.PolarAxes.set_thetalim`. Previously, one +had to use `.Axes.get_ylim`, `.Axes.get_xlim` as a workaround. + +:: + + ax = plt.subplot(projection="polar") + ax.set_rlim(1, 5) + rmin, rmax = ax.get_rlim() # was: AttributeError diff --git a/doc/users/resources/index.rst b/doc/users/resources/index.rst index a31dbc83aa9d..148279b94678 100644 --- a/doc/users/resources/index.rst +++ b/doc/users/resources/index.rst @@ -71,7 +71,7 @@ Videos Tutorials ========= -* `Matplotlib tutorial `_ +* `Matplotlib tutorial `_ by Nicolas P. Rougier * `Anatomy of Matplotlib - IPython Notebooks diff --git a/environment.yml b/environment.yml index ed9cc7009541..27624759ea86 100644 --- a/environment.yml +++ b/environment.yml @@ -13,20 +13,20 @@ 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 - pyparsing>=3 - pyqt - - python>=3.11 + - python>=3.12 - python-dateutil>=2.1 - setuptools_scm<10 - wxpython diff --git a/galleries/examples/images_contours_and_fields/image_antialiasing.py b/galleries/examples/images_contours_and_fields/image_antialiasing.py index 10f563875767..5d3c2f81b843 100644 --- a/galleries/examples/images_contours_and_fields/image_antialiasing.py +++ b/galleries/examples/images_contours_and_fields/image_antialiasing.py @@ -63,7 +63,7 @@ # # ``interpolation_stage='data'``: Data -> Interpolate/Resample -> Normalize -> RGBA # -# For both keyword arguments, Matplotlib has a default "antialiased", that is +# For both keyword arguments, Matplotlib has a default "auto", that is # recommended for most situations, and is described below. Note that this # default behaves differently if the image is being down- or up-sampled, as # described below. @@ -166,12 +166,12 @@ # %% # A final example shows the desirability of performing the anti-aliasing at the # RGBA stage when using non-trivial interpolation kernels. In the following, -# the data in the upper 100 rows is exactly 0.0, and data in the inner circle +# the data in the outer circle is exactly 0.0, and data in the inner circle # is exactly 2.0. If we perform the *interpolation_stage* in 'data' space and # use an anti-aliasing filter (first panel), then floating point imprecision # makes some of the data values just a bit less than zero or a bit more than # 2.0, and they get assigned the under- or over- colors. This can be avoided if -# you do not use an anti-aliasing filter (*interpolation* set set to +# you do not use an anti-aliasing filter (*interpolation* set to # 'nearest'), however, that makes the part of the data susceptible to Moiré # patterns much worse (second panel). Therefore, we recommend the default # *interpolation* of 'hanning'/'auto', and *interpolation_stage* of diff --git a/galleries/examples/misc/svg_filter_pie.py b/galleries/examples/misc/svg_filter_pie.py index f8ccc5bcb22b..d438fe77b8a6 100644 --- a/galleries/examples/misc/svg_filter_pie.py +++ b/galleries/examples/misc/svg_filter_pie.py @@ -28,11 +28,11 @@ # We want to draw the shadow for each pie, but we will not use "shadow" # option as it doesn't save the references to the shadow patches. -pie = ax.pie(fracs, explode=explode, labels=labels, autopct='%1.1f%%') +pie = ax.pie(fracs, explode=explode, wedge_labels=labels, wedge_label_distance=1.1) -for w in pie.wedges: +for w, label in zip(pie.wedges, labels): # set the id with the label. - w.set_gid(w.get_label()) + w.set_gid(label) # we don't want to draw the edge of the pie w.set_edgecolor("none") diff --git a/galleries/examples/pie_and_polar_charts/bar_of_pie.py b/galleries/examples/pie_and_polar_charts/bar_of_pie.py index 7c703976db2e..6e58bba5209d 100644 --- a/galleries/examples/pie_and_polar_charts/bar_of_pie.py +++ b/galleries/examples/pie_and_polar_charts/bar_of_pie.py @@ -25,8 +25,11 @@ explode = [0.1, 0, 0] # rotate so that first wedge is split by the x-axis angle = -180 * overall_ratios[0] -pie = ax1.pie(overall_ratios, autopct='%1.1f%%', startangle=angle, - labels=labels, explode=explode) +pie = ax1.pie(overall_ratios, startangle=angle, explode=explode) + +# label the wedges with our label strings and the ratios as percentages +ax1.pie_label(pie, labels, distance=1.1) +ax1.pie_label(pie, '{frac:.1%}', distance=0.6) # bar chart parameters age_ratios = [.33, .54, .07, .06] diff --git a/galleries/examples/pie_and_polar_charts/pie_features.py b/galleries/examples/pie_and_polar_charts/pie_features.py index 8510c09f23a5..80b8ade230b2 100644 --- a/galleries/examples/pie_and_polar_charts/pie_features.py +++ b/galleries/examples/pie_and_polar_charts/pie_features.py @@ -15,15 +15,15 @@ # ------------ # # Plot a pie chart of animals and label the slices. To add -# labels, pass a list of labels to the *labels* parameter +# labels, pass a list of labels to the *wedge_labels* parameter. import matplotlib.pyplot as plt labels = 'Frogs', 'Hogs', 'Dogs', 'Logs' -sizes = [15, 30, 45, 10] +sizes = [12, 24, 36, 8] fig, ax = plt.subplots() -ax.pie(sizes, labels=labels) +ax.pie(sizes, wedge_labels=labels) # %% # Each slice of the pie chart is a `.patches.Wedge` object; therefore in @@ -31,16 +31,44 @@ # the *wedgeprops* argument, as demonstrated in # :doc:`/gallery/pie_and_polar_charts/nested_pie`. # +# Set label positions +# ------------------- +# If you want the labels outside the pie, set a *wedge_label_distance* greater than 1. +# This is the distance from the center of the pie as a fraction of its radius. + +fig, ax = plt.subplots() +ax.pie(sizes, wedge_labels=labels, wedge_label_distance=1.1) + +# %% +# # Auto-label slices # ----------------- # -# Pass a function or format string to *autopct* to label slices. +# Pass a format string to *wedge_labels* to label slices with their values... + +fig, ax = plt.subplots() +ax.pie(sizes, wedge_labels='{absval:.1f}') + +# %% +# +# ...or with their percentages... + +fig, ax = plt.subplots() +ax.pie(sizes, wedge_labels='{frac:.1%}') + +# %% +# +# ...or both. fig, ax = plt.subplots() -ax.pie(sizes, labels=labels, autopct='%1.1f%%') +ax.pie(sizes, wedge_labels='{absval:d}\n{frac:.1%}') + +# %% +# +# For more control over labels, or to add multiple sets, see +# :doc:`/gallery/pie_and_polar_charts/pie_label`. # %% -# By default, the label values are obtained from the percent size of the slice. # # Color slices # ------------ @@ -48,8 +76,7 @@ # Pass a list of colors to *colors* to set the color of each slice. fig, ax = plt.subplots() -ax.pie(sizes, labels=labels, - colors=['olivedrab', 'rosybrown', 'gray', 'saddlebrown']) +ax.pie(sizes, colors=['olivedrab', 'rosybrown', 'gray', 'saddlebrown']) # %% # Hatch slices @@ -58,22 +85,9 @@ # Pass a list of hatch patterns to *hatch* to set the pattern of each slice. fig, ax = plt.subplots() -ax.pie(sizes, labels=labels, hatch=['**O', 'oO', 'O.O', '.||.']) - -# %% -# Swap label and autopct text positions -# ------------------------------------- -# Use the *labeldistance* and *pctdistance* parameters to position the *labels* -# and *autopct* text respectively. - -fig, ax = plt.subplots() -ax.pie(sizes, labels=labels, autopct='%1.1f%%', - pctdistance=1.25, labeldistance=.6) +ax.pie(sizes, hatch=['**O', 'oO', 'O.O', '.||.']) # %% -# *labeldistance* and *pctdistance* are ratios of the radius; therefore they -# vary between ``0`` for the center of the pie and ``1`` for the edge of the -# pie, and can be set to greater than ``1`` to place text outside the pie. # # Explode, shade, and rotate slices # --------------------------------- @@ -86,11 +100,10 @@ # # This example orders the slices, separates (explodes) them, and rotates them. -explode = (0, 0.1, 0, 0) # only "explode" the 2nd slice (i.e. 'Hogs') +explode = (0, 0.2, 0, 0) # only "explode" the 2nd slice (i.e. 'Hogs') fig, ax = plt.subplots() -ax.pie(sizes, explode=explode, labels=labels, autopct='%1.1f%%', - shadow=True, startangle=90) +ax.pie(sizes, explode=explode, wedge_labels='{frac:.1%}', shadow=True, startangle=90) plt.show() # %% @@ -107,8 +120,7 @@ fig, ax = plt.subplots() -ax.pie(sizes, labels=labels, autopct='%.0f%%', - textprops={'size': 'small'}, radius=0.5) +ax.pie(sizes, wedge_labels='{frac:.1%}', textprops={'size': 'small'}, radius=0.5) plt.show() # %% @@ -119,8 +131,8 @@ # the `.Shadow` patch. This can be used to modify the default shadow. fig, ax = plt.subplots() -ax.pie(sizes, explode=explode, labels=labels, autopct='%1.1f%%', - shadow={'ox': -0.04, 'edgecolor': 'none', 'shade': 0.9}, startangle=90) +ax.pie(sizes, explode=explode, shadow={'ox': -0.04, 'edgecolor': 'none', 'shade': 0.9}, + startangle=90) plt.show() # %% 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/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/_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/__init__.pyi b/lib/matplotlib/_api/__init__.pyi index aeefaa35ffaf..6b6ad583e528 100644 --- a/lib/matplotlib/_api/__init__.pyi +++ b/lib/matplotlib/_api/__init__.pyi @@ -1,6 +1,5 @@ from collections.abc import Callable, Generator, Iterable, Mapping, Sequence -from typing import Any, TypeVar, overload -from typing import Self +from typing import Any, Self, overload from numpy.typing import NDArray @@ -16,14 +15,13 @@ from .deprecation import ( # noqa: F401, re-exported API MatplotlibDeprecationWarning as MatplotlibDeprecationWarning, ) -_T = TypeVar("_T") - class _Unset: ... +UNSET = _Unset() -class classproperty(Any): +class classproperty[T](Any): def __init__( self, - fget: Callable[[_T], Any], + fget: Callable[[T], Any], fset: None = ..., fdel: None = ..., doc: str | None = None, @@ -33,7 +31,7 @@ class classproperty(Any): @overload def __get__(self, instance: object, owner: type[object]) -> Any: ... @property - def fget(self) -> Callable[[_T], Any]: ... + def fget(self) -> Callable[[T], Any]: ... def check_isinstance( types: type | tuple[type | None, ...], /, **kwargs: Any @@ -41,14 +39,14 @@ def check_isinstance( def list_suggestion_error_msg(name: str, potential: Any, values: Sequence[Any]) -> str: ... def check_in_list(values: Sequence[Any], /, **kwargs: Any) -> None: ... def check_shape(shape: tuple[int | None, ...], /, **kwargs: NDArray) -> None: ... -def getitem_checked(mapping: Mapping[Any, _T], /, _error_cls: type[Exception] = ..., **kwargs: Any) -> _T: ... +def getitem_checked[T](mapping: Mapping[Any, T], /, _error_cls: type[Exception] = ..., **kwargs: Any) -> T: ... def caching_module_getattr(cls: type) -> Callable[[str], Any]: ... @overload -def define_aliases( +def define_aliases[T]( alias_d: dict[str, list[str]], cls: None = ... -) -> Callable[[type[_T]], type[_T]]: ... +) -> Callable[[type[T]], type[T]]: ... @overload -def define_aliases(alias_d: dict[str, list[str]], cls: type[_T]) -> type[_T]: ... +def define_aliases[T](alias_d: dict[str, list[str]], cls: type[T]) -> type[T]: ... def select_matching_signature( funcs: list[Callable], *args: Any, **kwargs: Any ) -> Any: ... diff --git a/lib/matplotlib/_api/deprecation.pyi b/lib/matplotlib/_api/deprecation.pyi index e050290662d9..11f84b3d0484 100644 --- a/lib/matplotlib/_api/deprecation.pyi +++ b/lib/matplotlib/_api/deprecation.pyi @@ -1,13 +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 -) - -_P = ParamSpec("_P") -_R = TypeVar("_R") -_T = TypeVar("_T") +from typing import Any, Literal, TypedDict, Unpack, overload class MatplotlibDeprecationWarning(DeprecationWarning): ... @@ -23,9 +16,9 @@ class NamedDeprecationKwargs(DeprecationKwargs, total=False): name: str def warn_deprecated(since: str, **kwargs: Unpack[NamedDeprecationKwargs]) -> None: ... -def deprecated( +def deprecated[T]( since: str, **kwargs: Unpack[NamedDeprecationKwargs] -) -> Callable[[_T], _T]: ... +) -> Callable[[T], T]: ... class deprecate_privatize_attribute(Any): def __init__(self, since: str, **kwargs: Unpack[NamedDeprecationKwargs]): ... @@ -34,42 +27,42 @@ class deprecate_privatize_attribute(Any): DECORATORS: dict[Callable, Callable] = ... @overload -def rename_parameter( +def rename_parameter[**P, R]( since: str, old: str, new: str, func: None = ... -) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]: ... +) -> Callable[[Callable[P, R]], Callable[P, R]]: ... @overload -def rename_parameter( - since: str, old: str, new: str, func: Callable[_P, _R] -) -> Callable[_P, _R]: ... +def rename_parameter[**P, R]( + since: str, old: str, new: str, func: Callable[P, R] +) -> Callable[P, R]: ... class _deprecated_parameter_class: ... _deprecated_parameter: _deprecated_parameter_class @overload -def delete_parameter( +def delete_parameter[**P, R]( since: str, name: str, func: None = ..., **kwargs: Unpack[DeprecationKwargs] -) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]: ... +) -> Callable[[Callable[P, R]], Callable[P, R]]: ... @overload -def delete_parameter( - since: str, name: str, func: Callable[_P, _R], **kwargs: Unpack[DeprecationKwargs] -) -> Callable[_P, _R]: ... +def delete_parameter[**P, R]( + since: str, name: str, func: Callable[P, R], **kwargs: Unpack[DeprecationKwargs] +) -> Callable[P, R]: ... @overload -def make_keyword_only( +def make_keyword_only[**P, R]( since: str, name: str, func: None = ... -) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]: ... +) -> Callable[[Callable[P, R]], Callable[P, R]]: ... @overload -def make_keyword_only( - since: str, name: str, func: Callable[_P, _R] -) -> Callable[_P, _R]: ... -def deprecate_method_override( - method: Callable[_P, _R], +def make_keyword_only[**P, R]( + since: str, name: str, func: Callable[P, R] +) -> Callable[P, R]: ... +def deprecate_method_override[**P, R]( + method: Callable[P, R], obj: object | type, *, allow_empty: bool = ..., since: str, **kwargs: Unpack[NamedDeprecationKwargs] -) -> Callable[_P, _R]: ... +) -> Callable[P, R]: ... def suppress_matplotlib_deprecation_warning() -> ( contextlib.AbstractContextManager[None] ): ... diff --git a/lib/matplotlib/_docstring.pyi b/lib/matplotlib/_docstring.pyi index 7bb256a3032b..49d7f08cb346 100644 --- a/lib/matplotlib/_docstring.pyi +++ b/lib/matplotlib/_docstring.pyi @@ -1,11 +1,8 @@ from collections.abc import Callable -from typing import Any, TypeVar, overload +from typing import Any, overload -_T = TypeVar('_T') - - -def kwarg_doc(text: str) -> Callable[[_T], _T]: ... +def kwarg_doc[T](text: str) -> Callable[[T], T]: ... class Substitution: @@ -13,7 +10,7 @@ class Substitution: def __init__(self, *args: str): ... @overload def __init__(self, **kwargs: str): ... - def __call__(self, func: _T) -> _T: ... + def __call__[T](self, func: T) -> T: ... class _ArtistKwdocLoader(dict[str, str]): @@ -23,10 +20,10 @@ class _ArtistKwdocLoader(dict[str, str]): class _ArtistPropertiesSubstitution: def __init__(self) -> None: ... def register(self, **kwargs) -> None: ... - def __call__(self, obj: _T) -> _T: ... + def __call__[T](self, obj: T) -> T: ... -def copy(source: Any) -> Callable[[_T], _T]: ... +def copy[T](source: Any) -> Callable[[T], T]: ... dedent_interpd: _ArtistPropertiesSubstitution diff --git a/lib/matplotlib/_mathtext_data.py b/lib/matplotlib/_mathtext_data.py index 6d0c20a1b2a2..5430bc29fd05 100644 --- a/lib/matplotlib/_mathtext_data.py +++ b/lib/matplotlib/_mathtext_data.py @@ -3,7 +3,7 @@ """ from __future__ import annotations -from typing import TypeAlias, overload +from typing import overload from .ft2font import CharacterCodeType @@ -1177,9 +1177,8 @@ # Each element is a 4-tuple of the form: # src_start, src_end, dst_font, dst_start -_EntryTypeIn: TypeAlias = tuple[str, str, str, str | CharacterCodeType] -_EntryTypeOut: TypeAlias = tuple[CharacterCodeType, CharacterCodeType, str, - CharacterCodeType] +type _EntryTypeIn = tuple[str, str, str, str | CharacterCodeType] +type _EntryTypeOut = tuple[CharacterCodeType, CharacterCodeType, str, CharacterCodeType] _stix_virtual_fonts: dict[str, dict[str, list[_EntryTypeIn]] | list[_EntryTypeIn]] = { 'bb': { diff --git a/lib/matplotlib/_type1font.py b/lib/matplotlib/_type1font.py index b4ee5edb233c..c7b73f9c0c7e 100644 --- a/lib/matplotlib/_type1font.py +++ b/lib/matplotlib/_type1font.py @@ -866,17 +866,16 @@ def subset(self, characters, name_prefix): encoding = {code: glyph for code, glyph in self.prop['Encoding'].items() if code in characters} - encoding[0] = '.notdef' # todo and done include strings (glyph names) - todo = set(encoding.values()) + todo = {'.notdef', *encoding.values()} done = set() seen_subrs = {0, 1, 2, 3} while todo: glyph = todo.pop() called_glyphs, called_subrs = _CharstringSimulator(self).run(glyph) + done.add(glyph) todo.update(called_glyphs - done) seen_subrs.update(called_subrs) - done.add(glyph) charstrings = self._subset_charstrings(done) subrs = self._subset_subrs(seen_subrs) @@ -949,7 +948,8 @@ def _charstring_tokens(data): def _postscript_encoding(self, encoding): """Return a PostScript encoding array for the encoding.""" return '\n'.join([ - '/Encoding 256 array\n0 1 255 { 1 index exch /.notdef put} for', + '/Encoding 256 array', + '0 1 255 { 1 index exch /.notdef put} for', *( f'dup {i} /{glyph} put' for i, glyph in sorted(encoding.items()) diff --git a/lib/matplotlib/artist.pyi b/lib/matplotlib/artist.pyi index 7b8b0c36be69..803b17dc36c6 100644 --- a/lib/matplotlib/artist.pyi +++ b/lib/matplotlib/artist.pyi @@ -15,16 +15,14 @@ from .transforms import ( import numpy as np from collections.abc import Callable, Iterable -from typing import Any, Literal, NamedTuple, TextIO, overload, TypeVar +from typing import Any, Literal, NamedTuple, TextIO, overload from numpy.typing import ArrayLike -_T_Artist = TypeVar("_T_Artist", bound=Artist) - def allow_rasterization(draw): ... class _XYPair(NamedTuple): - x: ArrayLike - y: ArrayLike + x: list[float] + y: list[float] class Artist: zorder: float @@ -143,11 +141,11 @@ class Artist: ) -> list[Artist]: ... @overload - def findobj( + def findobj[T: Artist]( self, - match: type[_T_Artist], + match: type[T], include_self: bool = ..., - ) -> list[_T_Artist]: ... + ) -> list[T]: ... def get_cursor_data(self, event: MouseEvent) -> Any: ... def format_cursor_data(self, data: Any) -> str: ... diff --git a/lib/matplotlib/axes/__init__.pyi b/lib/matplotlib/axes/__init__.pyi index 7df38b8bde9e..9f38c13c9284 100644 --- a/lib/matplotlib/axes/__init__.pyi +++ b/lib/matplotlib/axes/__init__.pyi @@ -1,10 +1,6 @@ -from typing import TypeVar - from ._axes import Axes as Axes -_T = TypeVar("_T") - # Backcompat. Subplot = Axes @@ -13,4 +9,4 @@ class _SubplotBaseMeta(type): class SubplotBase(metaclass=_SubplotBaseMeta): ... -def subplot_class_factory(cls: type[_T]) -> type[_T]: ... +def subplot_class_factory[T](cls: type[T]) -> type[T]: ... diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index 565bd30d32bb..6102fd3d3ed3 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -41,6 +41,7 @@ BarContainer, ErrorbarContainer, PieContainer, StemContainer) from matplotlib.text import Text from matplotlib.transforms import _ScaledRotation +from matplotlib._api import UNSET as _UNSET _log = logging.getLogger(__name__) @@ -3534,13 +3535,13 @@ def stem(self, *args, linefmt=None, markerfmt=None, basefmt=None, bottom=0, self.add_container(stem_container) return stem_container - @_api.make_keyword_only("3.10", "explode") - @_preprocess_data(replace_names=["x", "explode", "labels", "colors"]) - def pie(self, x, explode=None, labels=None, colors=None, - autopct=None, pctdistance=0.6, shadow=False, labeldistance=1.1, - startangle=0, radius=1, counterclock=True, - wedgeprops=None, textprops=None, center=(0, 0), - frame=False, rotatelabels=False, *, normalize=True, hatch=None): + @_preprocess_data(replace_names=["x", "explode", "labels", "colors", + "wedge_labels"]) + def pie(self, x, *, explode=None, labels=None, colors=None, wedge_labels=None, + wedge_label_distance=0.6, autopct=None, pctdistance=0.6, shadow=False, + labeldistance=_UNSET, startangle=0, radius=1, counterclock=True, + wedgeprops=None, textprops=None, center=(0, 0), frame=False, + rotatelabels=False, normalize=True, hatch=None): """ Plot a pie chart. @@ -3560,7 +3561,13 @@ def pie(self, x, explode=None, labels=None, colors=None, of the radius with which to offset each wedge. labels : list, default: None - A sequence of strings providing the labels for each wedge + A sequence of strings providing the legend labels for each wedge. + + .. deprecated:: 3.12 + In future these labels will not appear on the wedges but only + be made available for the legend (see *labeldistance* below). + To place labels on the wedges, use *wedge_labels* or the + `pie_label` method. colors : :mpltype:`color` or list of :mpltype:`color`, default: None A sequence of colors through which the pie chart will cycle. If @@ -3573,12 +3580,35 @@ def pie(self, x, explode=None, labels=None, colors=None, .. versionadded:: 3.7 + wedge_labels : str or list of str, optional + A sequence of strings providing the labels for each wedge, or a format + string with ``absval`` and/or ``frac`` placeholders. For example, to label + each wedge with its value and the percentage in brackets:: + + wedge_labels="{absval:d} ({frac:.0%})" + + For more control or to add multiple sets of labels, use `pie_label` + instead. + + .. versionadded:: 3.12 + + wedge_label_distance : float, default: 0.6 + The radial position of the wedge labels, relative to the pie radius. + Values > 1 are outside the wedge and values < 1 are inside the wedge. + + .. versionadded:: 3.12 + autopct : None or str or callable, default: None If not *None*, *autopct* is a string or function used to label the wedges with their numeric value. The label will be placed inside the wedge. If *autopct* is a format string, the label will be ``fmt % pct``. If *autopct* is a function, then it will be called. + .. admonition:: Discouraged + + Consider using the *wedge_labels* parameter or `pie_label` + method instead. + pctdistance : float, default: 0.6 The relative distance along the radius at which the text generated by *autopct* is drawn. To draw the text outside the pie, @@ -3591,6 +3621,11 @@ def pie(self, x, explode=None, labels=None, colors=None, If set to ``None``, labels are not drawn but are still stored for use in `.legend`. + .. deprecated:: 3.12 + From v3.14 *labeldistance* will default to ``None`` and will + later be removed altogether. Use *wedge_labels* and + *wedge_label_distance* or the `pie_label` method instead. + shadow : bool or dict, default: False If bool, whether to draw a shadow beneath the pie. If dict, draw a shadow passing the properties in the dict to `.Shadow`. @@ -3672,8 +3707,33 @@ def pie(self, x, explode=None, labels=None, colors=None, raise ValueError('Cannot plot an unnormalized pie with sum(x) > 1') else: fracs = x + + if labeldistance is _UNSET: + # NB: when the labeldistance default changes, both labeldistance and + # rotatelabels should be deprecated for removal. + if labels is not None: + msg = ( + "From %(removal)s labeldistance will default to None, so that the " + "strings provided in the labels parameter are only available for " + "the legend. Later labeldistance will be removed completely. To " + "preserve existing behavior for now, pass labeldistance=1.1. " + "Consider using the wedge_labels parameter or the pie_label method " + "instead of the labels parameter." + ) + _api.warn_deprecated("3.12", message=msg) + labeldistance = 1.1 + if labels is None: labels = [''] * len(x) + else: + if wedge_labels is not None and labeldistance is not None: + raise ValueError( + 'wedge_labels is a replacement for labels when annotating the ' + 'wedges, so the two should not be used together unless ' + 'labeldistance is None. To add multiple sets of labels, use the ' + 'pie_label method.' + ) + if explode is None: explode = [0] * len(x) if len(x) != len(labels): @@ -3731,11 +3791,16 @@ def get_next_color(): pc = PieContainer(slices, x, normalize) - if labeldistance is None: + if wedge_labels is not None: + self.pie_label(pc, wedge_labels, distance=wedge_label_distance, + textprops=textprops) + + elif labeldistance is None: # Insert an empty list of texts for backwards compatibility of the # return value. pc.add_texts([]) - else: + + if labeldistance is not None: # Add labels to the wedges. labels_textprops = { 'fontsize': mpl.rcParams['xtick.labelsize'], diff --git a/lib/matplotlib/axes/_axes.pyi b/lib/matplotlib/axes/_axes.pyi index b43ad157f6b5..3e954a81190f 100644 --- a/lib/matplotlib/axes/_axes.pyi +++ b/lib/matplotlib/axes/_axes.pyi @@ -31,6 +31,7 @@ import matplotlib.tri as mtri import matplotlib.table as mtable import matplotlib.stackplot as mstack import matplotlib.streamplot as mstream +from matplotlib._api import _Unset import PIL.Image from collections.abc import Callable, Iterable, Sequence @@ -311,10 +312,12 @@ class Axes(_AxesBase): explode: ArrayLike | None = ..., labels: Sequence[str] | None = ..., colors: ColorType | Sequence[ColorType] | None = ..., + wedge_labels: str | Sequence | None = ..., + wedge_label_distance: float | Sequence = ..., autopct: str | Callable[[float], str] | None = ..., pctdistance: float = ..., shadow: bool = ..., - labeldistance: float | None = ..., + labeldistance: float | None | _Unset = ..., startangle: float = ..., radius: float = ..., counterclock: bool = ..., @@ -479,7 +482,7 @@ class Axes(_AxesBase): x: ArrayLike, y1: ArrayLike | float, y2: ArrayLike | float = ..., - where: Sequence[bool] | None = ..., + where: ArrayLike | None = ..., interpolate: bool = ..., step: Literal["pre", "post", "mid"] | None = ..., *, @@ -491,7 +494,7 @@ class Axes(_AxesBase): y: ArrayLike, x1: ArrayLike | float, x2: ArrayLike | float = ..., - where: Sequence[bool] | None = ..., + where: ArrayLike | None = ..., step: Literal["pre", "post", "mid"] | None = ..., interpolate: bool = ..., *, diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py index 0ddf18b12ec2..1a175b084316 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -1435,6 +1435,11 @@ def __clear(self): self.xaxis.set_clip_path(self.patch) self.yaxis.set_clip_path(self.patch) + # Lazy tick lists no longer trigger spine transform setup as a + # side effect, so nudge each spine explicitly. + for spine in self.spines.values(): + spine._ensure_transform_is_set() + if self._sharex is not None: self.xaxis.set_visible(xaxis_visible) self.patch.set_visible(patch_visible) @@ -2647,7 +2652,7 @@ def _unit_change_handler(self, axis_name, event=None): self._unit_change_handler, axis_name, event=object()) _api.check_in_list(self._axis_map, axis_name=axis_name) for line in self.lines: - line.recache_always() + line.recache(always=True) self.relim() self._request_autoscale_view(axis_name) @@ -3256,7 +3261,12 @@ def _update_title_position(self, renderer): if title.get_text(): for ax in axs: ax.yaxis.get_tightbbox(renderer) # update offsetText - if ax.yaxis.offsetText.get_text(): + # A hidden offset text (e.g. on the shared y axis of an + # inner subplot) is not drawn, so it must not move the + # title: its tight bbox is non-finite and would otherwise + # push the title to infinity. + if (ax.yaxis.offsetText.get_visible() + and ax.yaxis.offsetText.get_text()): bb = ax.yaxis.offsetText.get_tightbbox(renderer) if bb.intersection(title.get_tightbbox(renderer), bb): top = bb.ymax @@ -4737,7 +4747,10 @@ def get_tightbbox(self, renderer=None, *, call_axes_locator=True, for axis in self._axis_map.values(): if self.axison and axis.get_visible(): - ba = martist._get_tightbbox_for_layout_only(axis, renderer) + if for_layout_only: + ba = martist._get_tightbbox_for_layout_only(axis, renderer) + else: + ba = axis.get_tightbbox(renderer) if ba: bb.append(ba) self._update_title_position(renderer) diff --git a/lib/matplotlib/axes/_base.pyi b/lib/matplotlib/axes/_base.pyi index 4a70405346a5..eb055d806c30 100644 --- a/lib/matplotlib/axes/_base.pyi +++ b/lib/matplotlib/axes/_base.pyi @@ -27,11 +27,9 @@ from cycler import Cycler import numpy as np from numpy.typing import ArrayLike -from typing import Any, Literal, TypeVar, overload +from typing import Any, Literal, overload from matplotlib.typing import ColorType -_T = TypeVar("_T", bound=Artist) - class _axis_method_wrapper: attr_name: str method_name: str @@ -67,7 +65,7 @@ class _AxesBase(martist.Artist): def __init__( self, - fig: Figure, + fig: Figure | SubFigure, *args: tuple[float, float, float, float] | Bbox | int, facecolor: ColorType | None = ..., frameon: bool = ..., @@ -136,7 +134,7 @@ class _AxesBase(martist.Artist): def clear(self) -> None: ... def cla(self) -> None: ... - class ArtistList(Sequence[_T]): + class ArtistList[T: Artist](Sequence[T]): def __init__( self, axes: _AxesBase, @@ -145,21 +143,21 @@ class _AxesBase(martist.Artist): invalid_types: type | Iterable[type] | None = ..., ) -> None: ... def __len__(self) -> int: ... - def __iter__(self) -> Iterator[_T]: ... + def __iter__(self) -> Iterator[T]: ... @overload - def __getitem__(self, key: int) -> _T: ... + def __getitem__(self, key: int) -> T: ... @overload - def __getitem__(self, key: slice) -> list[_T]: ... + def __getitem__(self, key: slice) -> list[T]: ... @overload - def __add__(self, other: _AxesBase.ArtistList[_T]) -> list[_T]: ... + def __add__(self, other: _AxesBase.ArtistList[T]) -> list[T]: ... @overload def __add__(self, other: list[Any]) -> list[Any]: ... @overload def __add__(self, other: tuple[Any]) -> tuple[Any]: ... @overload - def __radd__(self, other: _AxesBase.ArtistList[_T]) -> list[_T]: ... + def __radd__(self, other: _AxesBase.ArtistList[T]) -> list[T]: ... @overload def __radd__(self, other: list[Any]) -> list[Any]: ... @overload diff --git a/lib/matplotlib/axis.py b/lib/matplotlib/axis.py index b0b576781c4f..349e728ba8ff 100644 --- a/lib/matplotlib/axis.py +++ b/lib/matplotlib/axis.py @@ -2,6 +2,7 @@ Classes for the ticks and x- and y-axis. """ +import contextlib import datetime import functools import logging @@ -255,6 +256,25 @@ def set_clip_path(self, path, transform=None): self.gridline.set_clip_path(path, transform) self.stale = True + def _configure_for_axis(self, axis, major): + """ + Apply axis-level configuration to a freshly-materialized Tick. + + Used by `_LazyTickList` to apply ``set_tick_params()`` overrides + held on the Axis and to stamp the clip state set via + ``Axis.set_clip_path`` onto the Tick and its gridline. + """ + # Subclasses of Axis (e.g. SkewXAxis in the skewt gallery example) + # may override _get_tick() without forwarding _{major,minor}_tick_kw, + # so apply them here. + tick_kw = axis._major_tick_kw if major else axis._minor_tick_kw + if tick_kw: + self._apply_params(**tick_kw) + for artist in (self, self.gridline): + artist.clipbox = axis.clipbox + artist._clippath = axis._clippath + artist._clipon = axis._clipon + def contains(self, mouseevent): """ Test whether the mouse event occurred in the Tick marks. @@ -548,6 +568,26 @@ def formatter(self, formatter): self._formatter = formatter +@contextlib.contextmanager +def _rc_context_raw(snapshot): + """ + Like ``mpl.rc_context(snapshot)`` but bypasses ``RcParams`` validators + on entry and exit; re-applying a snapshot to its own values must not + re-trigger one-shot validator warnings (e.g. ``toolbar='toolmanager'``). + ``snapshot=None`` is a no-op. + """ + if snapshot is None: + yield + return + rc = mpl.rcParams + orig = dict(rc) + rc._update_raw(snapshot) + try: + yield + finally: + rc._update_raw(orig) + + class _LazyTickList: """ A descriptor for lazy instantiation of tick lists. @@ -560,26 +600,26 @@ def __init__(self, major): self._major = major def __get__(self, instance, owner): + """Materialize the descriptor to a list with one configured tick.""" if instance is None: return self - else: - # instance._get_tick() can itself try to access the majorTicks - # attribute (e.g. in certain projection classes which override - # e.g. get_xaxis_text1_transform). In order to avoid infinite - # recursion, first set the majorTicks on the instance temporarily - # to an empty list. Then create the tick; note that _get_tick() - # may call reset_ticks(). Therefore, the final tick list is - # created and assigned afterwards. - if self._major: - instance.majorTicks = [] - tick = instance._get_tick(major=True) - instance.majorTicks = [tick] - return instance.majorTicks - else: - instance.minorTicks = [] - tick = instance._get_tick(major=False) - instance.minorTicks = [tick] - return instance.minorTicks + # 1. Bind a placeholder so reentrant access via _get_tick() (e.g. + # projections overriding get_xaxis_text1_transform) does not + # recurse back into this descriptor. + # 2. Build the tick under the rcParams snapshot from the last + # Axis.clear() so its sub-artists pick up the right rcParams. + # 3. Apply set_tick_params() overrides and axis state. + # 4. Re-bind the final list; _get_tick() may have called + # reset_ticks(), which pops the attribute, so this assignment + # is what makes future accesses skip the descriptor. + attr = 'majorTicks' if self._major else 'minorTicks' + setattr(instance, attr, ()) # placeholder; not appended to + with _rc_context_raw(instance._tick_rcParams): + tick = instance._get_tick(major=self._major) + tick._configure_for_axis(instance, self._major) + tick_list = [tick] + setattr(instance, attr, tick_list) + return tick_list class Axis(martist.Artist): @@ -684,6 +724,12 @@ def __init__(self, axes, *, pickradius=15, clear=True): # Initialize here for testing; later add API self._major_tick_kw = dict() self._minor_tick_kw = dict() + # Snapshot of rcParams from the last Axis.clear() (or + # set_tick_params(reset=True)); re-applied by _LazyTickList when + # it lazily materializes a Tick. Kept separate from + # _major_tick_kw/_minor_tick_kw, which hold user-provided + # set_tick_params() overrides rather than ambient rcParams. + self._tick_rcParams = None if clear: self.clear() @@ -881,12 +927,14 @@ def _reset_major_tick_kw(self): self._major_tick_kw['gridOn'] = ( mpl.rcParams['axes.grid'] and mpl.rcParams['axes.grid.which'] in ('both', 'major')) + self._tick_rcParams = dict(mpl.rcParams) def _reset_minor_tick_kw(self): self._minor_tick_kw.clear() self._minor_tick_kw['gridOn'] = ( mpl.rcParams['axes.grid'] and mpl.rcParams['axes.grid.which'] in ('both', 'minor')) + self._tick_rcParams = dict(mpl.rcParams) def clear(self): """ @@ -917,6 +965,11 @@ def clear(self): # Clear the callback registry for this axis, or it may "leak" self.callbacks = cbook.CallbackRegistry(signals=["units"]) + # Snapshot current rcParams so that a Tick materialized later by + # _LazyTickList (possibly outside any rc_context() active now) + # sees the same rcParams an eager pre-lazy tick would have. + self._tick_rcParams = dict(mpl.rcParams) + # whether the grids are on self._major_tick_kw['gridOn'] = ( mpl.rcParams['axes.grid'] and @@ -937,19 +990,46 @@ def reset_ticks(self): Each list starts with a single fresh Tick. """ - # Restore the lazy tick lists. - try: - del self.majorTicks - except AttributeError: - pass - try: - del self.minorTicks - except AttributeError: - pass - try: - self.set_clip_path(self.axes.patch) - except AttributeError: - pass + # Drop any materialized tick lists so the _LazyTickList descriptor is + # reactivated on next access. If ticks were already materialized, + # re-apply the axes-patch clip path; otherwise skip. + had_major = bool(self.__dict__.pop('majorTicks', None)) + had_minor = bool(self.__dict__.pop('minorTicks', None)) + if had_major or had_minor: + try: + self.set_clip_path(self.axes.patch) + except AttributeError: + pass + + def _existing_ticks(self, major=None): + """ + Yield already-materialized ticks without triggering the lazy descriptor. + + `majorTicks` and `minorTicks` are `_LazyTickList` descriptors that + create a fresh `.Tick` on first access. Several internal methods + (`set_clip_path`, `set_tick_params`) need to touch every + *already-materialized* tick without forcing materialization, because + doing so would + + (a) create throwaway Tick objects during ``Axes.__init__`` and + ``Axes.__clear`` + (b) risk re-entering the + ``Spine.set_position -> Axis.reset_ticks -> Axis.set_clip_path + -> _LazyTickList.__get__ -> Tick.__init__ -> Spine.set_position`` + cascade. + + Reading the instance ``__dict__`` directly bypasses the descriptor. + + Parameters + ---------- + major : bool, optional + If True, yield only major ticks; if False, only minor ticks; + if None (default), yield major followed by minor. + """ + if major is None or major: + yield from self.__dict__.get('majorTicks', ()) + if major is None or not major: + yield from self.__dict__.get('minorTicks', ()) def minorticks_on(self): """ @@ -1018,11 +1098,11 @@ def set_tick_params(self, which='major', reset=False, **kwargs): else: if which in ['major', 'both']: self._major_tick_kw.update(kwtrans) - for tick in self.majorTicks: + for tick in self._existing_ticks(major=True): tick._apply_params(**kwtrans) if which in ['minor', 'both']: self._minor_tick_kw.update(kwtrans) - for tick in self.minorTicks: + for tick in self._existing_ticks(major=False): tick._apply_params(**kwtrans) # labelOn and labelcolor also apply to the offset text. if 'label1On' in kwtrans or 'label2On' in kwtrans: @@ -1161,7 +1241,7 @@ def _translate_tick_params(cls, kw, reverse=False): def set_clip_path(self, path, transform=None): super().set_clip_path(path, transform) - for child in self.majorTicks + self.minorTicks: + for child in self._existing_ticks(): child.set_clip_path(path, transform) self.stale = True diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 27c3752858a7..633ce987269d 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -506,7 +506,7 @@ def draw_tex(self, gc, x, y, s, prop, angle, *, mtext=None): mtext : `~matplotlib.text.Text` The original text object to be rendered. """ - self._draw_text_as_path(gc, x, y, s, prop, angle, ismath="TeX") + self._draw_text_as_path(gc, x, y, s, prop, angle, ismath="TeX", mtext=mtext) def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): """ diff --git a/lib/matplotlib/backend_bases.pyi b/lib/matplotlib/backend_bases.pyi index 94a8522717cd..fe492f0dde66 100644 --- a/lib/matplotlib/backend_bases.pyi +++ b/lib/matplotlib/backend_bases.pyi @@ -19,7 +19,7 @@ from matplotlib.text import Text, TextToPath from matplotlib.transforms import Bbox, BboxBase, Transform, TransformedPath from collections.abc import Callable, Iterable, Sequence -from typing import Any, IO, Literal, NamedTuple, TypeVar, overload +from typing import Any, IO, Literal, NamedTuple, overload from numpy.typing import ArrayLike from .typing import ( CapStyleType, @@ -362,7 +362,6 @@ class FigureCanvasBase: @classmethod def get_default_filetype(cls) -> str: ... def get_default_filename(self) -> str: ... - _T = TypeVar("_T", bound=FigureCanvasBase) @overload def mpl_connect( diff --git a/lib/matplotlib/backend_managers.pyi b/lib/matplotlib/backend_managers.pyi index 9e59acb14eda..541572b60ae4 100644 --- a/lib/matplotlib/backend_managers.pyi +++ b/lib/matplotlib/backend_managers.pyi @@ -3,7 +3,7 @@ from matplotlib.backend_bases import FigureCanvasBase from matplotlib.figure import Figure from collections.abc import Callable, Iterable -from typing import Any, TypeVar +from typing import Any class ToolEvent: name: str @@ -48,8 +48,7 @@ class ToolManager: def get_tool_keymap(self, name: str) -> list[str]: ... def update_keymap(self, name: str, key: str | Iterable[str]) -> None: ... def remove_tool(self, name: str) -> None: ... - _T = TypeVar("_T", bound=backend_tools.ToolBase) - def add_tool(self, name: str, tool: type[_T], *args, **kwargs) -> _T: ... + def add_tool[T: backend_tools.ToolBase](self, name: str, tool: type[T], *args, **kwargs) -> T: ... def trigger_tool( self, name: str | backend_tools.ToolBase, diff --git a/lib/matplotlib/backends/_backend_pdf_ps.py b/lib/matplotlib/backends/_backend_pdf_ps.py index 87fbae4d749b..9675a3e74f8c 100644 --- a/lib/matplotlib/backends/_backend_pdf_ps.py +++ b/lib/matplotlib/backends/_backend_pdf_ps.py @@ -149,7 +149,7 @@ def add(self, charcode: str, glyph_index: GlyphIndexType, subset: int, ---------- charcode : CharacterCodeType The character code to record. - glyph : GlyphIndexType + glyph_index : GlyphIndexType The corresponding glyph index to record. subset : int The subset in which the subset character code resides. diff --git a/lib/matplotlib/backends/backend_pdf.py b/lib/matplotlib/backends/backend_pdf.py index f429bd9c9e4a..13890eba0c24 100644 --- a/lib/matplotlib/backends/backend_pdf.py +++ b/lib/matplotlib/backends/backend_pdf.py @@ -2717,7 +2717,8 @@ class FigureCanvasPdf(FigureCanvasBase): fixed_dpi = 72 filetypes = {'pdf': 'Portable Document Format'} - def get_default_filetype(self): + @classmethod + def get_default_filetype(cls): return 'pdf' def print_pdf(self, filename, *, diff --git a/lib/matplotlib/backends/backend_pdf.pyi b/lib/matplotlib/backends/backend_pdf.pyi new file mode 100644 index 000000000000..de6c19ffe238 --- /dev/null +++ b/lib/matplotlib/backends/backend_pdf.pyi @@ -0,0 +1,420 @@ +import os +import types +from collections.abc import Callable, Iterable, Sequence +from datetime import datetime +from enum import Enum +from functools import total_ordering +from typing import IO, Any, Literal, Protocol, Self + +import numpy as np +from _typeshed import ReadableBuffer, SupportsWrite +from numpy import typing as npt + +from matplotlib import _api, path, transforms +from matplotlib._type1font import Type1Font +from matplotlib.backend_bases import FigureCanvasBase, GraphicsContextBase +from matplotlib.dviread import DviFont +from matplotlib.figure import Figure +from matplotlib.font_manager import FontPath, FontProperties +from matplotlib.text import Text +from matplotlib.transforms import BboxBase, Transform, TransformedBbox, TransformedPath +from matplotlib.typing import ( + CapStyleType, + ColorType, + JoinStyleType, + LineStyleType, + RGBColorType, +) + +from . import _backend_pdf_ps + +# XXX: Some of these might be worth moving to `mpl.typing` +type _CommandType = list[_SupportsPdfReprExt] +type _CommandFuncType = Callable[..., _CommandType] +type _RectangleType = tuple[float, float, float, float] | list[float] +# struct definition SketchParams in _backend_agg_basic_types.h +type _SketchParamsType = tuple[float, float, float] +type _HatchType = str +type _HatchStyleType = tuple[ + ColorType | None, ColorType | None, _HatchType | None, float +] + +class _SupportsPdfRepr(Protocol): + def pdfRepr(self) -> bytes: ... + +type _SupportsPdfReprExt = ( + _SupportsPdfRepr + | float + | np.floating + | bool + | int + | np.integer + | str + | bytes + | dict[Name | bytes, _SupportsPdfReprExt] + | list[_SupportsPdfReprExt] + | tuple[_SupportsPdfReprExt, ...] + | None + | datetime + | BboxBase +) + +type _MetadataDict = dict[str, str | datetime | Name] + +def pdfRepr(obj: _SupportsPdfReprExt) -> bytes: ... + +class Reference: + def __init__(self, id: int) -> None: ... + def __repr__(self) -> str: ... + def pdfRepr(self) -> bytes: ... + def write( + self, contents: _SupportsPdfReprExt, file: SupportsWrite[bytes] + ) -> None: ... + +@total_ordering +class Name: + def __init__(self, name: Self | bytes | str) -> None: ... + def __repr__(self) -> str: ... + def __str__(self) -> str: ... + def __eq__(self, other: Any) -> bool: ... + def __lt__(self, other: Any) -> bool: ... + def __hash__(self) -> int: ... + def pdfRepr(self) -> bytes: ... + +class Verbatim: + def __init__(self, x: bytes) -> None: ... + def pdfRepr(self) -> bytes: ... + +class Op(Enum): + close_fill_stroke = b"b" + fill_stroke = b"B" + fill = b"f" + closepath = b"h" + close_stroke = b"s" + stroke = b"S" + endpath = b"n" + begin_text = b"BT" + end_text = b"ET" + curveto = b"c" + rectangle = b"re" + lineto = b"l" + moveto = b"m" + concat_matrix = b"cm" + use_xobject = b"Do" + setgray_stroke = b"G" + setgray_nonstroke = b"g" + setrgb_stroke = b"RG" + setrgb_nonstroke = b"rg" + setcolorspace_stroke = b"CS" + setcolorspace_nonstroke = b"cs" + setcolor_stroke = b"SCN" + setcolor_nonstroke = b"scn" + setdash = b"d" + setlinejoin = b"j" + setlinecap = b"J" + setgstate = b"gs" + gsave = b"q" + grestore = b"Q" + textpos = b"Td" + selectfont = b"Tf" + textmatrix = b"Tm" + textrise = b"Ts" + show = b"Tj" + showkern = b"TJ" + setlinewidth = b"w" + clip = b"W" + shading = b"sh" + def pdfRepr(self) -> bytes: ... + @classmethod + def paint_path(cls, fill: bool, stroke: bool) -> bytes: ... + +class Stream: + def __init__( + self, + id: int, + len: Reference | None, + file: PdfFile, + extra: dict[Name, Any] | None = None, + png: dict[Any, Any] | None = None, + ) -> None: ... + def end(self) -> None: ... + def write(self, data: bytes) -> None: ... + +class PdfFile: + def __init__( + self, + filename: str | os.PathLike | IO[Any], + metadata: _MetadataDict | None = None, + ) -> None: ... + @property + def dviFontInfo(self) -> dict[Name, types.SimpleNamespace]: ... + def newPage(self, width: float, height: float) -> None: ... + def newTextnote( + self, + text: _SupportsPdfReprExt, + positionRect: _RectangleType = [-100, -100, 0, 0], + ) -> None: ... + def finalize(self) -> None: ... + def close(self) -> None: ... + def write(self, data: ReadableBuffer) -> None: ... + def output(self, *data: _SupportsPdfReprExt) -> None: ... + def beginStream( + self, + id: int, + len: Reference | None, + extra: dict[Name, Any] | None = None, + png: dict[Any, Any] | None = None, + ) -> None: ... + def endStream(self) -> None: ... + def outputStream( + self, ref: Reference, data: bytes, *, extra: dict[Name, Any] | None = None + ) -> None: ... + def fontName(self, fontprop: FontPath | str, subset: int = 0) -> Name | None: ... + def dviFontName(self, dvifont: DviFont) -> Name: ... + def writeFonts(self) -> None: ... + @_api.delete_parameter("3.11", "fontfile") + def createType1Descriptor( + self, t1font: Type1Font, fontfile: Any = None + ) -> Reference: ... + def embedTTF( + self, + filename: Iterable[str | bytes | os.PathLike | FontPath] + | str + | bytes + | os.PathLike + | FontPath, + subset_index: int, + charmap: dict[int, int], + ) -> Reference: ... + def alphaState(self, alpha: tuple[float, float]) -> Name: ... + def writeExtGSTates(self) -> None: ... + def hatchPattern(self, hatch_style: _HatchStyleType) -> Name: ... + def writeHatches(self) -> None: ... + def addGouraudTriangles( + self, points: npt.ArrayLike, colors: npt.ArrayLike + ) -> tuple[Name, Reference]: ... + def writeGouraudTriangles(self) -> None: ... + def imageObject(self, image: npt.NDArray[np.uint8]) -> Name: ... + def writeImages(self) -> None: ... + def markerObject( + self, + path: path.Path, + trans: Transform, + fill: bool, + stroke: bool, + lw: float, + joinstyle: JoinStyleType, + capstyle: CapStyleType, + ) -> Name: ... + def writeMarkers(self) -> None: ... + def pathCollectionObject( + self, + gc: GraphicsContextBase, + path: path.Path, + trans: Transform, + padding: float, + filled: bool, + stroked: bool, + ) -> Name: ... + def writePathCollectionTemplates(self) -> None: ... + # types in _path.h::convert_to_string + @staticmethod + def pathOperations( + path: path.Path, + transform: Transform, + clip: _RectangleType | None = None, + simplify: bool | None = None, + sketch: _SketchParamsType | None = None, + ) -> list[Verbatim]: ... + def writePath( + self, + path: path.Path, + transform: Transform, + clip: bool = False, + sketch: _SketchParamsType | None = None, + ) -> None: ... + def reserveObject(self, name: str = "") -> Reference: ... + def recordXref(self, id: int) -> None: ... + def writeObject( + self, object: _SupportsPdfReprExt, contents: dict[str, _SupportsPdfReprExt] + ) -> None: ... + def writeXref(self) -> None: ... + def writeInfoDict(self) -> None: ... + def writeTrailer(self) -> None: ... + +class RendererPdf(_backend_pdf_ps.RendererPDFPSBase): + paths: tuple[ + Name, + path.Path, + Transform, + Reference, + JoinStyleType, + CapStyleType, + float, + bool, + bool, + ] + def __init__( + self, file: PdfFile, image_dpi: float, height: float, width: float + ): ... + def finalize(self) -> None: ... + def check_gc( + self, gc: GraphicsContextBase, fillcolor: ColorType | None = None + ) -> None: ... + def get_image_magnification(self) -> float: ... + def draw_image( + self, + gc: GraphicsContextBase, + x: float, + y: float, + im: npt.ArrayLike, + transform: transforms.Affine2DBase | None = None, + ) -> None: ... + def draw_path( + self, + gc: GraphicsContextBase, + path: path.Path, + transform: Transform, + rgbFace: ColorType | None = None, + ) -> None: ... + def draw_path_collection( + self, + gc: GraphicsContextBase, + master_transform: Transform, + paths: Sequence[path.Path], + all_transforms: Sequence[npt.ArrayLike], + offsets: npt.ArrayLike | Sequence[npt.ArrayLike], + offset_trans: Transform, + facecolors: ColorType | Sequence[ColorType], + edgecolors: ColorType | Sequence[ColorType], + linewidths: float | Sequence[float], + linestyles: LineStyleType | Sequence[LineStyleType], + antialiaseds: bool | Sequence[bool], + urls: str | Sequence[str], + offset_position: Any, + *, + hatchcolors: ColorType | Sequence[ColorType] | None = None, + ) -> None: ... + # XXX: Here the implementation relies on `fill` and `stroke` which are not + # in the interface of `GraphicsContextBase`. Here we use + # `GraphicsContextPdf` to annotate `gc`, as a result, `RendererPdf` does not + # strictly inherit from `RenderedBase` correctly. + def draw_markers( + self, + gc: GraphicsContextPdf, # type: ignore[override] + marker_path: path.Path, + marker_trans: Transform, + path: path.Path, + trans: Transform, + rgbFace: ColorType | None = None, + ) -> None: ... + def draw_gouraud_triangles( + self, + gc: GraphicsContextBase, + points: npt.ArrayLike, + colors: npt.ArrayLike, + trans: Transform, + ) -> None: ... + def draw_mathtext( + self, + gc: GraphicsContextBase, + x: float, + y: float, + s: str, + prop: FontProperties, + angle: float, + ) -> None: ... + def draw_tex( + self, + gc: GraphicsContextBase, + x: float, + y: float, + s: str, + prop: FontProperties, + angle: float, + *, + mtext: Text | None = None, + ) -> None: ... + def encode_string(self, s: str, fonttype: int) -> bytes: ... + def draw_text( + self, + gc: GraphicsContextBase, + x: float, + y: float, + s: str, + prop: FontProperties, + angle: float, + ismath: bool | Literal["TeX"] = False, + mtext: Text | None = None, + ) -> None: ... + def new_gc(self) -> GraphicsContextPdf: ... + +class GraphicsContextPdf(GraphicsContextBase): + file: PdfFile + capstyles: dict[CapStyleType, int] + joinstyles: dict[JoinStyleType, int] + commands: tuple[tuple[str, ...], _CommandFuncType] + def __init__(self, file: PdfFile): ... + def __repr__(self) -> str: ... + def stroke(self) -> bool: ... + def fill(self, *args: ColorType) -> bool: ... + def paint(self) -> Op: ... + def capstyle_cmd(self, style: CapStyleType) -> _CommandType: ... + def joinstyle_cmd(self, style: JoinStyleType) -> _CommandType: ... + def linewidth_cmd(self, width: float) -> _CommandType: ... + def dash_cmd(self, dashes: tuple[float, Sequence[float]]) -> _CommandType: ... + def alpha_cmd( + self, + alpha: tuple[float, float], + forced: bool, + effective_alphas: tuple[float, float], + ) -> _CommandType: ... + def hatch_cmd( + self, hatch: _HatchType, hatch_color: ColorType, hatch_linewidth: float + ) -> _CommandType: ... + def rgb_cmd(self, rgb: RGBColorType) -> _CommandType: ... + def fillcolor_cmd(self, rgb: RGBColorType) -> _CommandType: ... + def push(self) -> list[Op]: ... + def pop(self) -> list[Op]: ... + def clip_cmd( + self, cliprect: TransformedBbox, clippath: TransformedPath + ) -> _CommandType: ... + def delta(self, other: GraphicsContextBase) -> _CommandType: ... + def copy_properties(self, other: GraphicsContextBase) -> None: ... + def finalize(self) -> list[Op]: ... + +class PdfPages: + def __init__( + self, + filename: str | os.PathLike | IO[Any], + keep_empty: None = None, + metadata: _MetadataDict | None = None, + ) -> None: ... + def __enter__(self) -> Self: ... + def __exit__( + self, exc_type: type[BaseException] | None, exc_val: object, exc_tb: object + ) -> None: ... + def close(self) -> None: ... + def infodict(self) -> _MetadataDict: ... + def savefig( + self, figure: Figure | int | None = None, **kwargs: dict[str, Any] + ) -> None: ... + def get_pagecount(self) -> int: ... + def attach_note( + self, + text: _SupportsPdfReprExt, + positionRect: _RectangleType = [-100, -100, 0, 0], + ) -> None: ... + +class FigureCanvasPdf(FigureCanvasBase): + filetypes: dict[str, str] + @classmethod + def get_default_filetype(cls) -> str: ... + def print_pdf( + self, + filename: PdfPages | str | os.PathLike | IO[Any], + *, + bbox_inches_restore: _RectangleType | None = None, + metadata: _MetadataDict | None = None, + ) -> None: ... + def draw(self) -> None: ... diff --git a/lib/matplotlib/backends/backend_pgf.py b/lib/matplotlib/backends/backend_pgf.py index 36048fe016df..6b13c9f5b52e 100644 --- a/lib/matplotlib/backends/backend_pgf.py +++ b/lib/matplotlib/backends/backend_pgf.py @@ -761,7 +761,8 @@ class FigureCanvasPgf(FigureCanvasBase): "pdf": "LaTeX compiled PGF picture", "png": "Portable Network Graphics", } - def get_default_filetype(self): + @classmethod + def get_default_filetype(cls): return 'pdf' def _print_pgf_to_fh(self, fh, *, bbox_inches_restore=None): diff --git a/lib/matplotlib/backends/backend_ps.py b/lib/matplotlib/backends/backend_ps.py index 266e80f9a742..9ab1a9b79c0d 100644 --- a/lib/matplotlib/backends/backend_ps.py +++ b/lib/matplotlib/backends/backend_ps.py @@ -967,7 +967,8 @@ class FigureCanvasPS(FigureCanvasBase): filetypes = {'ps': 'Postscript', 'eps': 'Encapsulated Postscript'} - def get_default_filetype(self): + @classmethod + def get_default_filetype(cls): return 'ps' def _print_ps( diff --git a/lib/matplotlib/backends/backend_qt.py b/lib/matplotlib/backends/backend_qt.py index 58a1468b6b33..39450ee32065 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,13 @@ 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): + try: self._timer_stop() + except RuntimeError as e: + # Silence warning on shutdown. + ignore_msg = "wrapped C/C++ object of type QTimer has been deleted" + if str(e) != ignore_msg: + raise 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/backend_svg.py b/lib/matplotlib/backends/backend_svg.py index 24790356b9d7..da910f4bcbc0 100644 --- a/lib/matplotlib/backends/backend_svg.py +++ b/lib/matplotlib/backends/backend_svg.py @@ -1371,7 +1371,8 @@ def print_svgz(self, filename, **kwargs): gzip.GzipFile(mode='w', fileobj=fh) as gzipwriter): return self.print_svg(gzipwriter, **kwargs) - def get_default_filetype(self): + @classmethod + def get_default_filetype(cls): return 'svg' def draw(self): diff --git a/lib/matplotlib/backends/backend_template.py b/lib/matplotlib/backends/backend_template.py index 83aa6bb567c1..ad0efc63e0a3 100644 --- a/lib/matplotlib/backends/backend_template.py +++ b/lib/matplotlib/backends/backend_template.py @@ -199,7 +199,8 @@ def print_foo(self, filename, **kwargs): """ self.draw() - def get_default_filetype(self): + @classmethod + def get_default_filetype(cls): return 'foo' 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/backends/registry.pyi b/lib/matplotlib/backends/registry.pyi index 565f044bf212..d1ba09cb523b 100644 --- a/lib/matplotlib/backends/registry.pyi +++ b/lib/matplotlib/backends/registry.pyi @@ -25,7 +25,7 @@ class BackendRegistry: def backend_for_gui_framework(self, framework: str) -> str | None: ... def is_valid_backend(self, backend: str) -> bool: ... def list_all(self) -> list[str]: ... - def list_builtin(self, filter_: BackendFilter | None) -> list[str]: ... + def list_builtin(self, filter_: BackendFilter | None = ...) -> list[str]: ... def list_gui_frameworks(self) -> list[str]: ... def load_backend_module(self, backend: str) -> ModuleType: ... def resolve_backend(self, backend: str | None) -> tuple[str, str | None]: ... diff --git a/lib/matplotlib/backends/web_backend/js/mpl.js b/lib/matplotlib/backends/web_backend/js/mpl.js index 7745cbcf1e98..d42fe5e17972 100644 --- a/lib/matplotlib/backends/web_backend/js/mpl.js +++ b/lib/matplotlib/backends/web_backend/js/mpl.js @@ -183,17 +183,7 @@ mpl.figure.prototype._init_canvas = function () { 'z-index: 1;' ); - // Apply a ponyfill if ResizeObserver is not implemented by browser. - if (this.ResizeObserver === undefined) { - if (window.ResizeObserver !== undefined) { - this.ResizeObserver = window.ResizeObserver; - } else { - var obs = _JSXTOOLS_RESIZE_OBSERVER({}); - this.ResizeObserver = obs.ResizeObserver; - } - } - - this.resizeObserverInstance = new this.ResizeObserver(function (entries) { + this.resizeObserverInstance = new ResizeObserver(function (entries) { // There's no need to resize if the WebSocket is not connected: // - If it is still connecting, then we will get an initial resize from // Python once it connects. @@ -728,7 +718,3 @@ mpl.figure.prototype.toolbar_button_onclick = function (name) { mpl.figure.prototype.toolbar_button_onmouseover = function (tooltip) { this.message.textContent = tooltip; }; - -///////////////// REMAINING CONTENT GENERATED BY embed_js.py ///////////////// -// prettier-ignore -var _JSXTOOLS_RESIZE_OBSERVER=function(A){var t,i=new WeakMap,n=new WeakMap,a=new WeakMap,r=new WeakMap,o=new Set;function s(e){if(!(this instanceof s))throw new TypeError("Constructor requires 'new' operator");i.set(this,e)}function h(){throw new TypeError("Function is not a constructor")}function c(e,t,i,n){e=0 in arguments?Number(arguments[0]):0,t=1 in arguments?Number(arguments[1]):0,i=2 in arguments?Number(arguments[2]):0,n=3 in arguments?Number(arguments[3]):0,this.right=(this.x=this.left=e)+(this.width=i),this.bottom=(this.y=this.top=t)+(this.height=n),Object.freeze(this)}function d(){t=requestAnimationFrame(d);var s=new WeakMap,p=new Set;o.forEach((function(t){r.get(t).forEach((function(i){var r=t instanceof window.SVGElement,o=a.get(t),d=r?0:parseFloat(o.paddingTop),f=r?0:parseFloat(o.paddingRight),l=r?0:parseFloat(o.paddingBottom),u=r?0:parseFloat(o.paddingLeft),g=r?0:parseFloat(o.borderTopWidth),m=r?0:parseFloat(o.borderRightWidth),w=r?0:parseFloat(o.borderBottomWidth),b=u+f,F=d+l,v=(r?0:parseFloat(o.borderLeftWidth))+m,W=g+w,y=r?0:t.offsetHeight-W-t.clientHeight,E=r?0:t.offsetWidth-v-t.clientWidth,R=b+v,z=F+W,M=r?t.width:parseFloat(o.width)-R-E,O=r?t.height:parseFloat(o.height)-z-y;if(n.has(t)){var k=n.get(t);if(k[0]===M&&k[1]===O)return}n.set(t,[M,O]);var S=Object.create(h.prototype);S.target=t,S.contentRect=new c(u,d,M,O),s.has(i)||(s.set(i,[]),p.add(i)),s.get(i).push(S)}))})),p.forEach((function(e){i.get(e).call(e,s.get(e),e)}))}return s.prototype.observe=function(i){if(i instanceof window.Element){r.has(i)||(r.set(i,new Set),o.add(i),a.set(i,window.getComputedStyle(i)));var n=r.get(i);n.has(this)||n.add(this),cancelAnimationFrame(t),t=requestAnimationFrame(d)}},s.prototype.unobserve=function(i){if(i instanceof window.Element&&r.has(i)){var n=r.get(i);n.has(this)&&(n.delete(this),n.size||(r.delete(i),o.delete(i))),n.size||r.delete(i),o.size||cancelAnimationFrame(t)}},A.DOMRectReadOnly=c,A.ResizeObserver=s,A.ResizeObserverEntry=h,A}; // eslint-disable-line diff --git a/lib/matplotlib/backends/web_backend/package.json b/lib/matplotlib/backends/web_backend/package.json index 95bd8fdf54e6..e2a4009a971b 100644 --- a/lib/matplotlib/backends/web_backend/package.json +++ b/lib/matplotlib/backends/web_backend/package.json @@ -11,8 +11,5 @@ "lint:check": "npm run prettier:check && npm run eslint:check", "prettier": "prettier --write \"**/*{.ts,.tsx,.js,.jsx,.css,.json}\"", "prettier:check": "prettier --check \"**/*{.ts,.tsx,.js,.jsx,.css,.json}\"" - }, - "dependencies": { - "@jsxtools/resize-observer": "^1.0.4" } } diff --git a/lib/matplotlib/cbook.py b/lib/matplotlib/cbook.py index 2c8c830ee2d1..7aaf900d4e61 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/cbook.pyi b/lib/matplotlib/cbook.pyi index 4a9fcaa32e67..653e2219c5b7 100644 --- a/lib/matplotlib/cbook.pyi +++ b/lib/matplotlib/cbook.pyi @@ -11,16 +11,12 @@ from numpy.typing import ArrayLike from typing import ( Any, - Generic, IO, Literal, - TypeVar, overload, ) from collections.abc import Sequence -_T = TypeVar("_T") - def _get_running_interactive_framework() -> str | None: ... class CallbackRegistry: @@ -42,9 +38,9 @@ class CallbackRegistry: self, *, signal: Any | None = ... ) -> contextlib.AbstractContextManager[None]: ... -class silent_list(list[_T]): +class silent_list[T](list[T]): type: str | None - def __init__(self, type: str | None, seq: Iterable[_T] | None = ...) -> None: ... + def __init__(self, type: str | None, seq: Iterable[T] | None = ...) -> None: ... def strip_math(s: str) -> str: ... def is_writable_file_like(obj: Any) -> bool: ... @@ -87,37 +83,37 @@ def flatten( seq: Iterable[Any], scalarp: Callable[[Any], bool] = ... ) -> Generator[Any, None, None]: ... -class _Stack(Generic[_T]): +class _Stack[T]: def __init__(self) -> None: ... def clear(self) -> None: ... - def __call__(self) -> _T: ... + def __call__(self) -> T: ... def __len__(self) -> int: ... - def __getitem__(self, ind: int) -> _T: ... - def forward(self) -> _T: ... - def back(self) -> _T: ... - def push(self, o: _T) -> _T: ... - def home(self) -> _T: ... + def __getitem__(self, ind: int) -> T: ... + def forward(self) -> T: ... + def back(self) -> T: ... + def push(self, o: T) -> T: ... + def home(self) -> T: ... def safe_masked_invalid(x: ArrayLike, copy: bool = ...) -> np.ndarray: ... def print_cycles( objects: Iterable[Any], outstream: IO = ..., show_progress: bool = ... ) -> None: ... -class Grouper(Generic[_T]): - def __init__(self, init: Iterable[_T] = ...) -> None: ... - def __contains__(self, item: _T) -> bool: ... - def join(self, a: _T, *args: _T) -> None: ... - def joined(self, a: _T, b: _T) -> bool: ... - def remove(self, a: _T) -> None: ... - def __iter__(self) -> Iterator[list[_T]]: ... - def get_siblings(self, a: _T, *, include_self: bool = True) -> list[_T]: ... - -class GrouperView(Generic[_T]): - def __init__(self, grouper: Grouper[_T]) -> None: ... - def __contains__(self, item: _T) -> bool: ... - def __iter__(self) -> Iterator[list[_T]]: ... - def joined(self, a: _T, b: _T) -> bool: ... - def get_siblings(self, a: _T, *, include_self: bool = True) -> list[_T]: ... +class Grouper[T]: + def __init__(self, init: Iterable[T] = ...) -> None: ... + def __contains__(self, item: T) -> bool: ... + def join(self, a: T, *args: T) -> None: ... + def joined(self, a: T, b: T) -> bool: ... + def remove(self, a: T) -> None: ... + def __iter__(self) -> Iterator[list[T]]: ... + def get_siblings(self, a: T, *, include_self: bool = True) -> list[T]: ... + +class GrouperView[T]: + def __init__(self, grouper: Grouper[T]) -> None: ... + def __contains__(self, item: T) -> bool: ... + def __iter__(self) -> Iterator[list[T]]: ... + def joined(self, a: T, b: T) -> bool: ... + def get_siblings(self, a: T, *, include_self: bool = True) -> list[T]: ... def simple_linear_interpolation(a: ArrayLike, steps: int) -> np.ndarray: ... def delete_masked_points(*args): ... @@ -148,7 +144,7 @@ def pts_to_midstep(x: np.ndarray, *args: np.ndarray) -> np.ndarray: ... STEP_LOOKUP_MAP: dict[str, Callable] def index_of(y: float | ArrayLike) -> tuple[np.ndarray, np.ndarray]: ... -def safe_first_element(obj: Collection[_T]) -> _T: ... +def safe_first_element[T](obj: Collection[T]) -> T: ... def sanitize_sequence(data): ... def _resize_sequence(seq: Sequence, N: int) -> Sequence: ... def normalize_kwargs( diff --git a/lib/matplotlib/colorbar.pyi b/lib/matplotlib/colorbar.pyi index 07467ca74f3d..33a63ddd5335 100644 --- a/lib/matplotlib/colorbar.pyi +++ b/lib/matplotlib/colorbar.pyi @@ -13,11 +13,12 @@ from collections.abc import Sequence from typing import Any, Literal, overload from .typing import ColorType -class _ColorbarSpine(mspines.Spines): + +class _ColorbarSpine(mspines.Spine): def __init__(self, axes: Axes): ... - def get_window_extent(self, renderer: RendererBase | None = ...) -> Bbox:... + def get_window_extent(self, renderer: RendererBase | None = ...) -> Bbox: ... def set_xy(self, xy: ArrayLike) -> None: ... - def draw(self, renderer: RendererBase | None) -> None:... + def draw(self, renderer: RendererBase | None) -> None: ... class Colorbar: diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index 53471e0f0a17..010f73131fbc 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -288,7 +288,15 @@ def same_color(c1, c2): """ Return whether the colors *c1* and *c2* are the same. - *c1*, *c2* can be single colors or lists/arrays of colors. + Parameters + ---------- + c1, c2 : :mpltype:`color` or list of :mpltype:`color` or RGB(A) array + If passing multiple colors, *c1* and *c2* must be of the same length. RGB(A) + arrays must be of shape (ncolors, 3) or (ncolors, 4). + + Returns + ------- + bool """ c1 = to_rgba_array(c1) c2 = to_rgba_array(c2) @@ -1225,7 +1233,7 @@ def from_list(name, colors, N=256, gamma=1.0, *, bad=None, under=None, over=None except Exception as e2: raise e2 from e vals = np.asarray(_vals) - if np.min(vals) < 0 or np.max(vals) > 1 or np.any(np.diff(vals) <= 0): + if np.min(vals) < 0 or np.max(vals) > 1 or np.any(np.diff(vals) < 0): raise ValueError( "the values passed in the (value, color) pairs " "must increase monotonically from 0 to 1." diff --git a/lib/matplotlib/figure.pyi b/lib/matplotlib/figure.pyi index 59d276362dc5..e5ca72f00d89 100644 --- a/lib/matplotlib/figure.pyi +++ b/lib/matplotlib/figure.pyi @@ -1,6 +1,6 @@ from collections.abc import Callable, Hashable, Iterable, Sequence import os -from typing import Any, IO, Literal, TypeVar, overload +from typing import Any, IO, Literal, overload import numpy as np from numpy.typing import ArrayLike @@ -29,8 +29,6 @@ from mpl_toolkits.mplot3d import Axes3D from .typing import ColorType, HashableList, LegendLocType -_T = TypeVar("_T") - class FigureBase(Artist): artists: list[Artist] lines: list[Line2D] @@ -259,19 +257,19 @@ class FigureBase(Artist): gridspec_kw: dict[str, Any] | None = ..., ) -> dict[str, Axes]: ... @overload - def subplot_mosaic( + def subplot_mosaic[T]( self, - mosaic: list[HashableList[_T]], + mosaic: list[HashableList[T]], *, sharex: bool = ..., sharey: bool = ..., width_ratios: ArrayLike | None = ..., height_ratios: ArrayLike | None = ..., - empty_sentinel: _T = ..., + empty_sentinel: T = ..., subplot_kw: dict[str, Any] | None = ..., - per_subplot_kw: dict[_T | tuple[_T, ...], dict[str, Any]] | None = ..., + per_subplot_kw: dict[T | tuple[T, ...], dict[str, Any]] | None = ..., gridspec_kw: dict[str, Any] | None = ..., - ) -> dict[_T, Axes]: ... + ) -> dict[T, Axes]: ... @overload def subplot_mosaic( self, diff --git a/lib/matplotlib/font_manager.pyi b/lib/matplotlib/font_manager.pyi index 4bb1a8bae2a9..45cafeb23e3f 100644 --- a/lib/matplotlib/font_manager.pyi +++ b/lib/matplotlib/font_manager.pyi @@ -147,7 +147,7 @@ class FontManager: rebuild_if_missing: bool = ..., ) -> list[FontPath]: ... -def is_opentype_cff_font(filename: str) -> bool: ... +def is_opentype_cff_font(filename: str | os.PathLike) -> bool: ... def get_font( font_filepaths: Iterable[str | bytes | os.PathLike | FontPath] | str | bytes | os.PathLike | FontPath, ) -> ft2font.FT2Font: ... diff --git a/lib/matplotlib/ft2font.pyi b/lib/matplotlib/ft2font.pyi index f8057742b376..882cde6c7ae9 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 +from typing import BinaryIO, Literal, NewType, NotRequired, TypedDict, cast, final, overload import numpy as np from numpy.typing import NDArray @@ -13,7 +12,7 @@ __libraqm_version__: str # We can't change the type hints for standard library chr/ord, so character codes are a # simple type alias. -CharacterCodeType: TypeAlias = int +type CharacterCodeType = int # But glyph indices are internal, so use a distinct type hint. GlyphIndexType = NewType('GlyphIndexType', int) @@ -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/image.pyi b/lib/matplotlib/image.pyi index 1fcc1a710bfd..4c2a8fbdb0e6 100644 --- a/lib/matplotlib/image.pyi +++ b/lib/matplotlib/image.pyi @@ -84,7 +84,7 @@ class _ImageBase(colorizer.ColorizingArtist): self, renderer: RendererBase, magnification: float = ..., unsampled: bool = ... ) -> tuple[np.ndarray, float, float, Affine2D]: ... def draw(self, renderer: RendererBase) -> None: ... - def write_png(self, fname: str | pathlib.Path | BinaryIO) -> None: ... + def write_png(self, fname: str | os.PathLike | BinaryIO) -> None: ... def set_data(self, A: ArrayLike | None) -> None: ... def set_array(self, A: ArrayLike | None) -> None: ... def get_shape(self) -> tuple[int, int, int]: ... diff --git a/lib/matplotlib/legend.py b/lib/matplotlib/legend.py index e25c3525821c..01324fed9078 100644 --- a/lib/matplotlib/legend.py +++ b/lib/matplotlib/legend.py @@ -1187,33 +1187,56 @@ def _find_best_position(self, width, height, renderer): bbox = Bbox.from_bounds(0, 0, width, height) + candidate_boxes = [] + for loc_code in range(1, len(self.codes)): + left, bottom = self._get_anchored_bbox(loc_code, bbox, + self.get_bbox_to_anchor(), + renderer) + candidate_boxes.append((loc_code, + Bbox.from_bounds(left, bottom, width, height))) + + # Every candidate box has the same width and height, with only a handful of + # distinct left/bottom edges. For speed we compute each point's membership + # in those intervals once, rather than for all 10 candidate boxes. + pts = [line.vertices for line in lines] + if offsets: + pts.append(np.asarray(offsets, dtype=float)) + pts = np.concatenate(pts) if pts else np.empty((0, 2)) + x_left = np.unique([box.x0 for _, box in candidate_boxes]) + y_bottom = np.unique([box.y0 for _, box in candidate_boxes]) + x, y = pts[:, 0], pts[:, 1] + with np.errstate(invalid='ignore'): + # Broadcast the (n_edges, 1) edge positions against the (n_points,) + # coordinates to get (n_edges, n_points) interval membership arrays. + in_x = ((x_left[:, np.newaxis] < x) + & (x < x_left[:, np.newaxis] + width)) + in_y = ((y_bottom[:, np.newaxis] < y) + & (y < y_bottom[:, np.newaxis] + height)) + candidates = [] - for idx in range(1, len(self.codes)): - l, b = self._get_anchored_bbox(idx, bbox, - self.get_bbox_to_anchor(), - renderer) - legendBox = Bbox.from_bounds(l, b, width, height) + for loc_code, legendBox in candidate_boxes: + contained_count = np.count_nonzero( + in_x[np.where(x_left == legendBox.x0)[0][0]] + & in_y[np.where(y_bottom == legendBox.y0)[0][0]]) # XXX TODO: If markers are present, it would be good to take them # into account when checking vertex overlaps in the next line. - badness = (sum(legendBox.count_contains(line.vertices) - for line in lines) - + legendBox.count_contains(offsets) + badness = (contained_count + legendBox.count_overlaps(bboxes) + sum(line.intersects_bbox(legendBox, filled=False) for line in lines)) - # Include the index to favor lower codes in case of a tie. - candidates.append((badness, idx, (l, b))) + # Include the loc code to favor lower codes in case of a tie. + candidates.append((badness, loc_code, (legendBox.x0, legendBox.y0))) if badness == 0: break - _, _, (l, b) = min(candidates) + _, _, (left, bottom) = min(candidates) if self._loc_used_default and time.perf_counter() - start_time > 1: _api.warn_external( 'Creating legend with loc="best" can be slow with large ' 'amounts of data.') - return l, b + return left, bottom def contains(self, mouseevent): return self.legendPatch.contains(mouseevent) diff --git a/lib/matplotlib/legend_handler.pyi b/lib/matplotlib/legend_handler.pyi index db028a136a48..950b7d933019 100644 --- a/lib/matplotlib/legend_handler.pyi +++ b/lib/matplotlib/legend_handler.pyi @@ -1,13 +1,12 @@ from collections.abc import Callable, Sequence + +from numpy.typing import ArrayLike + from matplotlib.artist import Artist from matplotlib.legend import Legend from matplotlib.offsetbox import OffsetBox from matplotlib.transforms import Transform -from typing import TypeVar - -from numpy.typing import ArrayLike - def update_from_first_child(tgt: Artist, src: Artist) -> None: ... class HandlerBase: @@ -144,8 +143,6 @@ class HandlerLineCollection(HandlerLine2D): trans: Transform, ) -> Sequence[Artist]: ... -_T = TypeVar("_T", bound=Artist) - class HandlerRegularPolyCollection(HandlerNpointsYoffsets): def __init__( self, @@ -167,13 +164,13 @@ class HandlerRegularPolyCollection(HandlerNpointsYoffsets): def update_prop( self, legend_handle, orig_handle: Artist, legend: Legend ) -> None: ... - def create_collection( + def create_collection[T: Artist]( self, - orig_handle: _T, + orig_handle: T, sizes: Sequence[float] | None, offsets: Sequence[float] | None, offset_transform: Transform, - ) -> _T: ... + ) -> T: ... def create_artists( self, legend: Legend, @@ -187,22 +184,22 @@ class HandlerRegularPolyCollection(HandlerNpointsYoffsets): ) -> Sequence[Artist]: ... class HandlerPathCollection(HandlerRegularPolyCollection): - def create_collection( + def create_collection[T: Artist]( self, - orig_handle: _T, + orig_handle: T, sizes: Sequence[float] | None, offsets: Sequence[float] | None, offset_transform: Transform, - ) -> _T: ... + ) -> T: ... class HandlerCircleCollection(HandlerRegularPolyCollection): - def create_collection( + def create_collection[T: Artist]( self, - orig_handle: _T, + orig_handle: T, sizes: Sequence[float] | None, offsets: Sequence[float] | None, offset_transform: Transform, - ) -> _T: ... + ) -> T: ... class HandlerErrorbar(HandlerLine2D): def __init__( diff --git a/lib/matplotlib/lines.py b/lib/matplotlib/lines.py index 9f179e7bfe42..f43494843607 100644 --- a/lib/matplotlib/lines.py +++ b/lib/matplotlib/lines.py @@ -592,9 +592,10 @@ def set_markevery(self, every): ----- Setting *markevery* will still only draw markers at actual data points. While the float argument form aims for uniform visual spacing, it has - to coerce from the ideal spacing to the nearest available data point. - Depending on the number and distribution of data points, the result - may still not look evenly spaced. + to coerce from the ideal spacing along the drawn line to the nearest + available data point. Depending on the number and distribution of data + points, and on how jagged the line is, the result may still not look + evenly spaced along the x- or y-axis. When using a start offset to specify the first marker, the offset will be from the first data point which may be different from the first @@ -668,6 +669,7 @@ def set_data(self, *args): self.set_xdata(x) self.set_ydata(y) + @_api.deprecated("3.12", alternative="recache(always=True)") def recache_always(self): self.recache(always=True) diff --git a/lib/matplotlib/mathtext.pyi b/lib/matplotlib/mathtext.pyi index 607501a275c6..878fd0c58c29 100644 --- a/lib/matplotlib/mathtext.pyi +++ b/lib/matplotlib/mathtext.pyi @@ -1,5 +1,5 @@ import os -from typing import Generic, IO, Literal, TypeVar, overload +from typing import IO, Literal, overload from matplotlib.font_manager import FontProperties from matplotlib.typing import ColorType @@ -11,16 +11,19 @@ from ._mathtext import ( get_unicode_index as get_unicode_index, ) -_ParseType = TypeVar("_ParseType", RasterParse, VectorParse) - -class MathTextParser(Generic[_ParseType]): +class MathTextParser[ParseType: (RasterParse, VectorParse)]: @overload def __init__(self: MathTextParser[VectorParse], output: Literal["path"]) -> None: ... @overload def __init__(self: MathTextParser[RasterParse], output: Literal["agg", "raster", "macosx"]) -> None: ... def parse( - self, s: str, dpi: float = ..., prop: FontProperties | None = ..., *, antialiased: bool | None = ... - ) -> _ParseType: ... + self, + s: str, + dpi: float = ..., + prop: FontProperties | None = ..., + *, + antialiased: bool | None = ..., + ) -> ParseType: ... def math_to_image( s: str, diff --git a/lib/matplotlib/projections/polar.py b/lib/matplotlib/projections/polar.py index 9d999dde2f6f..6908e9b45b65 100644 --- a/lib/matplotlib/projections/polar.py +++ b/lib/matplotlib/projections/polar.py @@ -1069,6 +1069,21 @@ def set_thetalim(self, *args, **kwargs): raise ValueError("The angle range must be less than a full circle") return tuple(np.rad2deg((new_min, new_max))) + def get_thetalim(self): + """ + Get the minimum and maximum theta values. + + Returns + ------- + thetamin, thetamax : float + The minimum and maximum theta limit values in degrees. + + See Also + -------- + set_thetalim + """ + return tuple(np.rad2deg(self.get_xlim())) + def set_theta_offset(self, offset): """ Set the offset for the location of 0 in radians. @@ -1228,6 +1243,21 @@ def set_rlim(self, bottom=None, top=None, *, return self.set_ylim(bottom=bottom, top=top, emit=emit, auto=auto, **kwargs) + def get_rlim(self): + """ + Get the radial axis view limits. + + Returns + ------- + bottom, top : float + The lower and upper radial axis limits. + + See Also + -------- + set_rlim + """ + return self.get_ylim() + def get_rlabel_position(self): """ Returns diff --git a/lib/matplotlib/projections/polar.pyi b/lib/matplotlib/projections/polar.pyi index de1cbc293900..b3f18587c237 100644 --- a/lib/matplotlib/projections/polar.pyi +++ b/lib/matplotlib/projections/polar.pyi @@ -141,6 +141,7 @@ class PolarAxes(Axes): def set_thetalim(self, minval: float, maxval: float, /) -> tuple[float, float]: ... @overload def set_thetalim(self, *, thetamin: float, thetamax: float) -> tuple[float, float]: ... + def get_thetalim(self) -> tuple[float, float]: ... def set_theta_offset(self, offset: float) -> None: ... def get_theta_offset(self) -> float: ... def set_theta_zero_location( @@ -169,6 +170,7 @@ class PolarAxes(Axes): auto: bool = ..., **kwargs, ) -> tuple[float, float]: ... + def get_rlim(self) -> tuple[float, float]: ... def get_rlabel_position(self) -> float: ... def set_rlabel_position(self, value: float) -> None: ... def set_rscale(self, *args, **kwargs) -> None: ... diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py index 732ec874bf38..8cdacca8c0a3 100644 --- a/lib/matplotlib/pyplot.py +++ b/lib/matplotlib/pyplot.py @@ -60,6 +60,7 @@ import matplotlib import matplotlib.image from matplotlib import _api +from matplotlib._api import UNSET as _UNSET # Re-exported (import x as x) for typing. from matplotlib import get_backend as get_backend, rcParams as rcParams from matplotlib import cm as cm # noqa: F401 @@ -91,8 +92,7 @@ from collections.abc import Callable, Hashable, Iterable, Sequence import pathlib import os - from typing import Any, BinaryIO, Literal, TypeVar - from typing_extensions import ParamSpec + from typing import Any, BinaryIO, Literal import PIL.Image from numpy.typing import ArrayLike @@ -154,10 +154,7 @@ LogLevel ) from matplotlib.widgets import SubplotTool - - _P = ParamSpec('_P') - _R = TypeVar('_R') - _T = TypeVar('_T') + from matplotlib._api import _Unset # We may not need the following imports here: @@ -183,25 +180,25 @@ @overload -def _copy_docstring_and_deprecators( +def _copy_docstring_and_deprecators[**P, R]( method: Any, func: Literal[None] = None -) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]: ... +) -> Callable[[Callable[P, R]], Callable[P, R]]: ... @overload -def _copy_docstring_and_deprecators( - method: Any, func: Callable[_P, _R]) -> Callable[_P, _R]: ... +def _copy_docstring_and_deprecators[**P, R]( + method: Any, func: Callable[P, R]) -> Callable[P, R]: ... -def _copy_docstring_and_deprecators( +def _copy_docstring_and_deprecators[**P, R]( method: Any, - func: Callable[_P, _R] | None = None -) -> Callable[[Callable[_P, _R]], Callable[_P, _R]] | Callable[_P, _R]: + func: Callable[P, R] | None = None +) -> Callable[[Callable[P, R]], Callable[P, R]] | Callable[P, R]: if func is None: - return cast('Callable[[Callable[_P, _R]], Callable[_P, _R]]', + return cast('Callable[[Callable[P, R]], Callable[P, R]]', functools.partial(_copy_docstring_and_deprecators, method)) - decorators: list[Callable[[Callable[_P, _R]], Callable[_P, _R]]] = [ + decorators: list[Callable[[Callable[P, R]], Callable[P, R]]] = [ _docstring.copy(method) ] # Check whether the definition of *method* includes @_api.rename_parameter @@ -1908,19 +1905,19 @@ def subplot_mosaic( @overload -def subplot_mosaic( - mosaic: list[HashableList[_T]], +def subplot_mosaic[T]( + mosaic: list[HashableList[T]], *, sharex: bool = ..., sharey: bool = ..., width_ratios: ArrayLike | None = ..., height_ratios: ArrayLike | None = ..., - empty_sentinel: _T = ..., + empty_sentinel: T = ..., subplot_kw: dict[str, Any] | None = ..., gridspec_kw: dict[str, Any] | None = ..., - per_subplot_kw: dict[_T | tuple[_T, ...], dict[str, Any]] | None = ..., + per_subplot_kw: dict[T | tuple[T, ...], dict[str, Any]] | None = ..., **fig_kw: Any -) -> tuple[Figure, dict[_T, matplotlib.axes.Axes]]: ... +) -> tuple[Figure, dict[T, matplotlib.axes.Axes]]: ... @overload @@ -1939,8 +1936,8 @@ def subplot_mosaic( ) -> tuple[Figure, dict[Hashable, matplotlib.axes.Axes]]: ... -def subplot_mosaic( - mosaic: str | list[HashableList[_T]] | list[HashableList[Hashable]], +def subplot_mosaic[T]( + mosaic: str | list[HashableList[T]] | list[HashableList[Hashable]], *, sharex: bool = False, sharey: bool = False, @@ -1950,11 +1947,11 @@ def subplot_mosaic( subplot_kw: dict[str, Any] | None = None, gridspec_kw: dict[str, Any] | None = None, per_subplot_kw: dict[str | tuple[str, ...], dict[str, Any]] | - dict[_T | tuple[_T, ...], dict[str, Any]] | + dict[T | tuple[T, ...], dict[str, Any]] | dict[Hashable | tuple[Hashable, ...], dict[str, Any]] | None = None, **fig_kw: Any ) -> tuple[Figure, dict[str, matplotlib.axes.Axes]] | \ - tuple[Figure, dict[_T, matplotlib.axes.Axes]] | \ + tuple[Figure, dict[T, matplotlib.axes.Axes]] | \ tuple[Figure, dict[Hashable, matplotlib.axes.Axes]]: """ Build a layout of Axes based on ASCII art or nested lists. @@ -3497,7 +3494,7 @@ def fill_between( x: ArrayLike, y1: ArrayLike | float, y2: ArrayLike | float = 0, - where: Sequence[bool] | None = None, + where: ArrayLike | None = None, interpolate: bool = False, step: Literal["pre", "post", "mid"] | None = None, *, @@ -3522,7 +3519,7 @@ def fill_betweenx( y: ArrayLike, x1: ArrayLike | float, x2: ArrayLike | float = 0, - where: Sequence[bool] | None = None, + where: ArrayLike | None = None, step: Literal["pre", "post", "mid"] | None = None, interpolate: bool = False, *, @@ -3966,13 +3963,16 @@ def phase_spectrum( @_copy_docstring_and_deprecators(Axes.pie) def pie( x: ArrayLike, + *, explode: ArrayLike | None = None, labels: Sequence[str] | None = None, colors: ColorType | Sequence[ColorType] | None = None, + wedge_labels: str | Sequence | None = None, + wedge_label_distance: float | Sequence = 0.6, autopct: str | Callable[[float], str] | None = None, pctdistance: float = 0.6, shadow: bool = False, - labeldistance: float | None = 1.1, + labeldistance: float | None | _Unset = _UNSET, startangle: float = 0, radius: float = 1, counterclock: bool = True, @@ -3981,7 +3981,6 @@ def pie( center: tuple[float, float] = (0, 0), frame: bool = False, rotatelabels: bool = False, - *, normalize: bool = True, hatch: str | Sequence[str] | None = None, data: DataParamType = None, @@ -3991,6 +3990,8 @@ def pie( explode=explode, labels=labels, colors=colors, + wedge_labels=wedge_labels, + wedge_label_distance=wedge_label_distance, autopct=autopct, pctdistance=pctdistance, shadow=shadow, diff --git a/lib/matplotlib/rcsetup.pyi b/lib/matplotlib/rcsetup.pyi index 120c0c93bec9..7086da6984a8 100644 --- a/lib/matplotlib/rcsetup.pyi +++ b/lib/matplotlib/rcsetup.pyi @@ -1,13 +1,17 @@ from cycler import Cycler from collections.abc import Callable, Iterable -from typing import Any, Literal, TypeVar +from typing import Any, Literal from matplotlib.typing import ColorType, LineStyleType, MarkEveryType -_T = TypeVar("_T") - -def _listify_validator(s: Callable[[Any], _T]) -> Callable[[Any], list[_T]]: ... +def _listify_validator[T]( + scalar_validator: Callable[[Any], T], + allow_stringlist: bool = ..., + *, + n: int | None = ..., + doc: str | None = ..., +) -> Callable[[Any], list[T]]: ... class ValidateInStrings: key: str diff --git a/lib/matplotlib/sphinxext/plot_directive.py b/lib/matplotlib/sphinxext/plot_directive.py index 7b46b3145e2b..dbf5072e2c93 100644 --- a/lib/matplotlib/sphinxext/plot_directive.py +++ b/lib/matplotlib/sphinxext/plot_directive.py @@ -162,6 +162,12 @@ The plot_srcset option is incompatible with *singlehtml* builds, and an error will be raised. +plot_skip_execution + If True, will not run any plot directives. Code, captions, etc. will all + still be rendered, but no plots will be created. + + .. versionadded:: 3.12 + Notes on how it works --------------------- @@ -197,6 +203,7 @@ from sphinx.environment.collectors import EnvironmentCollector from sphinx.errors import ExtensionError +from sphinx.util import logging import matplotlib from matplotlib.backend_bases import FigureManagerBase @@ -207,6 +214,8 @@ __version__ = 2 +_log = logging.getLogger(__name__) + # ----------------------------------------------------------------------------- # Registration hook @@ -323,6 +332,7 @@ def setup(app): app.add_config_value('plot_working_directory', None, True) app.add_config_value('plot_template', None, True) app.add_config_value('plot_srcset', [], True) + app.add_config_value('plot_skip_execution', False, True) app.connect('doctree-read', mark_plot_labels) app.add_css_file('plot_directive.css') app.connect('build-finished', _copy_css_file) @@ -925,23 +935,26 @@ def run(arguments, content, options, state_machine, state, lineno): # make figures try: - results = render_figures(code=code, - code_path=source_file_name, - output_dir=build_dir, - output_base=output_base, - context=keep_context, - function_name=function_name, - config=config, - context_reset=context_opt == 'reset', - close_figs=context_opt == 'close-figs', - code_includes=source_file_includes) + if config.plot_skip_execution: + results = [(code, [])] + else: + results = render_figures(code=code, + code_path=source_file_name, + output_dir=build_dir, + output_base=output_base, + context=keep_context, + function_name=function_name, + config=config, + context_reset=context_opt == 'reset', + close_figs=context_opt == 'close-figs', + code_includes=source_file_includes) errors = [] except PlotError as err: + message = "Exception occurred in plotting {}\n from {}:\n{}".format( + output_base, source_file_name, err) + _log.warning(message, location=(source_file_name, lineno)) reporter = state.memo.reporter - sm = reporter.system_message( - 2, "Exception occurred in plotting {}\n from {}:\n{}".format( - output_base, source_file_name, err), - line=lineno) + sm = reporter.system_message(2, message, line=lineno) results = [(code, [])] errors = [sm] diff --git a/lib/matplotlib/spines.py b/lib/matplotlib/spines.py index 741491b3dc58..35c1879a345c 100644 --- a/lib/matplotlib/spines.py +++ b/lib/matplotlib/spines.py @@ -206,6 +206,17 @@ def _ensure_position_is_set(self): self._position = ('outward', 0.0) # in points self.set_position(self._position) + def _ensure_transform_is_set(self): + # Install the default blended transform if the spine still carries + # the placeholder from Spine.__init__. Restricted to the standard + # cartesian spines: set_position/get_spine_transform only support + # those, and other spines (polar, cartopy's GeoSpine) manage their + # own transform. + if (self.spine_type in ('left', 'right', 'top', 'bottom') + and self._position is None + and self._transform is self.axes.transData): + self.set_position(('outward', 0.0)) + def register_axis(self, axis): """ Register an axis. diff --git a/lib/matplotlib/spines.pyi b/lib/matplotlib/spines.pyi index ff2a1a40bf94..37d4f28128fa 100644 --- a/lib/matplotlib/spines.pyi +++ b/lib/matplotlib/spines.pyi @@ -1,5 +1,5 @@ from collections.abc import Callable, Iterator, MutableMapping -from typing import Literal, TypeVar, overload +from typing import Literal, overload import matplotlib.patches as mpatches from matplotlib.axes import Axes @@ -36,17 +36,16 @@ class Spine(mpatches.Patch): def set_bounds(self, low: float | None = ..., high: float | None = ...) -> None: ... def get_bounds(self) -> tuple[float, float]: ... - _T = TypeVar("_T", bound=Spine) @classmethod - def linear_spine( - cls: type[_T], + def linear_spine[T: Spine]( + cls: type[T], axes: Axes, spine_type: Literal["left", "right", "bottom", "top"], **kwargs - ) -> _T: ... + ) -> T: ... @classmethod - def arc_spine( - cls: type[_T], + def arc_spine[T: Spine]( + cls: type[T], axes: Axes, spine_type: Literal["left", "right", "bottom", "top"], center: tuple[float, float], @@ -54,11 +53,11 @@ class Spine(mpatches.Patch): theta1: float, theta2: float, **kwargs - ) -> _T: ... + ) -> T: ... @classmethod - def circular_spine( - cls: type[_T], axes: Axes, center: tuple[float, float], radius: float, **kwargs - ) -> _T: ... + def circular_spine[T: Spine]( + cls: type[T], axes: Axes, center: tuple[float, float], radius: float, **kwargs + ) -> T: ... def set_color(self, c: ColorType | None) -> None: ... class SpinesProxy: diff --git a/lib/matplotlib/testing/compare.py b/lib/matplotlib/testing/compare.py index 2b94847a72b6..92547c3585f6 100644 --- a/lib/matplotlib/testing/compare.py +++ b/lib/matplotlib/testing/compare.py @@ -100,6 +100,9 @@ def _read_until(self, terminator): class _MagickConverter: def __call__(self, orig, dest): try: + # ImageMagick may not be permitted to follow a symlink, so resolve it + if orig.is_symlink(): + orig = orig.resolve() subprocess.run( [mpl._get_executable_info("magick").executable, orig, dest], check=True) diff --git a/lib/matplotlib/testing/compare.pyi b/lib/matplotlib/testing/compare.pyi index 8f11b3bebc1a..c673bdad6231 100644 --- a/lib/matplotlib/testing/compare.pyi +++ b/lib/matplotlib/testing/compare.pyi @@ -1,18 +1,19 @@ from collections.abc import Callable +from os import PathLike from typing import Literal, overload from numpy.typing import NDArray __all__ = ["calculate_rms", "comparable_formats", "compare_images"] -def make_test_filename(fname: str, purpose: str) -> str: ... +def make_test_filename(fname: str | PathLike, purpose: str) -> str: ... def get_cache_dir() -> str: ... def get_file_hash(path: str, block_size: int = ...) -> str: ... converter: dict[str, Callable[[str, str], None]] = {} def comparable_formats() -> list[str]: ... -def convert(filename: str, cache: bool) -> str: ... +def convert(filename: str | PathLike, cache: bool) -> str: ... def crop_to_same( actual_path: str, actual_image: NDArray, expected_path: str, expected_image: NDArray ) -> tuple[NDArray, NDArray]: ... diff --git a/lib/matplotlib/testing/decorators.pyi b/lib/matplotlib/testing/decorators.pyi index 1738794d119a..e53dc8c90bbe 100644 --- a/lib/matplotlib/testing/decorators.pyi +++ b/lib/matplotlib/testing/decorators.pyi @@ -1,16 +1,12 @@ from collections.abc import Callable, Sequence from pathlib import Path -from typing import Any, TypeVar -from typing_extensions import ParamSpec +from typing import Any from matplotlib.figure import Figure from matplotlib.typing import RcStyleType -_P = ParamSpec("_P") -_R = TypeVar("_R") - def remove_ticks_and_titles(figure: Figure) -> None: ... -def image_comparison( +def image_comparison[**P, R]( baseline_images: list[str] | None, extensions: list[str] | None = ..., tol: float = ..., @@ -18,8 +14,8 @@ def image_comparison( remove_text: bool = ..., savefig_kwarg: dict[str, Any] | None = ..., style: RcStyleType | None = ..., -) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]: ... -def check_figures_equal( +) -> Callable[[Callable[P, R]], Callable[P, R]]: ... +def check_figures_equal[**P, R]( *, extensions: Sequence[str] = ..., tol: float = ... -) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]: ... +) -> Callable[[Callable[P, R]], Callable[P, R]]: ... def _image_directories(func: Callable) -> tuple[Path, Path]: ... diff --git a/lib/matplotlib/tests/baseline_images/test_usetex/test_usetex.pdf b/lib/matplotlib/tests/baseline_images/test_usetex/test_usetex.pdf index f2ebbeb528cc..bce9d583d4d4 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_usetex/test_usetex.pdf and b/lib/matplotlib/tests/baseline_images/test_usetex/test_usetex.pdf differ diff --git a/lib/matplotlib/tests/baseline_images/test_usetex/test_usetex.png b/lib/matplotlib/tests/baseline_images/test_usetex/test_usetex.png index e4a62a1c87ae..9f9d21b22528 100644 Binary files a/lib/matplotlib/tests/baseline_images/test_usetex/test_usetex.png and b/lib/matplotlib/tests/baseline_images/test_usetex/test_usetex.png differ diff --git a/lib/matplotlib/tests/test_animation.py b/lib/matplotlib/tests/test_animation.py index a00adcdf95f0..bfddddf6d665 100644 --- a/lib/matplotlib/tests/test_animation.py +++ b/lib/matplotlib/tests/test_animation.py @@ -181,7 +181,7 @@ def gen_writers(): # Smoke test for saving animations. In the future, we should probably # design more sophisticated tests which compare resulting frames a-la # matplotlib.testing.image_comparison -@pytest.mark.parametrize('writer, frame_format, output', gen_writers()) +@pytest.mark.parametrize('writer, frame_format, output', list(gen_writers())) @pytest.mark.parametrize('anim', [dict(klass=dict)], indirect=['anim']) def test_save_animation_smoketest(tmp_path, writer, frame_format, output, anim): if frame_format is not None: @@ -201,7 +201,7 @@ def test_save_animation_smoketest(tmp_path, writer, frame_format, output, anim): del anim -@pytest.mark.parametrize('writer, frame_format, output', gen_writers()) +@pytest.mark.parametrize('writer, frame_format, output', list(gen_writers())) def test_grabframe(tmp_path, writer, frame_format, output): WriterClass = animation.writers[writer] diff --git a/lib/matplotlib/tests/test_api.py b/lib/matplotlib/tests/test_api.py index 36522e1fd53b..34cbd3808812 100644 --- a/lib/matplotlib/tests/test_api.py +++ b/lib/matplotlib/tests/test_api.py @@ -3,7 +3,7 @@ from collections.abc import Callable import re import typing -from typing import Any, TypeVar +from typing import Any import numpy as np import pytest @@ -15,8 +15,6 @@ if typing.TYPE_CHECKING: from typing import Self -T = TypeVar('T') - def test_unsupported_method(): class Base: @@ -108,7 +106,7 @@ def test_warn_deprecated(): def test_deprecate_privatize_attribute() -> None: class C: def __init__(self) -> None: self._attr = 1 - def _meth(self, arg: T) -> T: return arg + def _meth[T](self, arg: T) -> T: return arg attr: int = _api.deprecate_privatize_attribute("0.0") meth: Callable = _api.deprecate_privatize_attribute("0.0") diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index b3797171cfae..3189076c7655 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -240,7 +240,7 @@ def test_matshow(fig_test, fig_ref): @image_comparison([f'formatter_ticker_{i:03d}.png' for i in range(1, 6)], style='mpl20', - tol=0.03 if sys.platform == 'darwin' else 0) + tol=0 if platform.machine() == 'x86_64' else 0.03) def test_formatter_ticker(): import matplotlib.testing.jpl_units as units units.register() @@ -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] @@ -5611,7 +5608,7 @@ def test_marker_styles(): @image_comparison(['rc_markerfill.png'], style='mpl20', - tol=0.033 if sys.platform == 'darwin' else 0) + tol=0 if platform.machine() == 'x86_64' else 0.033) def test_markers_fillstyle_rcparams(): fig, ax = plt.subplots() x = np.arange(7) @@ -5634,7 +5631,7 @@ def test_vertex_markers(): @image_comparison(['vline_hline_zorder.png', 'errorbar_zorder.png'], style='mpl20', - tol=0.02 if sys.platform == 'darwin' else 0) + tol=0 if platform.machine() == 'x86_64' else 0.02) def test_eb_line_zorder(): x = list(range(10)) @@ -6628,8 +6625,23 @@ def test_pie_default(): colors = ['yellowgreen', 'gold', 'lightskyblue', 'lightcoral'] explode = (0, 0.1, 0, 0) # only "explode" the 2nd slice (i.e. 'Hogs') fig1, ax1 = plt.subplots(figsize=(8, 6)) - ax1.pie(sizes, explode=explode, labels=labels, colors=colors, - autopct='%1.1f%%', shadow=True, startangle=90) + ax1.pie(sizes, explode=explode, wedge_labels=labels, wedge_label_distance=1.1, + colors=colors, autopct='%1.1f%%', shadow=True, startangle=90) + + +@image_comparison(['pie_default.png'], style='mpl20') +def test_pie_default_legacy(): + # Same as above, but uses labels parameter. Remove after labeldistance + # parameter deprecation expires. + # The slices will be ordered and plotted counter-clockwise. + labels = 'Frogs', 'Hogs', 'Dogs', 'Logs' + sizes = [15, 30, 45, 10] + colors = ['yellowgreen', 'gold', 'lightskyblue', 'lightcoral'] + explode = (0, 0.1, 0, 0) # only "explode" the 2nd slice (i.e. 'Hogs') + fig1, ax1 = plt.subplots(figsize=(8, 6)) + with pytest.warns(mpl.MatplotlibDeprecationWarning): + ax1.pie(sizes, explode=explode, labels=labels, colors=colors, + autopct='%1.1f%%', shadow=True, startangle=90) @image_comparison(['pie_linewidth_0.png', 'pie_linewidth_0.png', 'pie_linewidth_0.png'], @@ -6641,32 +6653,35 @@ def test_pie_linewidth_0(): colors = ['yellowgreen', 'gold', 'lightskyblue', 'lightcoral'] explode = (0, 0.1, 0, 0) # only "explode" the 2nd slice (i.e. 'Hogs') - plt.pie(sizes, explode=explode, labels=labels, colors=colors, - autopct='%1.1f%%', shadow=True, startangle=90, + plt.pie(sizes, explode=explode, wedge_labels=labels, wedge_label_distance=1.1, + colors=colors, autopct='%1.1f%%', shadow=True, startangle=90, wedgeprops={'linewidth': 0}) # Set aspect ratio to be equal so that pie is drawn as a circle. plt.axis('equal') - # Reuse testcase from above for a labeled data test + # Reuse testcase from above for a labeled data test. Include legend labels + # to smoke test that they are correctly unpacked. data = {"l": labels, "s": sizes, "c": colors, "ex": explode} fig = plt.figure() ax = fig.gca() - ax.pie("s", explode="ex", labels="l", colors="c", + ax.pie("s", explode="ex", wedge_labels="l", colors="c", wedge_label_distance=1.1, autopct='%1.1f%%', shadow=True, startangle=90, - wedgeprops={'linewidth': 0}, data=data) + labels="l", labeldistance=None, wedgeprops={'linewidth': 0}, + data=data) ax.axis('equal') # And again to test the pyplot functions which should also be able to be # called with a data kwarg plt.figure() - plt.pie("s", explode="ex", labels="l", colors="c", + plt.pie("s", explode="ex", wedge_labels="l", colors="c", wedge_label_distance=1.1, autopct='%1.1f%%', shadow=True, startangle=90, - wedgeprops={'linewidth': 0}, data=data) + labels="l", labeldistance=None, wedgeprops={'linewidth': 0}, + data=data) plt.axis('equal') @image_comparison(['pie_center_radius.png'], style='mpl20', - tol=0.01 if sys.platform == 'darwin' else 0) + tol=0 if platform.machine() == 'x86_64' else 0.01) def test_pie_center_radius(): # The slices will be ordered and plotted counter-clockwise. labels = 'Frogs', 'Hogs', 'Dogs', 'Logs' @@ -6674,8 +6689,8 @@ def test_pie_center_radius(): colors = ['yellowgreen', 'gold', 'lightskyblue', 'lightcoral'] explode = (0, 0.1, 0, 0) # only "explode" the 2nd slice (i.e. 'Hogs') - plt.pie(sizes, explode=explode, labels=labels, colors=colors, - autopct='%1.1f%%', shadow=True, startangle=90, + plt.pie(sizes, explode=explode, wedge_labels=labels, wedge_label_distance=1.1, + colors=colors, autopct='%1.1f%%', shadow=True, startangle=90, wedgeprops={'linewidth': 0}, center=(1, 2), radius=1.5) plt.annotate("Center point", xy=(1, 2), xytext=(1, 1.3), @@ -6694,8 +6709,8 @@ def test_pie_linewidth_2(): colors = ['yellowgreen', 'gold', 'lightskyblue', 'lightcoral'] explode = (0, 0.1, 0, 0) # only "explode" the 2nd slice (i.e. 'Hogs') - plt.pie(sizes, explode=explode, labels=labels, colors=colors, - autopct='%1.1f%%', shadow=True, startangle=90, + plt.pie(sizes, explode=explode, wedge_labels=labels, wedge_label_distance=1.1, + colors=colors, autopct='%1.1f%%', shadow=True, startangle=90, wedgeprops={'linewidth': 2}) # Set aspect ratio to be equal so that pie is drawn as a circle. plt.axis('equal') @@ -6709,8 +6724,8 @@ def test_pie_ccw_true(): colors = ['yellowgreen', 'gold', 'lightskyblue', 'lightcoral'] explode = (0, 0.1, 0, 0) # only "explode" the 2nd slice (i.e. 'Hogs') - plt.pie(sizes, explode=explode, labels=labels, colors=colors, - autopct='%1.1f%%', shadow=True, startangle=90, + plt.pie(sizes, explode=explode, wedge_labels=labels, wedge_label_distance=1.1, + colors=colors, autopct='%1.1f%%', shadow=True, startangle=90, counterclock=True) # Set aspect ratio to be equal so that pie is drawn as a circle. plt.axis('equal') @@ -6725,35 +6740,53 @@ def test_pie_frame_grid(): # only "explode" the 2nd slice (i.e. 'Hogs') explode = (0, 0.1, 0, 0) - plt.pie(sizes, explode=explode, labels=labels, colors=colors, - autopct='%1.1f%%', shadow=True, startangle=90, + plt.pie(sizes, explode=explode, wedge_labels=labels, wedge_label_distance=1.1, + colors=colors, autopct='%1.1f%%', shadow=True, startangle=90, wedgeprops={'linewidth': 0}, frame=True, center=(2, 2)) - plt.pie(sizes[::-1], explode=explode, labels=labels, colors=colors, - autopct='%1.1f%%', shadow=True, startangle=90, + plt.pie(sizes[::-1], explode=explode, wedge_labels=labels, wedge_label_distance=1.1, + colors=colors, autopct='%1.1f%%', shadow=True, startangle=90, wedgeprops={'linewidth': 0}, frame=True, center=(5, 2)) - plt.pie(sizes, explode=explode[::-1], labels=labels, colors=colors, - autopct='%1.1f%%', shadow=True, startangle=90, + plt.pie(sizes, explode=explode[::-1], wedge_labels=labels, wedge_label_distance=1.1, + colors=colors, autopct='%1.1f%%', shadow=True, startangle=90, wedgeprops={'linewidth': 0}, frame=True, center=(3, 5)) # Set aspect ratio to be equal so that pie is drawn as a circle. plt.axis('equal') +@image_comparison(['pie_rotatelabels_true.png'], style='mpl20') +def test_pie_label_rotate(): + # The slices will be ordered and plotted counter-clockwise. + labels = 'Hogwarts', 'Frogs', 'Dogs', 'Logs' + sizes = [15, 30, 45, 10] + colors = ['yellowgreen', 'gold', 'lightskyblue', 'lightcoral'] + explode = (0, 0.1, 0, 0) # only "explode" the 2nd slice (i.e. 'Frogs') + + pie = plt.pie(sizes, explode=explode, wedge_labels='{frac:.1%}', colors=colors, + shadow=True, startangle=90) + plt.pie_label(pie, labels, distance=1.1, rotate=True) + # Set aspect ratio to be equal so that pie is drawn as a circle. + plt.axis('equal') + + @image_comparison(['pie_rotatelabels_true.png'], style='mpl20') def test_pie_rotatelabels_true(): + # As above but using legacy labels and rotatelabels parameters. Remove + # when the labeldistance parameter deprecation expires. # The slices will be ordered and plotted counter-clockwise. labels = 'Hogwarts', 'Frogs', 'Dogs', 'Logs' sizes = [15, 30, 45, 10] colors = ['yellowgreen', 'gold', 'lightskyblue', 'lightcoral'] - explode = (0, 0.1, 0, 0) # only "explode" the 2nd slice (i.e. 'Hogs') + explode = (0, 0.1, 0, 0) # only "explode" the 2nd slice (i.e. 'Frogs') - plt.pie(sizes, explode=explode, labels=labels, colors=colors, - autopct='%1.1f%%', shadow=True, startangle=90, - rotatelabels=True) + with pytest.warns(mpl.MatplotlibDeprecationWarning): + plt.pie(sizes, explode=explode, labels=labels, colors=colors, + autopct='%1.1f%%', shadow=True, startangle=90, + rotatelabels=True) # Set aspect ratio to be equal so that pie is drawn as a circle. plt.axis('equal') @@ -6765,7 +6798,7 @@ def test_pie_nolabel_but_legend(): colors = ['yellowgreen', 'gold', 'lightskyblue', 'lightcoral'] explode = (0, 0.1, 0, 0) # only "explode" the 2nd slice (i.e. 'Hogs') plt.pie(sizes, explode=explode, labels=labels, colors=colors, - autopct='%1.1f%%', shadow=True, startangle=90, labeldistance=None, + wedge_labels='{frac:.1%}', shadow=True, startangle=90, labeldistance=None, rotatelabels=True) plt.axis('equal') plt.ylim(-1.2, 1.2) @@ -6806,9 +6839,13 @@ def test_pie_textprops(): rotation_mode="anchor", size=12, color="red") - _, texts, autopct = plt.gca().pie(data, labels=labels, autopct='%.2f', - textprops=textprops) - for labels in [texts, autopct]: + fig, ax = plt.subplots() + + pie1 = ax.pie(data, wedge_labels=labels, autopct='%.2f', textprops=textprops) + with pytest.warns(mpl.MatplotlibDeprecationWarning): + pie2 = ax.pie(data, labels=labels, textprops=textprops) + + for labels in pie1.texts + pie2.texts: for tx in labels: assert tx.get_ha() == textprops["horizontalalignment"] assert tx.get_va() == textprops["verticalalignment"] @@ -6836,7 +6873,7 @@ def test_pie_invalid_labels(): # Test ValueError raised when feeding short labels list to axes.pie fig, ax = plt.subplots() with pytest.raises(ValueError): - ax.pie([1, 2, 3], labels=["One", "Two"]) + ax.pie([1, 2, 3], labels=["One", "Two"], labeldistance=None) def test_pie_invalid_radius(): @@ -6846,6 +6883,13 @@ def test_pie_invalid_radius(): ax.pie([1, 2, 3], radius=-5) +def test_pie_wedge_labels_and_labels(): + fig, ax = plt.subplots() + with pytest.raises(ValueError, match='wedge_labels is a replacement for labels'): + ax.pie([1, 2], wedge_labels=['spam', 'eggs'], labels=['bacon', 'beans'], + labeldistance=1.2) + + def test_normalize_kwarg_pie(): fig, ax = plt.subplots() x = [0.3, 0.3, 0.1] @@ -7965,6 +8009,22 @@ def test_title_above_offset(left, center): assert ycenter == yleft +def test_title_above_hidden_offset(): + # On an inner subplot with a shared y axis the offset text is hidden, but + # it still carries text. It must not be considered when positioning the + # title: its tight bbox is non-finite and used to push the title (and the + # subplot position computed by tight_layout) to NaN/inf. See #31881. + mpl.rcParams['axes.titley'] = None + fig, axs = plt.subplots(1, 2, sharey=True, tight_layout=True) + for i, ax in enumerate(axs): + ax.set_title(f'Subplot {i}') + ax.plot(range(10), [1e53] * 10) + fig.draw_without_rendering() # used to raise ValueError (NaN -> int) + for ax in axs: + assert np.isfinite(ax.title.get_window_extent().ymin) + assert np.all(np.isfinite(ax.get_position().bounds)) + + def test_title_no_move_off_page(): # If an Axes is off the figure (ie. if it is cropped during a save) # make sure that the automatic title repositioning does not get done. @@ -8314,6 +8374,21 @@ def test_tick_padding_tightbbox(): assert bb.y0 < bb2.y0 +def test_tightbbox_includes_long_label(): + fig, ax = plt.subplots() + + renderer = fig._get_renderer() + bbox_no_xlabel = ax.get_tightbbox(renderer, for_layout_only=False) + + ax.set_xlabel( + 'loooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong') + bbox_long_xlabel = ax.get_tightbbox(renderer, for_layout_only=False) + + # When for_layout_only is False, the axes tightbbox should encompass its labels even + # if they are long enough to extent beyond its limits. + assert bbox_long_xlabel.width > bbox_no_xlabel.width + + def test_inset(): """ Ensure that inset_ax argument is indeed optional @@ -9841,7 +9916,7 @@ def test_zorder_and_explicit_rasterization(): @image_comparison(["preset_clip_paths.png"], remove_text=True, style="mpl20", - tol=0.01 if sys.platform == 'darwin' else 0) + tol=0 if platform.machine() == 'x86_64' else 0.01) def test_preset_clip_paths(): fig, ax = plt.subplots() @@ -10336,13 +10411,13 @@ def test_pie_non_finite_values(): df = [5, float('nan'), float('inf')] with pytest.raises(ValueError, match='Wedge sizes must be finite numbers'): - ax.pie(df, labels=['A', 'B', 'C']) + ax.pie(df) def test_pie_all_zeros(): fig, ax = plt.subplots() with pytest.raises(ValueError, match="All wedge sizes are zero"): - ax.pie([0, 0], labels=["A", "B"]) + ax.pie([0, 0]) def test_animated_artists_not_drawn_by_default(): diff --git a/lib/matplotlib/tests/test_backend_pdf.py b/lib/matplotlib/tests/test_backend_pdf.py index 2088ce764b5c..90db19110814 100644 --- a/lib/matplotlib/tests/test_backend_pdf.py +++ b/lib/matplotlib/tests/test_backend_pdf.py @@ -5,6 +5,7 @@ import string import numpy as np +from packaging.version import parse as parse_version import pytest import matplotlib as mpl @@ -112,7 +113,10 @@ def test_indexed_image(): with pikepdf.Pdf.open(buf) as pdf: page, = pdf.pages - image, = page.images.values() + if parse_version(pikepdf.__version__) < parse_version('10.9.0'): + image, = page.images.values() + else: + image, = page.get_images().values() pdf_image = pikepdf.PdfImage(image) assert pdf_image.indexed pil_image = pdf_image.as_pil_image() 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_backend_tk.py b/lib/matplotlib/tests/test_backend_tk.py index 32212610ffc1..839d299f3d48 100644 --- a/lib/matplotlib/tests/test_backend_tk.py +++ b/lib/matplotlib/tests/test_backend_tk.py @@ -39,11 +39,6 @@ def _isolated_tk_test(success_count, func=None): sys.platform == "linux" and not _c_internal_utils.xdisplay_is_valid(), reason="$DISPLAY is unset" ) - @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' - ) @functools.wraps(func) def test_func(): # even if the package exists, may not actually be importable this can diff --git a/lib/matplotlib/tests/test_backends_interactive.py b/lib/matplotlib/tests/test_backends_interactive.py index 5d76300054d7..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,20 +317,12 @@ 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( 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) @@ -365,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(): @@ -374,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(): @@ -383,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 = [ @@ -408,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 @@ -443,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 = [ @@ -610,14 +601,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_colors.py b/lib/matplotlib/tests/test_colors.py index 5bc1f8aea973..2ef9b7c5d091 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -1828,6 +1828,13 @@ def test_LinearSegmentedColormap_from_list_value_color_tuple(): ) +def test_LinearSegmentedColormap_from_list_repeat_values(): + # Smoke test that we can pass repeated values to make a solid band of color + # followed by a linear ramp. + value_color_tuples = [(0, "red"), (0.6, "red"), (0.6, "blue"), (1, "green")] + mcolors.LinearSegmentedColormap.from_list("lsc", value_color_tuples, N=11) + + @image_comparison(['test_norm_abc.png'], remove_text=True, style='_classic_test', tol=0 if platform.machine() == 'x86_64' else 0.05) def test_norm_abc(): diff --git a/lib/matplotlib/tests/test_container.py b/lib/matplotlib/tests/test_container.py index b7dfe1196685..d27ee1115171 100644 --- a/lib/matplotlib/tests/test_container.py +++ b/lib/matplotlib/tests/test_container.py @@ -57,10 +57,13 @@ def test_barcontainer_position_centers__bottoms__tops(): def test_piecontainer_remove(): fig, ax = plt.subplots() - pie = ax.pie([2, 3], labels=['foo', 'bar'], autopct="%1.0f%%") + pie = ax.pie([2, 3], wedge_labels=['foo', 'bar'], autopct="%1.0f%%") ax.pie_label(pie, ['baz', 'qux']) + assert len(ax.patches) == 2 - assert len(ax.texts) == 6 + # We have added 6 labels but pie also adds an empty Text artist to each + # wedge if labeldistance is not None and labels is not passed + assert len(ax.texts) == 8 pie.remove() assert not ax.patches diff --git a/lib/matplotlib/tests/test_contour.py b/lib/matplotlib/tests/test_contour.py index d4369295637b..8b13caa15e67 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_figure.py b/lib/matplotlib/tests/test_figure.py index fbfb2515f42e..8391bda62e3c 100644 --- a/lib/matplotlib/tests/test_figure.py +++ b/lib/matplotlib/tests/test_figure.py @@ -1397,7 +1397,7 @@ def test_subfigure_dpi(): @image_comparison(['test_subfigure_ss.png'], style='mpl20', savefig_kwarg={'facecolor': 'teal'}, - tol=0.022 if sys.platform == 'darwin' else 0) + tol=0 if platform.machine() == 'x86_64' else 0.022) def test_subfigure_ss(): # test assigning the subfigure via subplotspec np.random.seed(19680801) diff --git a/lib/matplotlib/tests/test_ft2font.py b/lib/matplotlib/tests/test_ft2font.py index ee7c7cea3aac..105feb37fa2f 100644 --- a/lib/matplotlib/tests/test_ft2font.py +++ b/lib/matplotlib/tests/test_ft2font.py @@ -203,6 +203,31 @@ def test_ft2font_invalid_args(tmp_path): ft2font.FT2Font(file, _kerning_factor=123) +def test_ft2font_oversized_read(): + # A file object whose read() returns *more* bytes than requested is + # misbehaving: FreeType only sizes its buffer for the requested count, so + # an over-long read must be rejected rather than overflow the buffer or be + # silently truncated. Verify that such an object causes construction to + # fail loudly instead. + data = Path(fm.findfont('DejaVu Sans')).read_bytes() + + class OversizedReader: + def __init__(self, data): + self._data = data + + def seek(self, offset): + pass + + def read(self, size): + # Ignore the requested size and hand back the whole file, which is + # far more than FreeType ever asks for. The initial read(0) probe + # in the constructor still gets an empty bytes object. + return self._data if size else b'' + + with pytest.raises(RuntimeError): + ft2font.FT2Font(OversizedReader(data)) + + @pytest.mark.parametrize('name, size, skippable', [('DejaVu Sans', 1, False), ('WenQuanYi Zen Hei', 3, True)]) def test_ft2font_face_index(name, size, skippable): diff --git a/lib/matplotlib/tests/test_inset.py b/lib/matplotlib/tests/test_inset.py index 81528580c723..bbf481c23253 100644 --- a/lib/matplotlib/tests/test_inset.py +++ b/lib/matplotlib/tests/test_inset.py @@ -93,8 +93,10 @@ def test_inset_indicator_zorder(): assert inset.get_zorder() == 42 -@image_comparison(['zoom_inset_connector_styles.png'], remove_text=True, style='mpl20', - tol=0.024 if platform.machine() in ['aarch64', 'arm64'] else 0) +@image_comparison(['zoom_inset_connector_styles.png'], + remove_text=True, style='mpl20', + tol=(0.024 if platform.machine() in ['aarch64', 'arm64', + 'loongarch64'] else 0)) def test_zoom_inset_connector_styles(): fig, axs = plt.subplots(2) for ax in axs: diff --git a/lib/matplotlib/tests/test_legend.py b/lib/matplotlib/tests/test_legend.py index 67b433fe7447..84d3b7c4f09d 100644 --- a/lib/matplotlib/tests/test_legend.py +++ b/lib/matplotlib/tests/test_legend.py @@ -2,7 +2,6 @@ import io import itertools import platform -import sys import time from unittest import mock import warnings @@ -203,7 +202,7 @@ def test_alpha_rcparam(): @image_comparison(['fancy.png'], remove_text=True, style='mpl20', - tol=0.01 if sys.platform == 'darwin' else 0) + tol=0 if platform.machine() == 'x86_64' else 0.01) def test_fancy(): # using subplot triggers some offsetbox functionality untested elsewhere plt.subplot(121) @@ -665,7 +664,7 @@ def test_empty_bar_chart_with_legend(): @image_comparison(['shadow_argument_types.png'], remove_text=True, style='mpl20', - tol=0.028 if sys.platform == 'darwin' else 0) + tol=0 if platform.machine() == 'x86_64' else 0.028) def test_shadow_argument_types(): # Test that different arguments for shadow work as expected fig, ax = plt.subplots() diff --git a/lib/matplotlib/tests/test_mathtext.py b/lib/matplotlib/tests/test_mathtext.py index a8f9236b5208..b200c3a9e048 100644 --- a/lib/matplotlib/tests/test_mathtext.py +++ b/lib/matplotlib/tests/test_mathtext.py @@ -221,7 +221,7 @@ def baseline_images(request, fontset, index, text): @pytest.mark.parametrize( - 'index, text', enumerate(math_tests), ids=range(len(math_tests))) + 'index, text', list(enumerate(math_tests)), ids=range(len(math_tests))) @pytest.mark.parametrize( 'fontset', ['cm', 'stix', 'stixsans', 'dejavusans', 'dejavuserif']) @pytest.mark.parametrize('baseline_images', ['mathtext'], indirect=True) @@ -237,7 +237,7 @@ def test_mathtext_rendering(baseline_images, fontset, index, text): horizontalalignment='center', verticalalignment='center') -@pytest.mark.parametrize('index, text', enumerate(svgastext_math_tests), +@pytest.mark.parametrize('index, text', list(enumerate(svgastext_math_tests)), ids=range(len(svgastext_math_tests))) @pytest.mark.parametrize('fontset', ['cm', 'dejavusans']) @pytest.mark.parametrize('baseline_images', ['mathtext0'], indirect=True) @@ -254,7 +254,7 @@ def test_mathtext_rendering_svgastext(baseline_images, fontset, index, text): horizontalalignment='center', verticalalignment='center') -@pytest.mark.parametrize('index, text', enumerate(lightweight_math_tests), +@pytest.mark.parametrize('index, text', list(enumerate(lightweight_math_tests)), ids=range(len(lightweight_math_tests))) @pytest.mark.parametrize('fontset', ['dejavusans']) @pytest.mark.parametrize('baseline_images', ['mathtext1'], indirect=True) @@ -266,7 +266,7 @@ def test_mathtext_rendering_lightweight(baseline_images, fontset, index, text): @pytest.mark.parametrize( - 'index, text', enumerate(font_tests), ids=range(len(font_tests))) + 'index, text', list(enumerate(font_tests)), ids=range(len(font_tests))) @pytest.mark.parametrize( 'fontset', ['cm', 'stix', 'stixsans', 'dejavusans', 'dejavuserif']) @pytest.mark.parametrize('baseline_images', ['mathfont'], indirect=True) diff --git a/lib/matplotlib/tests/test_mlab.py b/lib/matplotlib/tests/test_mlab.py index 82f877b4cc01..4f2ae49f4772 100644 --- a/lib/matplotlib/tests/test_mlab.py +++ b/lib/matplotlib/tests/test_mlab.py @@ -207,7 +207,8 @@ def test_detrend_linear_2d(self): scope='class') class TestSpectral: @pytest.fixture(scope='class', autouse=True) - def stim(self, request, fstims, iscomplex, sides, len_x, NFFT_density, + @classmethod + def stim(cls, request, fstims, iscomplex, sides, len_x, NFFT_density, nover_density, pad_to_density, pad_to_spectrum): Fs = 100. @@ -323,11 +324,6 @@ def stim(self, request, fstims, iscomplex, sides, len_x, NFFT_density, if iscomplex: y = y.astype('complex') - # Interestingly, the instance on which this fixture is called is not - # the same as the one on which a test is run. So we need to modify the - # class itself when using a class-scoped fixture. - cls = request.cls - cls.Fs = Fs cls.sides = sides cls.fstims = fstims diff --git a/lib/matplotlib/tests/test_path.py b/lib/matplotlib/tests/test_path.py index 3c7ad5e2f99b..c31f33647879 100644 --- a/lib/matplotlib/tests/test_path.py +++ b/lib/matplotlib/tests/test_path.py @@ -105,7 +105,7 @@ def test_contains_points_negative_radius(): (1., 2., 1., 2.)] -@pytest.mark.parametrize('path, extents', zip(_test_paths, _test_path_extents)) +@pytest.mark.parametrize('path, extents', list(zip(_test_paths, _test_path_extents))) def test_exact_extents(path, extents): # notice that if we just looked at the control points to get the bounding # box of each curve, we would get the wrong answers. For example, for diff --git a/lib/matplotlib/tests/test_patheffects.py b/lib/matplotlib/tests/test_patheffects.py index 7095f6b3855b..d8dfee3c1b94 100644 --- a/lib/matplotlib/tests/test_patheffects.py +++ b/lib/matplotlib/tests/test_patheffects.py @@ -1,4 +1,4 @@ -import sys +import platform import numpy as np @@ -30,7 +30,7 @@ def test_patheffect1(): @image_comparison(['patheffect2'], remove_text=True, style='mpl20', - tol=0.051 if sys.platform == 'darwin' else 0) + tol=0 if platform.machine() == 'x86_64' else 0.051) def test_patheffect2(): ax2 = plt.subplot() @@ -46,7 +46,7 @@ def test_patheffect2(): @image_comparison(['patheffect3'], style='mpl20', - tol=0.02 if sys.platform == 'darwin' else 0) + tol=0 if platform.machine() == 'x86_64' else 0.02) def test_patheffect3(): plt.figure(figsize=(8, 6)) p1, = plt.plot([1, 3, 5, 4, 3], 'o-b', lw=4) 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/lib/matplotlib/tests/test_polar.py b/lib/matplotlib/tests/test_polar.py index 168047d0c07e..411676a3d619 100644 --- a/lib/matplotlib/tests/test_polar.py +++ b/lib/matplotlib/tests/test_polar.py @@ -1,4 +1,4 @@ -import sys +import platform import numpy as np from numpy.testing import assert_allclose @@ -12,7 +12,7 @@ @image_comparison(['polar_axes.png'], style='default', - tol=0.009 if sys.platform == 'darwin' else 0) + tol=0 if platform.machine() == 'x86_64' else 0.009) def test_polar_annotations(): # You can specify the xypoint and the xytext in different positions and # coordinate systems, and optionally turn on a connecting line and mark the @@ -47,7 +47,7 @@ def test_polar_annotations(): @image_comparison(['polar_coords.png'], style='default', remove_text=True, - tol=0.013 if sys.platform == 'darwin' else 0) + tol=0 if platform.machine() == 'x86_64' else 0.013) def test_polar_coord_annotations(): # You can also use polar notation on a cartesian axes. Here the native # coordinate system ('data') is cartesian, so you need to specify the @@ -420,6 +420,29 @@ def test_axvspan(): assert span.get_path()._interpolation_steps > 1 +def test_polar_get_rlim(): + # PolarAxes.get_rlim() should mirror set_rlim() + ax = plt.figure().add_subplot(projection='polar') + ax.set_rlim(1.5, 8.0) + assert ax.get_rlim() == (1.5, 8.0) + + +def test_polar_get_rlim_after_plot(): + # get_rlim() should work after autoscaling via plot() + ax = plt.figure().add_subplot(projection='polar') + theta = np.linspace(0, 2 * np.pi, 10) + ax.plot(theta, np.ones(10) * 5.0) + rmin, rmax = ax.get_rlim() + assert rmax >= 5.0 + + +def test_polar_get_thetalim(): + # PolarAxes.get_thetalim() should mirror set_thetalim() + ax = plt.figure().add_subplot(projection='polar') + ax.set_thetalim(thetamin=30, thetamax=90) + assert_allclose(ax.get_thetalim(), (30, 90)) + + @check_figures_equal() def test_remove_shared_polar(fig_ref, fig_test): # Removing shared polar axes used to crash. Test removing them, keeping in diff --git a/lib/matplotlib/tests/test_rcparams.py b/lib/matplotlib/tests/test_rcparams.py index dc36392ab676..e740d2454125 100644 --- a/lib/matplotlib/tests/test_rcparams.py +++ b/lib/matplotlib/tests/test_rcparams.py @@ -444,7 +444,7 @@ def generate_validator_testcases(valid): @pytest.mark.parametrize('validator, arg, target', - generate_validator_testcases(True)) + list(generate_validator_testcases(True))) def test_validator_valid(validator, arg, target): res = validator(arg) if isinstance(target, np.ndarray): @@ -457,7 +457,7 @@ def test_validator_valid(validator, arg, target): @pytest.mark.parametrize('validator, arg, exception_type', - generate_validator_testcases(False)) + list(generate_validator_testcases(False))) def test_validator_invalid(validator, arg, exception_type): with pytest.raises(exception_type): validator(arg) diff --git a/lib/matplotlib/tests/test_sphinxext.py b/lib/matplotlib/tests/test_sphinxext.py index c6f4e13c74c2..9192cdfb906b 100644 --- a/lib/matplotlib/tests/test_sphinxext.py +++ b/lib/matplotlib/tests/test_sphinxext.py @@ -16,8 +16,9 @@ tinypages = Path(__file__).parent / 'data/tinypages' -def build_sphinx_html(source_dir, doctree_dir, html_dir, extra_args=None): - # Build the pages with warnings turned into errors +def build_sphinx_html( + source_dir, doctree_dir, html_dir, extra_args=None, expected_returncode=0): + # Build the pages with warnings turned into errors. extra_args = [] if extra_args is None else extra_args cmd = [sys.executable, '-msphinx', '-W', '-b', 'html', '-d', str(doctree_dir), str(source_dir), str(html_dir), *extra_args] @@ -31,12 +32,31 @@ def build_sphinx_html(source_dir, doctree_dir, html_dir, extra_args=None): out = proc.stdout err = proc.stderr - assert proc.returncode == 0, \ + assert proc.returncode == expected_returncode, \ f"sphinx build failed with stdout:\n{out}\nstderr:\n{err}\n" - if err: + if expected_returncode == 0 and err: pytest.fail(f"sphinx build emitted the following warnings:\n{err}") - assert html_dir.is_dir() + if expected_returncode == 0: + assert html_dir.is_dir() + return proc + + +def test_plot_directive_exception_fails_build(tmp_path): + shutil.copyfile(tinypages / 'conf.py', tmp_path / 'conf.py') + shutil.copytree(tinypages / '_static', tmp_path / '_static') + (tmp_path / 'index.rst').write_text(""" +.. plot:: + + raise RuntimeError("plot directive failure") +""") + + proc = build_sphinx_html( + tmp_path, tmp_path / 'doctrees', tmp_path / '_build' / 'html', + expected_returncode=1) + + assert "Exception occurred in plotting index-1" in proc.stderr + assert "RuntimeError: plot directive failure" in proc.stderr def test_tinypages(tmp_path): @@ -269,3 +289,51 @@ def plot_file(num, suff=''): st = ('srcset="../_images/nestedpage2-index-2.png, ' '../_images/nestedpage2-index-2.2x.png 2.00x"') assert st in (html_dir / 'nestedpage2/index.html').read_text(encoding='utf-8') + + +def test_plot_skip_execution(tmp_path): + # test that modifying plot_exclude_patterns in config leads to skipping files + shutil.copyfile(tinypages / 'conf.py', tmp_path / 'conf.py') + shutil.copytree(tinypages / '_static', tmp_path / '_static') + shutil.copyfile(tinypages / 'range4.py', tmp_path / 'range4.py') + shutil.copyfile(tinypages / 'range6.py', tmp_path / 'range6.py') + + html_dir = tmp_path / '_build' / 'html' + img_dir = html_dir / '_images' + doctree_dir = tmp_path / 'doctrees' + + (tmp_path / 'index.rst').write_text(""" +.. plot:: + + plt.plot(range(2)) + +.. toctree:: + + script_func + script_nofunc +""") + (tmp_path / 'script_func.rst').write_text(""" +########## +Some plots +########## + +.. plot:: range6.py range6 + +.. plot:: range6.py range10 +""") + (tmp_path / 'script_nofunc.rst').write_text(""" +########## +Some plots +########## + +.. plot:: range4.py +""") + + # Build the pages with warnings turned into errors + build_sphinx_html(tmp_path, doctree_dir, html_dir, + extra_args=["-D", "plot_skip_execution=1"]) + + assert not (img_dir / "index-1.png").exists() + assert not (img_dir / "range6_range6.png").exists() + assert not (img_dir / "range6_range10.png").exists() + assert not (img_dir / "range4.png").exists() diff --git a/lib/matplotlib/tests/test_spines.py b/lib/matplotlib/tests/test_spines.py index b652b1f78867..1f0122f27aff 100644 --- a/lib/matplotlib/tests/test_spines.py +++ b/lib/matplotlib/tests/test_spines.py @@ -1,8 +1,9 @@ import numpy as np import pytest +import matplotlib.path as mpath import matplotlib.pyplot as plt -from matplotlib.spines import Spines +from matplotlib.spines import Spine, Spines from matplotlib.testing.decorators import check_figures_equal, image_comparison @@ -197,3 +198,19 @@ def test_spine_set_bounds_with_none(): "left bound should be numeric" assert np.isclose(left_bound[0], ylim[0]), "Lower bound should match original value" assert np.isclose(left_bound[1], ylim[1]), "Upper bound should match original value" + + +def test_clear_with_custom_spine_type(): + # Spines with a non-cartesian spine_type (e.g. cartopy's GeoSpine) manage + # their own transform and may reject set_position(); Axes.clear() must not + # call _ensure_transform_is_set() on them. See SciTools/cartopy#2674. + class NoPositionSpine(Spine): + def __init__(self, axes, **kwargs): + super().__init__(axes, 'geo', mpath.Path(np.empty((0, 2))), **kwargs) + + def set_position(self, position): + raise NotImplementedError('spine does not support set_position') + + fig, ax = plt.subplots() + ax.spines['geo'] = NoPositionSpine(ax) + ax.clear() # must not raise diff --git a/lib/matplotlib/tests/test_ticker.py b/lib/matplotlib/tests/test_ticker.py index 0d01677acc8c..189949638100 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 @@ -41,7 +41,7 @@ def test_integer(self, vmin, vmax, steps, expected): @pytest.mark.parametrize('kwargs, errortype, match', [ ({'foo': 0}, TypeError, - re.escape("set_params() got an unexpected keyword argument 'foo'")), + re.escape("__init__() got an unexpected keyword argument 'foo'")), ({'steps': [2, 1]}, ValueError, "steps argument must be an increasing"), ({'steps': 2}, ValueError, "steps argument must be an increasing"), ({'steps': [2, 11]}, ValueError, "steps argument must be an increasing"), @@ -461,7 +461,7 @@ class TestLogitLocator: @pytest.mark.parametrize( "lims, expected_low_ticks", - zip(ref_basic_limits, ref_basic_major_ticks), + list(zip(ref_basic_limits, ref_basic_major_ticks)), ) def test_basic_major(self, lims, expected_low_ticks): """ @@ -506,7 +506,7 @@ def test_nbins_major(self, lims): @pytest.mark.parametrize( "lims, expected_low_ticks", - zip(ref_basic_limits, ref_basic_major_ticks), + list(zip(ref_basic_limits, ref_basic_major_ticks)), ) def test_minor(self, lims, expected_low_ticks): """ @@ -1764,6 +1764,14 @@ def test_latex(self, is_latex, usetex, expected): with mpl.rc_context(rc={'text.usetex': usetex}): assert fmt.format_pct(50, 100) == expected + def test_call_without_axis(self): + # With explicit decimals the axis view interval is not needed, so the + # formatter should format a value even when it is not attached to an + # axis, instead of raising an AttributeError. + with mpl.rc_context(rc={'text.usetex': False}): + assert mticker.PercentFormatter(xmax=1.0, decimals=1)(0.5) == '50.0%' + assert mticker.PercentFormatter(xmax=100, decimals=0)(50) == '50%' + def _impl_locale_comma(): try: @@ -1951,10 +1959,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/matplotlib/tests/test_transforms.py b/lib/matplotlib/tests/test_transforms.py index 4655a8b5ebc7..d869240adaec 100644 --- a/lib/matplotlib/tests/test_transforms.py +++ b/lib/matplotlib/tests/test_transforms.py @@ -873,6 +873,21 @@ def test_bbox_intersection(): assert_bbox_eq(inter(r1, r5), bbox_from_ext(1, 1, 1, 1)) +def test_bbox_count_contains(): + bbox = mtransforms.Bbox.from_extents(0, 0, 1, 1) + assert bbox.count_contains([]) == 0 + assert bbox.count_contains([ + [0.5, 0.5], # inside + [0.1, 0.9], # inside + [2.0, 2.0], # outside + [-1.0, 0.5], # outside + [0.0, 0.5], # on left edge -> excluded + [1.0, 1.0], # on corner -> excluded + [np.nan, 0.5], # non-finite -> ignored + [0.5, np.inf], # non-finite -> ignored + ]) == 2 + + def test_bbox_as_strings(): b = mtransforms.Bbox([[.5, 0], [.75, .75]]) assert_bbox_eq(b, eval(repr(b), {'Bbox': mtransforms.Bbox})) diff --git a/lib/matplotlib/tests/test_typing.py b/lib/matplotlib/tests/test_typing.py index 811c3cc8df55..5d219f91ffa9 100644 --- a/lib/matplotlib/tests/test_typing.py +++ b/lib/matplotlib/tests/test_typing.py @@ -50,15 +50,14 @@ def test_typing_aliases_documented(): # Collect all public module-level assignment names (both annotated and plain). defined_types = set() for node in ast.iter_child_nodes(tree): - if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name): - name = node.target.id - elif isinstance(node, ast.Assign) and len(node.targets) == 1: - target = node.targets[0] - name = target.id if isinstance(target, ast.Name) else None - else: - continue - if name is not None and not name.startswith("_"): - defined_types.add(name) + match node: + case (ast.TypeAlias(name=ast.Name(id=name)) | + ast.AnnAssign(target=ast.Name(id=name)) | + ast.Assign(targets=(ast.Name(id=name), ))): + if name is not None and not name.startswith("_"): + defined_types.add(name) + case _: + continue assert defined_types, "No type definitions found in typing.py" @@ -104,7 +103,8 @@ def test_rcparam_stubs(): if not name.startswith('_') } - assert {*typing.get_args(RcKeyType)} == runtime_rc_keys + assert isinstance(RcKeyType, typing.TypeAliasType) + assert {*typing.get_args(RcKeyType.__value__)} == runtime_rc_keys runtime_rc_group_keys = set() for name in runtime_rc_keys: @@ -112,4 +112,5 @@ def test_rcparam_stubs(): for i in range(1, len(groups)): runtime_rc_group_keys.add('.'.join(groups[:i])) - assert {*typing.get_args(RcGroupKeyType)} == runtime_rc_group_keys + assert isinstance(RcGroupKeyType, typing.TypeAliasType) + assert {*typing.get_args(RcGroupKeyType.__value__)} == runtime_rc_group_keys diff --git a/lib/matplotlib/tests/test_usetex.py b/lib/matplotlib/tests/test_usetex.py index 87277f152789..55bd72ade5bf 100644 --- a/lib/matplotlib/tests/test_usetex.py +++ b/lib/matplotlib/tests/test_usetex.py @@ -26,10 +26,11 @@ def test_usetex(): kwargs = {"verticalalignment": "baseline", "size": 24, "bbox": dict(pad=0, edgecolor="k", facecolor="none")} ax.text(0.2, 0.7, - # the \LaTeX macro exercises character sizing and placement, + # The \LaTeX macro exercises character sizing and placement, # \left[ ... \right\} draw some variable-height characters, - # \sqrt and \frac draw horizontal rules, \mathrm changes the font - r'\LaTeX\ $\left[\int\limits_e^{2e}' + # \sqrt and \frac draw horizontal rules, \mathrm changes the font, + # the minus sign hits character code 0 in the Type 1 font that LaTeX uses. + r'\LaTeX\ $\left[\int\limits_e^{-2e}' r'\sqrt\frac{\log^3 x}{x}\,\mathrm{d}x \right\}$', **kwargs) ax.text(0.2, 0.3, "lg", **kwargs) @@ -43,6 +44,18 @@ def test_usetex(): ax.set_axis_off() +def test_usetex_fallback(monkeypatch): + # Smoke test that the base draw_tex implementation works. + from matplotlib.backend_bases import RendererBase + from matplotlib.backends.backend_agg import RendererAgg + monkeypatch.setattr(RendererAgg, 'draw_tex', RendererBase.draw_tex) + + plt.rcParams["text.usetex"] = True + fig, ax = plt.subplots() + ax.text(0, 0, "foo_bar") + fig.canvas.draw() + + @check_figures_equal(extensions=['png', 'pdf', 'svg']) def test_empty(fig_test, fig_ref): mpl.rcParams['text.usetex'] = True diff --git a/lib/matplotlib/ticker.py b/lib/matplotlib/ticker.py index f214f44b97eb..391e24dd2a06 100644 --- a/lib/matplotlib/ticker.py +++ b/lib/matplotlib/ticker.py @@ -1638,8 +1638,15 @@ def __init__(self, xmax=100, decimals=None, symbol='%', is_latex=False): def __call__(self, x, pos=None): """Format the tick as a percentage with the appropriate scaling.""" - ax_min, ax_max = self.axis.get_view_interval() - display_range = abs(ax_max - ax_min) + if self.decimals is None: + # The display range of the axis is only needed to pick the number + # of decimals automatically; when ``decimals`` is set explicitly we + # avoid touching ``self.axis`` so the formatter also works when it + # is not attached to an axis. + ax_min, ax_max = self.axis.get_view_interval() + display_range = abs(ax_max - ax_min) + else: + display_range = None return self.fix_minus(self.format_pct(x, display_range)) def format_pct(self, x, display_range): @@ -2115,14 +2122,26 @@ class MaxNLocator(Locator): Finds nice tick locations with no more than :math:`nbins + 1` ticks being within the view limits. Locations beyond the limits are added to support autoscaling. """ - default_params = dict(nbins=10, - steps=None, - integer=False, - symmetric=False, - prune=None, - min_n_ticks=2) - def __init__(self, nbins=None, **kwargs): + default_params = _api.deprecated("3.12")(property(lambda self: dict( + nbins=10, + steps=None, + integer=False, + symmetric=False, + prune=None, + min_n_ticks=2, + ))) + + def __init__( + self, + nbins=10, + *, + steps=None, + integer=False, + symmetric=False, + prune=None, + min_n_ticks=2, + ): """ Parameters ---------- @@ -2157,9 +2176,14 @@ def __init__(self, nbins=None, **kwargs): Relax *nbins* and *integer* constraints if necessary to obtain this minimum number of ticks. """ - if nbins is not None: - kwargs['nbins'] = nbins - self.set_params(**{**self.default_params, **kwargs}) + self.set_params( + nbins=nbins, + steps=steps, + integer=integer, + symmetric=symmetric, + prune=prune, + min_n_ticks=min_n_ticks, + ) @staticmethod def _validate_steps(steps): @@ -3033,7 +3057,8 @@ class AutoLocator(MaxNLocator): This is a subclass of `~matplotlib.ticker.MaxNLocator`, with parameters *nbins = 'auto'* and *steps = [1, 2, 2.5, 5, 10]*. """ - def __init__(self): + + def __init__(self, **kwargs): """ To know the values of the non-public parameters, please have a look to the defaults of `~matplotlib.ticker.MaxNLocator`. @@ -3044,7 +3069,7 @@ def __init__(self): else: nbins = 'auto' steps = [1, 2, 2.5, 5, 10] - super().__init__(nbins=nbins, steps=steps) + super().__init__(nbins=nbins, steps=steps, **kwargs) class AutoMinorLocator(Locator): diff --git a/lib/matplotlib/ticker.pyi b/lib/matplotlib/ticker.pyi index 6590faee7b10..8625c3ebd3fd 100644 --- a/lib/matplotlib/ticker.pyi +++ b/lib/matplotlib/ticker.pyi @@ -225,8 +225,18 @@ class _Edge_integer: def ge(self, x: float) -> float: ... class MaxNLocator(Locator): - default_params: dict[str, Any] - def __init__(self, nbins: int | Literal["auto"] | None = ..., **kwargs) -> None: ... + @property + def default_params(self) -> dict[str, Any]: ... + def __init__( + self, + nbins: int | Literal["auto"] | None = ..., + *, + steps: Sequence[float] | None = None, + integer: bool = False, + symmetric: bool = False, + prune: bool | None = None, + min_n_ticks: int = 2, + ) -> None: ... def set_params(self, **kwargs) -> None: ... def view_limits(self, dmin: float, dmax: float) -> tuple[float, float]: ... @@ -293,7 +303,7 @@ class LogitLocator(MaxNLocator): def minor(self, value: bool) -> None: ... class AutoLocator(MaxNLocator): - def __init__(self) -> None: ... + def __init__(self, **kwargs) -> None: ... class AutoMinorLocator(Locator): ndivs: int diff --git a/lib/matplotlib/transforms.py b/lib/matplotlib/transforms.py index a279de0dfd8b..2b46deae43dd 100644 --- a/lib/matplotlib/transforms.py +++ b/lib/matplotlib/transforms.py @@ -156,7 +156,7 @@ def invalidate(self): Invalidate this `TransformNode` and triggers an invalidation of its ancestors. Should be called any time the transform changes. """ - return self._invalidate_internal( + self._invalidate_internal( level=self._INVALID_AFFINE_ONLY if self.is_affine else self._INVALID_FULL, invalidating_node=self) diff --git a/lib/matplotlib/transforms.pyi b/lib/matplotlib/transforms.pyi index ebee3954a3a7..d3cd5f4ab692 100644 --- a/lib/matplotlib/transforms.pyi +++ b/lib/matplotlib/transforms.pyi @@ -9,9 +9,6 @@ from typing import Literal DEBUG: bool class TransformNode: - INVALID_NON_AFFINE: int - INVALID_AFFINE: int - INVALID: int # Implemented as a standard attr in base class, but functionally readonly and some subclasses implement as such @property def is_affine(self) -> bool: ... @@ -130,12 +127,39 @@ class Bbox(BboxBase): updatex: bool = ..., updatey: bool = ..., ) -> None: ... + # Access to the parent class property is broken in mypy right now, so ignore that + # check for the following read/write properties. + # https://github.com/python/mypy/issues/5936 + @BboxBase.x0.setter # type: ignore[attr-defined,misc] + def x0(self, val: float) -> None: ... + @BboxBase.y0.setter # type: ignore[attr-defined,misc] + def y0(self, val: float) -> None: ... + @BboxBase.x1.setter # type: ignore[attr-defined,misc] + def x1(self, val: float) -> None: ... + @BboxBase.y1.setter # type: ignore[attr-defined,misc] + def y1(self, val: float) -> None: ... + @BboxBase.p0.setter # type: ignore[attr-defined,misc] + def p0(self, val: tuple[float, float]) -> None: ... + @BboxBase.p1.setter # type: ignore[attr-defined,misc] + def p1(self, val: tuple[float, float]) -> None: ... + @BboxBase.intervalx.setter # type: ignore[attr-defined,misc] + def intervalx(self, interval: tuple[float, float]) -> None: ... + @BboxBase.intervaly.setter # type: ignore[attr-defined,misc] + def intervaly(self, interval: tuple[float, float]) -> None: ... + @BboxBase.bounds.setter # type: ignore[attr-defined,misc] + def bounds(self, bounds: tuple[float, float, float, float]) -> None: ... @property def minpos(self) -> float: ... + @minpos.setter + def minpos(self, val: float) -> None: ... @property def minposx(self) -> float: ... + @minposx.setter + def minposx(self, val: float) -> None: ... @property def minposy(self) -> float: ... + @minposy.setter + def minposy(self, val: float) -> None: ... def get_points(self) -> np.ndarray: ... def set_points(self, points: ArrayLike) -> None: ... def set(self, other: Bbox) -> None: ... diff --git a/lib/matplotlib/typing.py b/lib/matplotlib/typing.py index 76b5f2b642f3..70a61702c5c3 100644 --- a/lib/matplotlib/typing.py +++ b/lib/matplotlib/typing.py @@ -12,7 +12,7 @@ """ from collections.abc import Hashable, Sequence import pathlib -from typing import Any, Literal, TypeAlias, TypeVar, Union +from typing import Any, Literal from collections.abc import Callable, Mapping from . import path @@ -22,13 +22,13 @@ from .markers import MarkerStyle from .transforms import Bbox, Transform -DataParamType: TypeAlias = Mapping[str, Any] | None +type DataParamType = Mapping[str, Any] | None """The type of the *data* parameter in plotting functions.""" -RGBColorType: TypeAlias = tuple[float, float, float] | str +type RGBColorType = tuple[float, float, float] | str """Any RGB color specification accepted by Matplotlib.""" -RGBAColorType: TypeAlias = ( +type RGBAColorType = ( str | # "none" or "#RRGGBBAA"/"#RGBA" hex strings tuple[float, float, float, float] | # 2 tuple (color, alpha) representations, not infinitely recursive @@ -39,19 +39,19 @@ ) """Any RGBA color specification accepted by Matplotlib.""" -ColorType: TypeAlias = RGBColorType | RGBAColorType +type ColorType = RGBColorType | RGBAColorType """Any color specification accepted by Matplotlib. See :mpltype:`color`.""" -RGBColourType: TypeAlias = RGBColorType +type RGBColourType = RGBColorType """Alias of `.RGBColorType`.""" -RGBAColourType: TypeAlias = RGBAColorType +type RGBAColourType = RGBAColorType """Alias of `.RGBAColorType`.""" -ColourType: TypeAlias = ColorType +type ColourType = ColorType """Alias of `.ColorType`.""" -LineStyleType: TypeAlias = ( +type LineStyleType = ( Literal["-", "solid", "--", "dashed", "-.", "dashdot", ":", "dotted", "", "none", " ", "None"] | tuple[float, Sequence[float]] @@ -61,11 +61,10 @@ See :doc:`/gallery/lines_bars_and_markers/linestyles`. """ -DrawStyleType: TypeAlias = Literal["default", "steps", "steps-pre", "steps-mid", - "steps-post"] +type DrawStyleType = Literal["default", "steps", "steps-pre", "steps-mid", "steps-post"] """See :doc:`/gallery/lines_bars_and_markers/step_demo`.""" -MarkEveryType: TypeAlias = ( +type MarkEveryType = ( None | int | tuple[int, int] | slice | list[int] | float | tuple[float, float] | @@ -73,7 +72,7 @@ ) """See :doc:`/gallery/lines_bars_and_markers/markevery_demo`.""" -MarkerType: TypeAlias = ( +type MarkerType = ( path.Path | MarkerStyle | str | # str required for "$...$" marker Literal[ ".", ",", "o", "v", "^", "<", ">", @@ -87,33 +86,28 @@ Marker specification. See :doc:`/gallery/lines_bars_and_markers/marker_reference`. """ -FillStyleType: TypeAlias = Literal["full", "left", "right", "bottom", "top", "none"] +type FillStyleType = Literal["full", "left", "right", "bottom", "top", "none"] """Marker fill styles. See :doc:`/gallery/lines_bars_and_markers/marker_reference`.""" -JoinStyleType: TypeAlias = JoinStyle | Literal["miter", "round", "bevel"] +type JoinStyleType = JoinStyle | Literal["miter", "round", "bevel"] """Line join styles. See :doc:`/gallery/lines_bars_and_markers/joinstyle`.""" -CapStyleType: TypeAlias = CapStyle | Literal["butt", "projecting", "round"] +type CapStyleType = CapStyle | Literal["butt", "projecting", "round"] """Line cap styles. See :doc:`/gallery/lines_bars_and_markers/capstyle`.""" -LogLevel: TypeAlias = Literal["NOTSET", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] +type LogLevel = Literal["NOTSET", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] """Literal type for valid logging levels accepted by `matplotlib.set_loglevel()`.""" -CoordsBaseType = Union[ - str, - Artist, - Transform, - Callable[ - [RendererBase], - Union[Bbox, Transform] - ] -] -CoordsType = Union[ - CoordsBaseType, - tuple[CoordsBaseType, CoordsBaseType] -] +type CoordsBaseType = ( + str | + Artist | + Transform | + Callable[[RendererBase], Bbox | Transform] +) +type CoordsType = CoordsBaseType | tuple[CoordsBaseType, CoordsBaseType] +"""Annotation coordinate systems. See :doc:`/users/explain/text/annotations`.""" -RcStyleType: TypeAlias = ( +type RcStyleType = ( str | dict[str, Any] | pathlib.Path | @@ -124,11 +118,10 @@ `matplotlib.style.context`. """ -_HT = TypeVar("_HT", bound=Hashable) -HashableList: TypeAlias = list[_HT | "HashableList[_HT]"] +type HashableList[_HT: Hashable] = list[_HT | HashableList[_HT]] """A nested list of Hashable values.""" -MouseEventType: TypeAlias = Literal[ +type MouseEventType = Literal[ "button_press_event", "button_release_event", "motion_notify_event", @@ -138,18 +131,24 @@ "axes_enter_event", "axes_leave_event", ] +"""Literal type for valid `.MouseEvent` names.""" -KeyEventType: TypeAlias = Literal[ +type KeyEventType = Literal[ "key_press_event", "key_release_event" ] +"""Literal type for valid `.KeyEvent` names.""" -DrawEventType: TypeAlias = Literal["draw_event"] -PickEventType: TypeAlias = Literal["pick_event"] -ResizeEventType: TypeAlias = Literal["resize_event"] -CloseEventType: TypeAlias = Literal["close_event"] +type DrawEventType = Literal["draw_event"] +"""Literal type for valid `.DrawEvent` names.""" +type PickEventType = Literal["pick_event"] +"""Literal type for valid `.PickEvent` names.""" +type ResizeEventType = Literal["resize_event"] +"""Literal type for valid `.ResizeEvent` names.""" +type CloseEventType = Literal["close_event"] +"""Literal type for valid `.CloseEvent` names.""" -EventType: TypeAlias = Literal[ +type EventType = Literal[ MouseEventType, KeyEventType, DrawEventType, @@ -157,8 +156,9 @@ ResizeEventType, CloseEventType, ] +"""Literal type for all valid events.""" -LegendLocType: TypeAlias = ( +type LegendLocType = ( Literal[ # for simplicity, we don't distinguish the between allowed positions for # Axes legend and figure legend. It's still better to limit the allowed @@ -184,7 +184,7 @@ All the "outside ..." locations are only applicable to figure legends. """ -RcKeyType: TypeAlias = Literal[ +type RcKeyType = Literal[ "agg.path.chunksize", "animation.bitrate", "animation.codec", @@ -524,8 +524,9 @@ "ytick.minor.width", "ytick.right", ] +"""Valid specifiers for keys in `matplotlib.rcParams` and `matplotlib.rc_context`.""" -RcGroupKeyType: TypeAlias = Literal[ +type RcGroupKeyType = Literal[ "agg", "agg.path", "animation", @@ -590,3 +591,4 @@ "ytick.major", "ytick.minor", ] +"""Literal type for valid groups accepted by `matplotlib.rc`.""" diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index 2ce76acf49d3..d2c3e66bfe37 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -4167,8 +4167,10 @@ def get_tightbbox(self, renderer=None, *, call_axes_locator=True, if self._axis3don: for axis in self._axis_map.values(): if axis.get_visible(): - axis_bb = martist._get_tightbbox_for_layout_only( - axis, renderer) + if for_layout_only: + axis_bb = martist._get_tightbbox_for_layout_only(axis, renderer) + else: + axis_bb = axis.get_tightbbox(renderer=renderer) if axis_bb: batch.append(axis_bb) return mtransforms.Bbox.union(batch) diff --git a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py index 14bb1bbd9ba5..38dc14d6888f 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') @@ -1724,7 +1722,7 @@ def test_errorbar3d_errorevery(): @mpl3d_image_comparison(['errorbar3d.png'], style='mpl20', - tol=0.015 if sys.platform == 'darwin' else 0) + tol=0 if platform.machine() == 'x86_64' else 0.015) def test_errorbar3d(): """Tests limits, color styling, and legend for 3D errorbars.""" fig = plt.figure() @@ -3204,3 +3202,24 @@ def test_plot_surface_log_scale_invalid_values(): zmin, zmax = ax.get_zlim() assert 1e-3 < zmin < zmax < 1e3, f"zlim corrupted: {(zmin, zmax)}" + + +def test_axes3d_tightbbox_includes_axis_labels(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + ax.scatter([1], [1], [1]) + + fig.draw_without_rendering() + renderer = fig._get_renderer() + bb_no_labels = ax.get_tightbbox(renderer) + + ax.set_xlabel('X label') + ax.set_ylabel('Y label') + ax.set_zlabel('Z label') + + fig.draw_without_rendering() + bb_full = ax.get_tightbbox(renderer) + + # The full bbox must be strictly larger in at least one dimension than the + # bbox not including labels. + assert bb_full.width > bb_no_labels.width or bb_full.height > bb_no_labels.height diff --git a/lib/mpl_toolkits/mplot3d/tests/test_legend3d.py b/lib/mpl_toolkits/mplot3d/tests/test_legend3d.py index a46c958222d8..86e0017699fb 100644 --- a/lib/mpl_toolkits/mplot3d/tests/test_legend3d.py +++ b/lib/mpl_toolkits/mplot3d/tests/test_legend3d.py @@ -1,4 +1,4 @@ -import sys +import platform import numpy as np @@ -28,7 +28,7 @@ def test_legend_bar(): @image_comparison(['fancy.png'], remove_text=True, style='mpl20', - tol=0.01 if sys.platform == 'darwin' else 0) + tol=0 if platform.machine() == 'x86_64' else 0.01) def test_fancy(): fig, ax = plt.subplots(subplot_kw=dict(projection='3d')) ax.plot(np.arange(10), np.full(10, 5), np.full(10, 5), 'o--', label='line') diff --git a/meson.build b/meson.build index 7d1f3a433fbb..24a09821a047 100644 --- a/meson.build +++ b/meson.build @@ -6,7 +6,6 @@ project( find_program('python3', 'python', version: '>= 3.11'), '-m', 'setuptools_scm', check: true).stdout().strip(), # qt_editor backend is MIT - # ResizeObserver at end of lib/matplotlib/backends/web_backend/js/mpl.js is CC0 # STIX, Computer Modern, and Last Resort are OFL # DejaVu is Bitstream Vera and Public Domain license: 'PSF-2.0 AND MIT AND CC0-1.0 AND OFL-1.1 AND Bitstream-Vera AND Public-Domain', @@ -19,7 +18,6 @@ project( 'LICENSE/LICENSE_COURIERTEN', 'LICENSE/LICENSE_FREETYPE', 'LICENSE/LICENSE_HARFBUZZ', - 'LICENSE/LICENSE_JSXTOOLS_RESIZE_OBSERVER', 'LICENSE/LICENSE_LAST_RESORT_FONT', 'LICENSE/LICENSE_LIBRAQM', 'LICENSE/LICENSE_QT4_EDITOR', diff --git a/pyproject.toml b/pyproject.toml index 02e2cf3b6734..baafe820f811 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", @@ -31,18 +30,18 @@ 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", "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" @@ -69,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", @@ -401,6 +400,21 @@ addopts = [ "--import-mode=importlib", ] +[tool.cibuildwheel] +enable = ["cpython-freethreading"] +skip = "*-musllinux_aarch64" +manylinux-x86_64-image = "manylinux2014" + +before-build = "rm -rf {package}/build" +test-command = [ + # "python {package}/ci/check_wheel_licenses.py {wheel}", + "python {package}/ci/check_version_number.py", +] +test-environment = "PIP_PREFER_BINARY=true" + +[tool.cibuildwheel.macos.environment] +MACOSX_DEPLOYMENT_TARGET = "10.12" + [tool.cibuildwheel.pyodide] test-requires = "pytest" test-command = [ @@ -423,3 +437,26 @@ test-command = [ CFLAGS = "-fexceptions" CXXFLAGS = "-fexceptions" LDFLAGS = "-fexceptions" + +[tool.cibuildwheel.windows] +before-build = [ + "pip install delvewheel", + "rm -rf {package}/build", +] +repair-wheel-command = "delvewheel repair -w {dest_dir} {wheel}" + +[tool.cibuildwheel.windows.config-settings] +# On Windows, we explicitly request MSVC compilers (as GitHub Action runners have +# MinGW on PATH that would be picked otherwise), switch to a static build for +# runtimes, but use dynamic linking for `VCRUNTIME140.dll`, `VCRUNTIME140_1.dll`, +# and the UCRT. This avoids requiring specific versions of `MSVCP140.dll`, while +# keeping shared state with the rest of the Python process/extensions. +setup-args = [ + "--vsenv", + "-Db_vscrt=mt", + "-Dcpp_link_args=['ucrt.lib','vcruntime.lib','/nodefaultlib:libucrt.lib','/nodefaultlib:libvcruntime.lib']", +] + +[[tool.cibuildwheel.overrides]] +select = "cp314*" +manylinux-x86_64-image = "manylinux_2_28" diff --git a/src/_image_resample.h b/src/_image_resample.h index eaaf2306ae9f..2c48a080a66d 100644 --- a/src/_image_resample.h +++ b/src/_image_resample.h @@ -693,6 +693,20 @@ static void get_filter(const resample_params_t ¶ms, } +template +void render_image(renderer_t &renderer, rasterizer_t &rasterizer, span_gen_t &span_gen, double alpha) +{ + auto span_alloc = agg::span_allocator{}; + auto conv_alpha = span_conv_alpha{alpha}; + auto span_conv = agg::span_converter{span_gen, conv_alpha}; + + auto renderer_scanline = agg::renderer_scanline_aa{renderer, span_alloc, span_conv}; + + auto scanline = agg::scanline32_u8{}; + agg::render_scanlines(rasterizer, scanline, renderer_scanline); +} + + template void resample( const void *input, int in_width, int in_height, @@ -704,19 +718,7 @@ void resample( using input_pixfmt_t = typename type_mapping_t::pixfmt_type; using output_pixfmt_t = typename type_mapping_t::pixfmt_type; - using renderer_t = agg::renderer_base; - using rasterizer_t = agg::rasterizer_scanline_aa; - using scanline_t = agg::scanline32_u8; - - using reflect_t = agg::wrap_mode_reflect; - using image_accessor_wrap_t = agg::image_accessor_wrap; - using image_accessor_clip_t = agg::image_accessor_clip; - - using span_alloc_t = agg::span_allocator; - using span_conv_alpha_t = span_conv_alpha; - - using nn_affine_interpolator_t = accurate_interpolator_affine_nn<>; - using affine_interpolator_t = agg::span_interpolator_linear<>; + // Need to define this class explicitly because the first argument cannot be deduced using arbitrary_interpolator_t = agg::span_interpolator_adaptor, lookup_distortion>; @@ -734,28 +736,24 @@ void resample( params.interpolation = NEAREST; } - span_alloc_t span_alloc; - rasterizer_t rasterizer; - scanline_t scanline; - - span_conv_alpha_t conv_alpha(params.alpha); - agg::rendering_buffer input_buffer; input_buffer.attach( (unsigned char *)input, in_width, in_height, in_width * itemsize); input_pixfmt_t input_pixfmt(input_buffer); - image_accessor_wrap_t input_accessor_wrap(input_pixfmt); - image_accessor_clip_t input_accessor_clip(input_pixfmt, color_type::no_color()); + auto image_accessor_wrap = + agg::image_accessor_wrap{input_pixfmt}; + auto image_accessor_clip = agg::image_accessor_clip{input_pixfmt, color_type::no_color()}; agg::rendering_buffer output_buffer; output_buffer.attach( (unsigned char *)output, out_width, out_height, out_width * itemsize); output_pixfmt_t output_pixfmt(output_buffer); - renderer_t renderer(output_pixfmt); + auto renderer = agg::renderer_base{output_pixfmt}; agg::trans_affine inverted = params.affine; inverted.invert(); + auto rasterizer = agg::rasterizer_scanline_aa{}; rasterizer.clip_box(0, 0, out_width, out_height); agg::path_storage path; @@ -808,50 +806,42 @@ void resample( if (params.interpolation == NEAREST) { if (params.is_affine) { - using span_gen_t = typename type_mapping_t::template span_gen_nn_type; - using span_conv_t = agg::span_converter; - using nn_renderer_t = agg::renderer_scanline_aa; - nn_affine_interpolator_t interpolator(inverted); - span_gen_t span_gen(input_accessor_clip, interpolator); - span_conv_t span_conv(span_gen, conv_alpha); - nn_renderer_t nn_renderer(renderer, span_alloc, span_conv); - agg::render_scanlines(rasterizer, scanline, nn_renderer); + auto interpolator = accurate_interpolator_affine_nn{inverted}; + // C++17 cannot deduce arguments for an alias class template, so define the class explicitly + using span_gen_t = typename type_mapping_t:: + template span_gen_nn_type; + auto span_gen = span_gen_t{image_accessor_clip, interpolator}; + render_image(renderer, rasterizer, span_gen, params.alpha); } else { - using span_gen_t = typename type_mapping_t::template span_gen_nn_type; - using span_conv_t = agg::span_converter; - using nn_renderer_t = agg::renderer_scanline_aa; lookup_distortion dist( params.transform_mesh, in_width, in_height, out_width, out_height, true); - arbitrary_interpolator_t interpolator(inverted, dist); - span_gen_t span_gen(input_accessor_clip, interpolator); - span_conv_t span_conv(span_gen, conv_alpha); - nn_renderer_t nn_renderer(renderer, span_alloc, span_conv); - agg::render_scanlines(rasterizer, scanline, nn_renderer); + auto interpolator = arbitrary_interpolator_t{inverted, dist}; + // C++17 cannot deduce arguments for an alias class template, so define the class explicitly + using span_gen_t = typename type_mapping_t:: + template span_gen_nn_type; + auto span_gen = span_gen_t{image_accessor_clip, interpolator}; + render_image(renderer, rasterizer, span_gen, params.alpha); } } else { agg::image_filter_lut filter; get_filter(params, filter); if (params.is_affine && params.resample) { - using span_gen_t = typename type_mapping_t::template span_gen_affine_type; - using span_conv_t = agg::span_converter; - using int_renderer_t = agg::renderer_scanline_aa; - affine_interpolator_t interpolator(inverted); - span_gen_t span_gen(input_accessor_wrap, interpolator, filter); - span_conv_t span_conv(span_gen, conv_alpha); - int_renderer_t int_renderer(renderer, span_alloc, span_conv); - agg::render_scanlines(rasterizer, scanline, int_renderer); + auto interpolator = agg::span_interpolator_linear{inverted}; + // C++17 cannot deduce arguments for an alias class template, so define the class explicitly + using span_gen_t = typename type_mapping_t:: + template span_gen_affine_type; + auto span_gen = span_gen_t{image_accessor_wrap, interpolator, filter}; + render_image(renderer, rasterizer, span_gen, params.alpha); } else { - using span_gen_t = typename type_mapping_t::template span_gen_filter_type; - using span_conv_t = agg::span_converter; - using int_renderer_t = agg::renderer_scanline_aa; lookup_distortion dist( params.transform_mesh, in_width, in_height, out_width, out_height, false); - arbitrary_interpolator_t interpolator(inverted, dist); - span_gen_t span_gen(input_accessor_wrap, interpolator, filter); - span_conv_t span_conv(span_gen, conv_alpha); - int_renderer_t int_renderer(renderer, span_alloc, span_conv); - agg::render_scanlines(rasterizer, scanline, int_renderer); + auto interpolator = arbitrary_interpolator_t{inverted, dist}; + // C++17 cannot deduce arguments for an alias class template, so define the class explicitly + using span_gen_t = typename type_mapping_t:: + template span_gen_filter_type; + auto span_gen = span_gen_t{image_accessor_wrap, interpolator, filter}; + render_image(renderer, rasterizer, span_gen, params.alpha); } } } diff --git a/src/ft2font_wrapper.cpp b/src/ft2font_wrapper.cpp index 771f1db5a191..a8654ba1d8a3 100644 --- a/src/ft2font_wrapper.cpp +++ b/src/ft2font_wrapper.cpp @@ -453,6 +453,15 @@ read_from_file_callback(FT_Stream stream, unsigned long offset, unsigned char *b if (PyBytes_AsStringAndSize(read_result.ptr(), &tmpbuf, &n_read) == -1) { throw py::error_already_set(); } + if ((unsigned long)n_read > count) { + // A well-behaved read() never returns more than the requested + // number of bytes. FreeType only ever sized `buffer` for `count` + // bytes, so honoring an over-long read would overflow it. Rather + // than silently truncate (which would feed FreeType corrupt data), + // signal a failed read so that FT_Open_Face -- and in turn the + // FT2Font constructor -- raises. + n_read = 0; + } memcpy(buffer, tmpbuf, n_read); } catch (py::error_already_set &eas) { eas.discard_as_unraisable(__func__); diff --git a/tools/boilerplate.py b/tools/boilerplate.py index 0a1a26c7cb76..2ffe86080143 100644 --- a/tools/boilerplate.py +++ b/tools/boilerplate.py @@ -11,7 +11,7 @@ of Figure and Axes. Whenever the API of one of the wrapped methods changes, this script has to be rerun to keep pyplot.py up to date. -The test ``lib/matplotlib/test_pyplot.py::test_pyplot_up_to_date`` checks +The test ``lib/matplotlib/tests/test_pyplot.py::test_pyplot_up_to_date`` checks that the autogenerated part of pyplot.py is up to date. It will fail in the case of an API mismatch and remind the developer to rerun this script. """ @@ -95,6 +95,8 @@ def __init__(self, value): self._repr = "np.mean" elif value is _api.deprecation._deprecated_parameter: self._repr = "_api.deprecation._deprecated_parameter" + elif value is _api.UNSET: + self._repr = "_UNSET" elif isinstance(value, Enum): # Enum str is Class.Name whereas their repr is . self._repr = f'{type(value).__name__}.{value.name}' diff --git a/tools/embed_js.py b/tools/embed_js.py deleted file mode 100644 index 571bf80238e9..000000000000 --- a/tools/embed_js.py +++ /dev/null @@ -1,102 +0,0 @@ -""" -Script to embed JavaScript dependencies in mpl.js. -""" - -from collections import namedtuple -from pathlib import Path -import re -import shutil -import subprocess -import sys - - -Package = namedtuple('Package', [ - # The package to embed, in some form that `npm install` can use. - 'name', - # The path to the source file within the package to embed. - 'source', - # The path to the license file within the package to embed. - 'license']) -# The list of packages to embed, in some form that `npm install` can use. -JAVASCRIPT_PACKAGES = [ - # Polyfill/ponyfill for ResizeObserver. - Package('@jsxtools/resize-observer', 'index.js', 'LICENSE.md'), -] -# This is the magic line that must exist in mpl.js, after which the embedded -# JavaScript will be appended. -MPLJS_MAGIC_HEADER = ( - "///////////////// REMAINING CONTENT GENERATED BY embed_js.py " - "/////////////////\n") - - -def safe_name(name): - """ - Make *name* safe to use as a JavaScript variable name. - """ - return '_'.join(re.split(r'[@/-]', name)).upper() - - -def prep_package(web_backend_path, pkg): - source = web_backend_path / 'node_modules' / pkg.name / pkg.source - license = web_backend_path / 'node_modules' / pkg.name / pkg.license - if not source.exists(): - # Exact version should already be saved in package.json, so we use - # --no-save here. - try: - subprocess.run(['npm', 'install', '--no-save', pkg.name], - cwd=web_backend_path) - except FileNotFoundError as err: - raise ValueError( - f'npm must be installed to fetch {pkg.name}') from err - if not source.exists(): - raise ValueError( - f'{pkg.name} package is missing source in {pkg.source}') - elif not license.exists(): - raise ValueError( - f'{pkg.name} package is missing license in {pkg.license}') - - return source, license - - -def gen_embedded_lines(pkg, source): - name = safe_name(pkg.name) - print('Embedding', source, 'as', name) - yield '// prettier-ignore\n' - for line in source.read_text().splitlines(): - yield (line.replace('module.exports=function', f'var {name}=function') - + ' // eslint-disable-line\n') - - -def build_mpljs(web_backend_path, license_path): - mpljs_path = web_backend_path / "js/mpl.js" - mpljs_orig = mpljs_path.read_text().splitlines(keepends=True) - try: - mpljs_orig = mpljs_orig[:mpljs_orig.index(MPLJS_MAGIC_HEADER) + 1] - except IndexError as err: - raise ValueError( - f'The mpl.js file *must* have the exact line: {MPLJS_MAGIC_HEADER}' - ) from err - - with mpljs_path.open('w') as mpljs: - mpljs.writelines(mpljs_orig) - - for pkg in JAVASCRIPT_PACKAGES: - source, license = prep_package(web_backend_path, pkg) - mpljs.writelines(gen_embedded_lines(pkg, source)) - - shutil.copy(license, - license_path / f'LICENSE{safe_name(pkg.name)}') - - -if __name__ == '__main__': - # Write the mpl.js file. - if len(sys.argv) > 1: - web_backend_path = Path(sys.argv[1]) - else: - web_backend_path = (Path(__file__).parent.parent / - "lib/matplotlib/backends/web_backend") - if len(sys.argv) > 2: - license_path = Path(sys.argv[2]) - else: - license_path = Path(__file__).parent.parent / "LICENSE" - build_mpljs(web_backend_path, license_path) 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