diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index a19b6d2346e3..f3595d2b7865 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -82,6 +82,8 @@ body: options: - pip - conda + - pixi + - uv - Linux package manager - from source (.tar.gz) - git checkout diff --git a/.github/workflows/cibuildwheel.yml b/.github/workflows/cibuildwheel.yml index 9d4de069b078..46a7c68f062e 100644 --- a/.github/workflows/cibuildwheel.yml +++ b/.github/workflows/cibuildwheel.yml @@ -46,7 +46,7 @@ jobs: fetch-depth: 0 persist-credentials: false - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 name: Install Python with: python-version: '3.11' @@ -72,7 +72,7 @@ jobs: run: twine check dist/* - name: Upload sdist result - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: cibw-sdist path: dist/*.tar.gz @@ -130,20 +130,20 @@ jobs: cibw_archs: "AMD64" - os: windows-11-arm cibw_archs: "ARM64" - - os: macos-13 + - os: macos-15-intel cibw_archs: "x86_64" - os: macos-14 cibw_archs: "arm64" steps: - name: Download sdist - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: name: cibw-sdist path: dist/ - name: Build wheels for CPython 3.14 - uses: pypa/cibuildwheel@352e01339f0a173aa2a3eb57f01492e341e83865 # v3.1.3 + uses: pypa/cibuildwheel@9c00cb4f6b517705a3794b22395aedc36257242c # v3.2.1 with: package-dir: dist/${{ needs.build_sdist.outputs.SDIST_NAME }} env: @@ -153,7 +153,7 @@ jobs: CIBW_MANYLINUX_X86_64_IMAGE: manylinux_2_28 - name: Build wheels for CPython 3.13 - uses: pypa/cibuildwheel@352e01339f0a173aa2a3eb57f01492e341e83865 # v3.1.3 + uses: pypa/cibuildwheel@9c00cb4f6b517705a3794b22395aedc36257242c # v3.2.1 with: package-dir: dist/${{ needs.build_sdist.outputs.SDIST_NAME }} env: @@ -162,7 +162,7 @@ jobs: CIBW_ARCHS: ${{ matrix.cibw_archs }} - name: Build wheels for CPython 3.12 - uses: pypa/cibuildwheel@352e01339f0a173aa2a3eb57f01492e341e83865 # v3.1.3 + uses: pypa/cibuildwheel@9c00cb4f6b517705a3794b22395aedc36257242c # v3.2.1 with: package-dir: dist/${{ needs.build_sdist.outputs.SDIST_NAME }} env: @@ -170,7 +170,7 @@ jobs: CIBW_ARCHS: ${{ matrix.cibw_archs }} - name: Build wheels for CPython 3.11 - uses: pypa/cibuildwheel@352e01339f0a173aa2a3eb57f01492e341e83865 # v3.1.3 + uses: pypa/cibuildwheel@9c00cb4f6b517705a3794b22395aedc36257242c # v3.2.1 with: package-dir: dist/${{ needs.build_sdist.outputs.SDIST_NAME }} env: @@ -178,7 +178,7 @@ jobs: CIBW_ARCHS: ${{ matrix.cibw_archs }} - name: Build wheels for PyPy - uses: pypa/cibuildwheel@352e01339f0a173aa2a3eb57f01492e341e83865 # v3.1.3 + uses: pypa/cibuildwheel@9c00cb4f6b517705a3794b22395aedc36257242c # v3.2.1 with: package-dir: dist/${{ needs.build_sdist.outputs.SDIST_NAME }} env: @@ -187,37 +187,8 @@ jobs: CIBW_ENABLE: pypy if: matrix.cibw_archs != 'aarch64' && matrix.os != 'windows-latest' && matrix.os != 'windows-11-arm' - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: cibw-wheels-${{ runner.os }}-${{ matrix.cibw_archs }} path: ./wheelhouse/*.whl if-no-files-found: error - - publish: - if: github.repository == 'matplotlib/matplotlib' && github.event_name == 'push' && github.ref_type == 'tag' - name: Upload release to PyPI - needs: [build_sdist, build_wheels] - runs-on: ubuntu-latest - environment: release - permissions: - id-token: write - attestations: write - contents: read - steps: - - name: Download packages - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 - with: - pattern: cibw-* - path: dist - merge-multiple: true - - - name: Print out packages - run: ls dist - - - name: Generate artifact attestation for sdist and wheel - uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0 - with: - subject-path: dist/matplotlib-* - - - name: Publish package distributions to PyPI - uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4 diff --git a/.github/workflows/circleci.yml b/.github/workflows/circleci.yml index 3838a38004e0..ab410b194754 100644 --- a/.github/workflows/circleci.yml +++ b/.github/workflows/circleci.yml @@ -11,7 +11,7 @@ jobs: steps: - name: GitHub Action step uses: - scientific-python/circleci-artifacts-redirector-action@839631420e45a08af893032e5a5e8843bf47e8ff # v1.2.0 + scientific-python/circleci-artifacts-redirector-action@5d358ff96e96429a5c64a969bb4a574555439f4f # v1.3.1 with: repo-token: ${{ secrets.GITHUB_TOKEN }} api-token: ${{ secrets.CIRCLECI_TOKEN }} @@ -41,7 +41,7 @@ jobs: - name: Set up reviewdog if: "${{ steps.fetch-artifacts.outputs.count != 0 }}" - uses: reviewdog/action-setup@e04ffabe3898a0af8d0fb1af00c188831c4b5893 # v1.3.2 + uses: reviewdog/action-setup@d8edfce3dd5e1ec6978745e801f9c50b5ef80252 # v1.4.0 with: reviewdog_version: latest diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index eebdd65105e3..72d84e46e077 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -32,7 +32,7 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@96f518a34f7a870018057716cc4d7a5c014bd61c # v3.29.10 + uses: github/codeql-action/init@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0 with: languages: ${{ matrix.language }} @@ -43,4 +43,4 @@ jobs: pip install --user -v . - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@96f518a34f7a870018057716cc4d7a5c014bd61c # v3.29.10 + uses: github/codeql-action/analyze@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0 diff --git a/.github/workflows/cygwin.yml b/.github/workflows/cygwin.yml index 071368531d3f..ba1fd6f57790 100644 --- a/.github/workflows/cygwin.yml +++ b/.github/workflows/cygwin.yml @@ -140,21 +140,21 @@ jobs: # FreeType build fails with bash, succeeds with dash - name: Cache pip - uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: C:\cygwin\home\runneradmin\.cache\pip key: Cygwin-py3.${{ matrix.python-minor-version }}-pip-${{ hashFiles('requirements/*/*.txt') }} restore-keys: ${{ matrix.os }}-py3.${{ matrix.python-minor-version }}-pip- - name: Cache ccache - uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.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@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: | C:\cygwin\home\runneradmin\.cache\matplotlib diff --git a/.github/workflows/do_not_merge.yml b/.github/workflows/do_not_merge.yml index d8664df9ba9a..0c263623942b 100644 --- a/.github/workflows/do_not_merge.yml +++ b/.github/workflows/do_not_merge.yml @@ -15,7 +15,8 @@ jobs: env: has_tag: >- ${{contains(github.event.pull_request.labels.*.name, 'status: needs comment/discussion') || - contains(github.event.pull_request.labels.*.name, 'status: waiting for other PR')}} + contains(github.event.pull_request.labels.*.name, 'status: waiting for other PR') || + contains(github.event.pull_request.labels.*.name, 'DO NOT MERGE') }} steps: - name: Check for label if: ${{'true' == env.has_tag}} @@ -23,6 +24,7 @@ jobs: echo "This PR cannot be merged because it has one of the following labels: " echo "* status: needs comment/discussion" echo "* status: waiting for other PR" + echo "* DO NOT MERGE" exit 1 - name: Allow merging if: ${{'false' == env.has_tag}} diff --git a/.github/workflows/good-first-issue.yml b/.github/workflows/good-first-issue.yml index cc15717e3351..5bb69db0595c 100644 --- a/.github/workflows/good-first-issue.yml +++ b/.github/workflows/good-first-issue.yml @@ -12,7 +12,7 @@ jobs: issues: write steps: - name: Add comment - uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0 + uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0 with: issue-number: ${{ github.event.issue.number }} body: | diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 8e2002353164..17c4922df054 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -10,6 +10,6 @@ jobs: pull-requests: write runs-on: ubuntu-latest steps: - - uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5.0.0 + - uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1 with: sync-labels: true diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index f5cada1f3f9d..f1c6d21019e3 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -14,7 +14,7 @@ jobs: with: fetch-depth: 0 persist-credentials: false - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: "3.x" - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 @@ -32,7 +32,7 @@ jobs: persist-credentials: false - name: Set up Python 3 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: '3.11' @@ -40,7 +40,7 @@ jobs: run: pip3 install ruff - name: Set up reviewdog - uses: reviewdog/action-setup@e04ffabe3898a0af8d0fb1af00c188831c4b5893 # v1.3.9 + uses: reviewdog/action-setup@d8edfce3dd5e1ec6978745e801f9c50b5ef80252 # v1.4.0 - name: Run ruff env: @@ -61,7 +61,7 @@ jobs: persist-credentials: false - name: Set up Python 3 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: '3.11' @@ -69,7 +69,7 @@ jobs: run: pip3 install -r requirements/testing/mypy.txt -r requirements/testing/all.txt - name: Set up reviewdog - uses: reviewdog/action-setup@e04ffabe3898a0af8d0fb1af00c188831c4b5893 # v1.3.9 + uses: reviewdog/action-setup@d8edfce3dd5e1ec6978745e801f9c50b5ef80252 # v1.4.0 - name: Run mypy env: @@ -92,7 +92,7 @@ jobs: persist-credentials: false - name: eslint - uses: reviewdog/action-eslint@2fee6dd72a5419ff4113f694e2068d2a03bb35dd # v1.33.2 + uses: reviewdog/action-eslint@556a3fdaf8b4201d4d74d406013386aa4f7dab96 # v1.34.0 with: filter_mode: nofilter github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/mypy-stubtest.yml b/.github/workflows/mypy-stubtest.yml index b40909b371a6..3815efd08954 100644 --- a/.github/workflows/mypy-stubtest.yml +++ b/.github/workflows/mypy-stubtest.yml @@ -17,12 +17,12 @@ jobs: persist-credentials: false - name: Set up Python 3 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: '3.11' - name: Set up reviewdog - uses: reviewdog/action-setup@e04ffabe3898a0af8d0fb1af00c188831c4b5893 # v1.3.9 + uses: reviewdog/action-setup@d8edfce3dd5e1ec6978745e801f9c50b5ef80252 # v1.4.0 - name: Install tox run: python -m pip install tox diff --git a/.github/workflows/pr_welcome.yml b/.github/workflows/pr_welcome.yml index 0a654753861a..2580b5a008a0 100644 --- a/.github/workflows/pr_welcome.yml +++ b/.github/workflows/pr_welcome.yml @@ -9,10 +9,10 @@ jobs: permissions: pull-requests: write steps: - - uses: actions/first-interaction@753c925c8d1ac6fede23781875376600628d9b5d # v3.0.0 + - uses: actions/first-interaction@1c4688942c71f71d4f5502a26ea67c331730fa4d # v3.1.0 with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - pr-message: >+ + repo_token: ${{ secrets.GITHUB_TOKEN }} + pr_message: >+ Thank you for opening your first PR into Matplotlib! diff --git a/.github/workflows/stale-tidy.yml b/.github/workflows/stale-tidy.yml index bc50dc892155..85c8fec38e63 100644 --- a/.github/workflows/stale-tidy.yml +++ b/.github/workflows/stale-tidy.yml @@ -9,7 +9,7 @@ jobs: if: github.repository == 'matplotlib/matplotlib' runs-on: ubuntu-latest steps: - - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0 + - uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} operations-per-run: 300 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index b65b44a59e88..bbc58eca4fb0 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -9,7 +9,7 @@ jobs: if: github.repository == 'matplotlib/matplotlib' runs-on: ubuntu-latest steps: - - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0 + - uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} operations-per-run: 20 @@ -36,3 +36,4 @@ jobs: ascending: true exempt-issue-labels: "keep" exempt-pr-labels: "keep,status: orphaned PR" + sort-by: updated diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e965819628be..048f11be14d2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -60,10 +60,6 @@ jobs: extra-requirements: '-r requirements/testing/extra.txt' # https://github.com/matplotlib/matplotlib/issues/29844 pygobject-ver: '<3.52.0' - - os: ubuntu-22.04-arm - python-version: '3.12' - # https://github.com/matplotlib/matplotlib/issues/29844 - pygobject-ver: '<3.52.0' - name-suffix: "(Extra TeX packages)" os: ubuntu-22.04 python-version: '3.13' @@ -77,14 +73,17 @@ jobs: pygobject-ver: '<3.52.0' - os: ubuntu-24.04 python-version: '3.12' - - os: macos-13 # This runner is on Intel chips. - # merge numpy and pandas install in nighties test when this runner is dropped + - 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 pygobject-ver: '<3.52.0' - - os: macos-14 # This runner is on M1 (arm64) chips. + - os: macos-15 # This runner is on M1 (arm64) chips. python-version: '3.13' # https://github.com/matplotlib/matplotlib/issues/29732 pygobject-ver: '<3.52.0' @@ -96,7 +95,7 @@ jobs: persist-credentials: false - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 with: python-version: ${{ matrix.python-version }} allow-prereleases: true @@ -116,6 +115,7 @@ jobs: fonts-wqy-zenhei \ gdb \ gir1.2-gtk-3.0 \ + gir1.2-gtk-4.0 \ graphviz \ inkscape \ language-pack-de \ @@ -149,9 +149,8 @@ jobs: if [[ "${{ matrix.name-suffix }}" != '(Minimum Versions)' ]]; then sudo apt-get install -yy --no-install-recommends ffmpeg poppler-utils fi - if [[ "${{ matrix.os }}" = ubuntu-22.04 || "${{ matrix.os }}" = ubuntu-22.04-arm ]]; then + if [[ "${{ matrix.os }}" = ubuntu-22.04 ]]; then sudo apt-get install -yy --no-install-recommends \ - gir1.2-gtk-4.0 \ libgirepository1.0-dev else # ubuntu-24.04 sudo apt-get install -yy --no-install-recommends \ @@ -179,7 +178,7 @@ jobs: esac - name: Cache pip - uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 if: startsWith(runner.os, 'Linux') with: path: ~/.cache/pip @@ -187,7 +186,7 @@ jobs: restore-keys: | ${{ matrix.os }}-py${{ matrix.python-version }}-pip- - name: Cache pip - uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 if: startsWith(runner.os, 'macOS') with: path: ~/Library/Caches/pip @@ -195,7 +194,7 @@ jobs: restore-keys: | ${{ matrix.os }}-py${{ matrix.python-version }}-pip- - name: Cache ccache - uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: | ~/.ccache @@ -203,7 +202,7 @@ jobs: restore-keys: | ${{ matrix.os }}-py${{ matrix.python-version }}-ccache- - name: Cache Matplotlib - uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: | ~/.cache/matplotlib @@ -256,7 +255,7 @@ jobs: ) # PyQt5 does not have any wheels for ARM on Linux. - if [[ "${{ matrix.os }}" != 'ubuntu-22.04-arm' ]]; then + if [[ "${{ matrix.os }}" != 'ubuntu-24.04-arm' ]]; then python -mpip install --upgrade --only-binary :all: pyqt5 && python -c 'import PyQt5.QtCore' && echo 'PyQt5 is available' || @@ -297,13 +296,7 @@ jobs: python -m pip install pytz tzdata # Must be installed for Pandas. python -m pip install \ --index-url https://pypi.anaconda.org/scientific-python-nightly-wheels/simple \ - --upgrade --only-binary=:all: numpy - # wheels for intel osx is not always available on nightly wheels index, merge this back into - # the above install command when the OSX-13 (intel) runners are dropped. - python -m pip install \ - --index-url https://pypi.anaconda.org/scientific-python-nightly-wheels/simple \ - --upgrade --only-binary=:all: pandas || true - + --upgrade --only-binary=:all: numpy pandas - name: Install Matplotlib run: | @@ -377,7 +370,7 @@ jobs: run: | if [[ "${{ runner.os }}" != 'macOS' ]]; then LCOV_IGNORE_ERRORS=',' # do not ignore any lcov errors by default - if [[ "${{ matrix.os }}" = ubuntu-24.04 ]]; then + if [[ "${{ matrix.os }}" = ubuntu-24.04 || "${{ matrix.os }}" = ubuntu-24.04-arm ]]; then # filter mismatch and unused-entity errors detected by lcov 2.x LCOV_IGNORE_ERRORS='mismatch,unused' fi @@ -396,12 +389,12 @@ jobs: fi - name: Upload code coverage if: ${{ !cancelled() && github.event_name != 'schedule' }} - uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3 + uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 with: name: "${{ matrix.python-version }} ${{ matrix.os }} ${{ matrix.name-suffix }}" token: ${{ secrets.CODECOV_TOKEN }} - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 if: failure() with: name: "${{ matrix.python-version }} ${{ matrix.os }} ${{ matrix.name-suffix }} result images" diff --git a/README.md b/README.md index 7b9c99597c0d..8f9edaad2b5b 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ [![Conda](https://img.shields.io/conda/vn/conda-forge/matplotlib)](https://anaconda.org/conda-forge/matplotlib) [![Downloads](https://img.shields.io/pypi/dm/matplotlib)](https://pypi.org/project/matplotlib) [![NUMFocus](https://img.shields.io/badge/powered%20by-NumFOCUS-orange.svg?style=flat&colorA=E1523D&colorB=007D8A)](https://numfocus.org) +[![LFX Health Score](https://insights.linuxfoundation.org/api/badge/health-score?project=matplotlib)](https://insights.linuxfoundation.org/project/matplotlib) [![Discourse help forum](https://img.shields.io/badge/help_forum-discourse-blue.svg)](https://discourse.matplotlib.org) [![Gitter](https://badges.gitter.im/matplotlib/matplotlib.svg)](https://gitter.im/matplotlib/matplotlib) @@ -14,14 +15,14 @@ [![Codecov status](https://codecov.io/github/matplotlib/matplotlib/badge.svg?branch=main&service=github)](https://app.codecov.io/gh/matplotlib/matplotlib) [![EffVer Versioning](https://img.shields.io/badge/version_scheme-EffVer-0097a7)](https://jacobtomlinson.dev/effver) -![Matplotlib logotype](https://matplotlib.org/_static/logo2.svg) +![Matplotlib logotype](https://matplotlib.org/stable/_static/logo2.svg) Matplotlib is a comprehensive library for creating static, animated, and interactive visualizations in Python. Check out our [home page](https://matplotlib.org/) for more information. -![image](https://matplotlib.org/_static/readme_preview.png) +![image](https://matplotlib.org/stable/_static/readme_preview.png) Matplotlib produces publication-quality figures in a variety of hardcopy formats and interactive environments across platforms. Matplotlib can be diff --git a/ci/mypy-stubtest-allowlist.txt b/ci/mypy-stubtest-allowlist.txt index 46ec06e0a9f1..12b6feb9b2e0 100644 --- a/ci/mypy-stubtest-allowlist.txt +++ b/ci/mypy-stubtest-allowlist.txt @@ -49,3 +49,6 @@ matplotlib\.figure\.FigureBase\.get_figure # getitem method only exists for 3.10 deprecation backcompatability matplotlib\.inset\.InsetIndicator\.__getitem__ + +# only defined in stubs; not present at runtime +matplotlib\.animation\.EventSourceProtocol diff --git a/doc/_static/switcher.json b/doc/_static/switcher.json index a5ba8551e994..3912afd8a17d 100644 --- a/doc/_static/switcher.json +++ b/doc/_static/switcher.json @@ -1,7 +1,7 @@ [ { "name": "3.10 (stable)", - "version": "3.10.5", + "version": "3.10.7", "url": "https://matplotlib.org/stable/", "preferred": true }, diff --git a/doc/_static/zenodo_cache/16999430.svg b/doc/_static/zenodo_cache/16999430.svg new file mode 100644 index 000000000000..44c448643e91 --- /dev/null +++ b/doc/_static/zenodo_cache/16999430.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + DOI + + + DOI + + + 10.5281/zenodo.16999430 + + + 10.5281/zenodo.16999430 + + + \ No newline at end of file diff --git a/doc/_static/zenodo_cache/17298696.svg b/doc/_static/zenodo_cache/17298696.svg new file mode 100644 index 000000000000..9aa8d7c94349 --- /dev/null +++ b/doc/_static/zenodo_cache/17298696.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + DOI + + + DOI + + + 10.5281/zenodo.17298696 + + + 10.5281/zenodo.17298696 + + + \ No newline at end of file diff --git a/doc/api/axes_api.rst b/doc/api/axes_api.rst index b742ce9b7a55..f5af8744a2bc 100644 --- a/doc/api/axes_api.rst +++ b/doc/api/axes_api.rst @@ -73,6 +73,7 @@ Basic Axes.eventplot Axes.pie + Axes.pie_label Axes.stackplot diff --git a/doc/api/next_api_changes/behavior/30532-TH.rst b/doc/api/next_api_changes/behavior/30532-TH.rst new file mode 100644 index 000000000000..3d368c566039 --- /dev/null +++ b/doc/api/next_api_changes/behavior/30532-TH.rst @@ -0,0 +1,4 @@ +Default name of ``ListedColormap`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The default name of `.ListedColormap` has changed from "from_list" to "unnamed". diff --git a/doc/api/next_api_changes/behavior/30634-AL.rst b/doc/api/next_api_changes/behavior/30634-AL.rst new file mode 100644 index 000000000000..585de1ea14eb --- /dev/null +++ b/doc/api/next_api_changes/behavior/30634-AL.rst @@ -0,0 +1,6 @@ +hist2d no longer forces axes limits +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Previously, `.Axes.hist2d` would force the axes x and y limits to the extents +of the histogrammed data, ignoring any other artists. `.Axes.hist2d` now +behaves similarly to `.Axes.imshow`: axes limits are updated to fit the data, +but autoscaling is not otherwise disabled. diff --git a/doc/api/next_api_changes/deprecations/30364-AS.rst b/doc/api/next_api_changes/deprecations/30364-AS.rst new file mode 100644 index 000000000000..4f5493b8b706 --- /dev/null +++ b/doc/api/next_api_changes/deprecations/30364-AS.rst @@ -0,0 +1,4 @@ +Parameters ``Axes3D.set_aspect(..., anchor=..., share=...)`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +The parameters *anchor* and *share* of `.Axes3D.set_aspect` are deprecated. +They had no effect on 3D axes and will be removed in a future version. diff --git a/doc/api/next_api_changes/deprecations/30531-TH.rst b/doc/api/next_api_changes/deprecations/30531-TH.rst new file mode 100644 index 000000000000..19d51fd2fb6c --- /dev/null +++ b/doc/api/next_api_changes/deprecations/30531-TH.rst @@ -0,0 +1,16 @@ +In-place modifications of colormaps +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Colormaps are planned to become immutable in the long term. + +As a first step, in-place modifications of colormaps are now pending-deprecated. +This affects the following methods of `.Colormap`: + +- `.Colormap.set_bad` - use ``cmap.with_extremes(bad=...)`` instead +- `.Colormap.set_under` - use ``cmap.with_extremes(under=...)`` instead +- `.Colormap.set_over` - use ``cmap.with_extremes(over=...)`` instead +- `.Colormap.set_extremes` - use ``cmap.with_extremes(...)`` instead + +Use the respective `.Colormap.with_extremes` and appropriate keyword arguments +instead which returns a copy of the colormap (available since matplotlib 3.4). +Alternatively, if you create the colormap yourself, you can also pass the +respective arguments to the constructor (available since matplotlib 3.11). diff --git a/doc/api/prev_api_changes/api_changes_3.10.7.rst b/doc/api/prev_api_changes/api_changes_3.10.7.rst new file mode 100644 index 000000000000..a60061e86277 --- /dev/null +++ b/doc/api/prev_api_changes/api_changes_3.10.7.rst @@ -0,0 +1,10 @@ +API Changes for 3.10.7 +====================== + +Development +----------- + +New minimum version of pyparsing +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The minimum required version of ``pyparsing`` has been updated from 2.3.1 to 3.0.0. diff --git a/doc/api/pyplot_summary.rst b/doc/api/pyplot_summary.rst index c4a860fd2590..97d9c576cc86 100644 --- a/doc/api/pyplot_summary.rst +++ b/doc/api/pyplot_summary.rst @@ -64,6 +64,7 @@ Basic stem eventplot pie + pie_label stackplot broken_barh vlines diff --git a/doc/api/toolkits/mplot3d/faq.rst b/doc/api/toolkits/mplot3d/faq.rst index e9ba804648e0..20fe81e574fe 100644 --- a/doc/api/toolkits/mplot3d/faq.rst +++ b/doc/api/toolkits/mplot3d/faq.rst @@ -6,8 +6,7 @@ mplot3d FAQ How is mplot3d different from Mayavi? ===================================== -`Mayavi `_ -is a very powerful and featureful 3D graphing library. For advanced +Mayavi_ is a very powerful and featureful 3D graphing library. For advanced 3D scenes and excellent rendering capabilities, it is highly recommended to use Mayavi. @@ -37,8 +36,7 @@ rendered properly in matplotlib's 2D rendering engine. This problem will likely not be solved until OpenGL support is added to all of the backends (patches are greatly welcomed). Until then, if you need complex -3D scenes, we recommend using -`MayaVi `_. +3D scenes, we recommend using Mayavi_. I don't like how the 3D plot is laid out, how do I change that? @@ -49,3 +47,5 @@ Work is being done to eliminate this issue. For matplotlib v1.1.0, there is a semi-official manner to modify these parameters. See the note in the :mod:`.mplot3d.axis3d` section of the mplot3d API documentation for more information. + +.. _Mayavi: https://docs.enthought.com/mayavi/mayavi/ diff --git a/doc/api/toolkits/mplot3d/view_angles.rst b/doc/api/toolkits/mplot3d/view_angles.rst index 75b24ba9c7b0..e4200cd2d0e4 100644 --- a/doc/api/toolkits/mplot3d/view_angles.rst +++ b/doc/api/toolkits/mplot3d/view_angles.rst @@ -11,8 +11,7 @@ The position of the viewport "camera" in a 3D plot is defined by three angles: *elevation*, *azimuth*, and *roll*. From the resulting position, it always points towards the center of the plot box volume. The angle direction is a common convention, and is shared with -`PyVista `_ and -`MATLAB `_. +`PyVista `_ and MATLAB_. Note that a positive roll angle rotates the viewing plane clockwise, so the 3d axes will appear to rotate counter-clockwise. @@ -51,8 +50,7 @@ can be specified by setting :rc:`axes3d.mouserotationstyle`, see :doc:`/users/explain/customizing`. Prior to v3.10, the 2D mouse position corresponded directly -to azimuth and elevation; this is also how it is done -in `MATLAB `_. +to azimuth and elevation; this is also how it is done in MATLAB_. To keep it this way, set ``mouserotationstyle: azel``. This approach works fine for spherical coordinate plots, where the *z* axis is special; however, it leads to a kind of 'gimbal lock' when looking down the *z* axis: @@ -131,7 +129,7 @@ Henriksen et al. [Henriksen2002]_ provide an overview. In summary: You can try out one of the various mouse rotation styles using: -.. code:: +.. code-block:: python import matplotlib as mpl mpl.rcParams['axes3d.mouserotationstyle'] = 'trackball' # 'azel', 'trackball', 'sphere', or 'arcball' @@ -188,6 +186,7 @@ the arcball to the border occurs at 45°, set the border width to The border is a circular arc, wrapped around the arcball sphere cylindrically (like a doughnut), joined smoothly to the sphere, much like Bell's hyperbola. +.. _MATLAB: https://www.mathworks.com/help/matlab/ref/view.html .. [Shoemake1992] Ken Shoemake, "ARCBALL: A user interface for specifying three-dimensional rotation using a mouse", in Proceedings of Graphics diff --git a/doc/conf.py b/doc/conf.py index 4d922a5636e1..d625038d149c 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -372,7 +372,7 @@ def gallery_image_warning_filter(record): :class: sphx-glr-download-link-note :ref:`Go to the end ` - to download the full example code.{2} + to download the full example code{2} .. rst-class:: sphx-glr-example-title diff --git a/doc/devel/contribute.rst b/doc/devel/contribute.rst index 558e19790d82..3667d6e20a5d 100644 --- a/doc/devel/contribute.rst +++ b/doc/devel/contribute.rst @@ -29,7 +29,8 @@ Ways to contribute * **You are a Matplotlib user, and you see a bug, a potential improvement, or something that annoys you, and you can fix it.** - You can search our issue tracker for an existing issue that describes your problem or + You can search our `issue tracker `__ + for an existing issue that describes your problem or open a new issue to inform us of the problem you observed and discuss the best approach to fix it. If your contributions would not be captured on GitHub (social media, communication, educational content), you can also reach out to us on gitter_, @@ -42,14 +43,11 @@ Ways to contribute Awesome — you have a focus on a specific application and domain and can start there. In this case, maintainers can help you figure out the best - implementation; open an issue or pull request with a starting point, and we'll - be happy to discuss technical approaches. + implementation; `open an issue `__ + in our issue tracker, and we'll be happy to discuss technical approaches. - If you prefer, you can use the `GitHub functionality for "draft" pull requests - `__ - and request early feedback on whatever you are working on, but you should be - aware that maintainers may not review your contribution unless it has the - "Ready to review" state on GitHub. + If you can implement the solution yourself, even better! Consider contributing + the change as a :ref:`pull request ` right away. * **You are new to Matplotlib, both as a user and contributor, and want to start contributing but have yet to develop a particular interest.** @@ -190,13 +188,20 @@ If you have developed an extension to Matplotlib, please consider adding it to o Restrictions on Generative AI Usage =================================== -We expect authentic engagement in our community. Be wary of posting output -from Large Language Models or similar generative AI as comments on GitHub or -our discourse server, as such comments tend to be formulaic and low content. -If you use generative AI tools as an aid in developing code or documentation -changes, ensure that you fully understand the proposed changes and can explain -why they are the correct approach and an improvement to the current state. +We expect authentic engagement in our community. +- Do not post output from Large Language Models or similar generative AI as + comments on GitHub or our discourse server, as such comments tend to be + formulaic and low content. +- If you use generative AI tools as an aid in developing code or documentation + changes, ensure that you fully understand the proposed changes and can + explain why they are the correct approach. + +Make sure you have added value based on your personal competency to your +contributions. Just taking some input, feeding it to an AI and posting the +result is not of value to the project. To preserve precious core developer +capacity, we reserve the right to rigorously reject seemingly AI generated +low-value contributions. .. _new_contributors: @@ -240,11 +245,11 @@ process works, technical questions about the code, what makes for good documentation or a blog post, how to get involved in community work, or get a "pre-review" on your PR. -To join, please go to our public community_ channel, and ask to be added to +To join, please go to our public `community gitter`_ channel, and ask to be added to ``#incubator``. One of our core developers will see your message and will add you. .. _gitter: https://gitter.im/matplotlib/matplotlib -.. _community: https://gitter.im/matplotlib/community +.. _community gitter: https://gitter.im/matplotlib/community .. _good_first_issues: @@ -287,7 +292,7 @@ guide you through each step: 4. Check existing pull requests (e.g., :ghpull:`28476`) and filter by the issue number to make sure the issue is not in progress: * If the issue has a pull request (is in progress), tag the user working on the issue, and ask to collaborate (optional). - * If a pull request does not exist, create a `draft pull request `_ and follow the `pull request guidelines `_. + * If there is no pull request, :ref:`create a new pull request `. 5. Please familiarize yourself with the pull request template (see below), and ensure you understand/are able to complete the template when you open your pull request. Additional information can be found in the `pull request guidelines `_. @@ -308,10 +313,7 @@ active contributors, many of whom felt just like you when they started out and are happy to welcome you and support you as you get to know how we work, and where things are. You can reach out on any of our :ref:`communication-channels`. For development questions we recommend reaching out on our development gitter_ -chat room and for community questions reach out at community_. - -.. _gitter: https://gitter.im/matplotlib/matplotlib -.. _community: https://gitter.im/matplotlib/community +chat room and for community questions reach out at `community gitter`_. .. _managing_issues_prs: diff --git a/doc/devel/development_setup.rst b/doc/devel/development_setup.rst index 45b95e48e7ff..4e452fb3bfe7 100644 --- a/doc/devel/development_setup.rst +++ b/doc/devel/development_setup.rst @@ -38,7 +38,7 @@ Set up development environment ============================== You can either work locally on your machine, or online in -`GitHub Codespaces `_, a cloud-based in-browser development +`GitHub Codespaces`_, a cloud-based in-browser development environment. @@ -119,8 +119,8 @@ code, as described in :ref:`development-workflow`. * `GitHub-Contributing to a Project `_ * `GitHub Skills `_ - * :ref:`using-git` - * :ref:`git-resources` + * :external+scipy:ref:`using-git` + * :external+scipy:ref:`git-resources` * `Installing git `_ * `Managing remote repositories `_ @@ -219,7 +219,7 @@ need to be installed when working in codespaces. Create GitHub Codespace :octicon:`codespaces` --------------------------------------------- -`GitHub Codespaces `_ is a cloud-based +`GitHub Codespaces`_ is a cloud-based in-browser development environment that comes with the appropriate setup to contribute to Matplotlib. @@ -260,7 +260,7 @@ Use the "Extensions" icon in the activity bar to install the "Live Server" extension. Locate the ``doc/build/html`` folder in the Explorer, right click the file you want to open and select "Open with Live Server." -.. _`github-codespaces`: https://docs.github.com/codespaces +.. _Github Codespaces: https://docs.github.com/codespaces .. _development-install: diff --git a/doc/devel/development_workflow.rst b/doc/devel/development_workflow.rst index 16766278f658..c0300acf1f7f 100644 --- a/doc/devel/development_workflow.rst +++ b/doc/devel/development_workflow.rst @@ -179,9 +179,9 @@ Enter a title for the set of changes with some explanation of what you've done. Mention anything you'd like particular attention for - such as a complicated change or some code you are not happy with. -If you don't think your request is ready to be merged, just say so in your pull -request message and use the "Draft PR" feature of GitHub. This is a good way of -getting some preliminary code review. +If you don't think your request is ready to be merged, make a +:ref:`draft pull request ` and state what aspects you want to have +feedback on. This is a good way of getting some preliminary code review. For more guidance on the mechanics of making a pull request, see GitHub's `pull request tutorial `_. diff --git a/doc/devel/index.rst b/doc/devel/index.rst index 7591359ec811..fdeb08d3b202 100644 --- a/doc/devel/index.rst +++ b/doc/devel/index.rst @@ -189,8 +189,13 @@ and managing a development environment and workflow: Policies and guidelines ======================= -These policies and guidelines help us maintain consistency in the various types -of maintenance work. If you are writing code or documentation, following these policies +.. admonition:: AI Usage + + AI may be used responsibly as a supportive tool, but we expect authentic + contributions. For guidance, see our :ref:`AI policy `. + +These policies and guidelines help us maintain consistency in the various types of +maintenance work. If you are writing code or documentation, following these policies helps maintainers more easily review your work. If you are helping triage, community manage, or release manage, these guidelines describe how our current process works. diff --git a/doc/devel/pr_guide.rst b/doc/devel/pr_guide.rst index a02b52ad5a38..e7e3ceba8f95 100644 --- a/doc/devel/pr_guide.rst +++ b/doc/devel/pr_guide.rst @@ -12,7 +12,7 @@ We value contributions from people with all levels of experience. In particular, if this is your first PR not everything has to be perfect. We'll guide you through the PR process. Nevertheless, please try to follow our guidelines as well as you can to help make the PR process quick and smooth. If your pull request is -incomplete or a work-in-progress, please mark it as a `draft pull requests `_ +incomplete or a work-in-progress, please mark it as a :ref:`draft pull request ` on GitHub and specify what feedback from the developers would be helpful. Please be patient with reviewers. We try our best to respond quickly, but we have @@ -109,15 +109,32 @@ Workflow * The PR should :ref:`target the main branch `. * Tag with descriptive :ref:`labels `. * Set the :ref:`milestone `. -* Keep an eye on the :ref:`number of commits `. +* :ref:`Review ` the contents. * Approve if all of the above topics are handled. -* :ref:`Merge ` if a sufficient number of approvals is reached. +* Keep an eye on the :ref:`number of commits `. +* :ref:`Merge ` if a :ref:`sufficient number of approvals ` is reached. .. _pr-guidelines-details: Detailed guidelines =================== +.. _draft-pr: + +Draft PRs +--------- + +Authors may create a `draft PR`_ (or change to draft status later) if the code +is not yet ready for a regular full review. Typical use cases are posting code +as a basis for discussion or signalling that you intend to rework the code as +a result of feedback. Authors should clearly communicate why the PR has draft +status and what needs to be done to make it ready for review. In particular, +they should explicitly ask for targeted feedback if needed. By default, +reviewers will not look at the code of a draft PR and only respond to specific +questions by the author. + +.. _draft PR: https://docs.github.com/en/github/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests#draft-pull-requests + .. _pr-documentation: Documentation @@ -174,10 +191,27 @@ All Pull Requests should target the main branch. The milestone tag triggers an :ref:`automatic backport ` for milestones which have a corresponding branch. -.. _pr-merging: +.. _pr-review: -Merging -------- +Review +------ + +* Do not let perfect be the enemy of the good, particularly for + documentation or example PRs. If you find yourself making many + small suggestions, either open a PR against the original branch, + 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 + 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. + +.. _pr-approval: + +Approval +-------- As a guiding principle, we require two `approvals`_ from core developers (those with commit rights) before merging a pull request. This two-pairs-of-eyes strategy shall ensure a consistent project direction and prevent accidental @@ -213,11 +247,6 @@ Some explicit rules following from this: A core dev should only champion one PR at a time and we should try to keep the flow of championed PRs reasonable. -After giving the last required approval, the author of the approval should -merge the PR. PR authors should not self-merge except for when another reviewer -explicitly allows it (e.g., "Approve modulo CI passing, may self merge when -green", or "Take or leave the comments. You may self merge".). - .. _pr-automated-tests: Automated tests @@ -225,6 +254,15 @@ Automated tests Before being merged, a PR should pass the :ref:`automated-tests`. If you are unsure why a test is failing, ask on the PR or in our :ref:`communication-channels` +.. _pr-merging: + +Merging +------- +After giving the last required :ref:`approval `, the author of the +approval should merge the PR. PR authors should not self-merge except for when +another reviewer explicitly allows it (e.g., "Approve modulo CI passing, may +self-merge when green", or "Take or leave the comments. You may self merge".). + .. _pr-squashing: Number of commits and squashing @@ -236,19 +274,6 @@ Number of commits and squashing about it is to eliminate binary files (ex multiple test image re-generations) and to remove upstream merges. -* Do not let perfect be the enemy of the good, particularly for - documentation or example PRs. If you find yourself making many - small suggestions, either open a PR against the original branch, - 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 - 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. - - .. _branches_and_backports: Branches and backports diff --git a/doc/devel/release_guide.rst b/doc/devel/release_guide.rst index d1b5c963a295..886b60240415 100644 --- a/doc/devel/release_guide.rst +++ b/doc/devel/release_guide.rst @@ -383,12 +383,23 @@ Building binaries ================= We distribute macOS, Windows, and many Linux wheels as well as a source tarball via -PyPI. Most builders should trigger automatically once the tag is pushed to GitHub: +PyPI. * Windows, macOS and manylinux wheels are built on GitHub Actions. Builds are triggered - by the GitHub Action defined in :file:`.github/workflows/cibuildwheel.yml`, and wheels + by the GitHub Action defined in a separate + `release repository `__, and wheels will be available as artifacts of the build. Both a source tarball and the wheels will be automatically uploaded to PyPI once all of them have been built. +* To trigger the build for the ``matplotlib-release`` repository: + + * If not already created, create a release branch for the meso version (e.g. ``v3.10.x``) + * Edit the ``SOURCE_REF_TO_BUILD`` environment variable at the top of + `wheels.yml `__ + on the release branch to point to the release tag. + * Run the workflow from the release branch, with "pypi" selected for the pypi environment + using the `Workflow Dispatch trigger `__ + * This will run cibuildwheel and upload to PyPI using the Trusted Publishers GitHub Action. + * The auto-tick bot should open a pull request into the `conda-forge feedstock `__. Review and merge (if you have the power to). diff --git a/doc/install/dependencies.rst b/doc/install/dependencies.rst index 4b006d9016e2..11317669817f 100644 --- a/doc/install/dependencies.rst +++ b/doc/install/dependencies.rst @@ -220,7 +220,7 @@ Build dependencies Python ------ -``pip`` normally builds packages using :external+pip:doc:`build isolation `, +``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 :external+pip:ref:`--no-build-isolation ` flag @@ -234,7 +234,7 @@ means that the dependencies must be explicitly installed, either by :ref:`creati - `setuptools_scm `_ (>= 7). Used to update the reported ``mpl.__version__`` based on the current git commit. Also a runtime dependency for editable installs. -- `NumPy `_ (>= 1.22). Also a runtime dependency. +- NumPy_ (>= 1.22). Also a runtime dependency. .. _compile-build-dependencies: @@ -377,7 +377,7 @@ them will be skipped by pytest. .. _pandas: https://pypi.org/project/pandas/ .. _pikepdf: https://pypi.org/project/pikepdf/ .. _psutil: https://pypi.org/project/psutil/ -.. _pytz: https://fonts.google.com/noto/use#faq +.. _pytz: https://pypi.org/project/pytz/ .. _pytest-cov: https://pytest-cov.readthedocs.io/en/latest/ .. _pytest-timeout: https://pypi.org/project/pytest-timeout/ .. _pytest-xdist: https://pypi.org/project/pytest-xdist/ @@ -473,7 +473,7 @@ Optional The documentation can be built without Inkscape and optipng, but the build process will raise various warnings. -* `Inkscape `_ +* Inkscape_ * `optipng `_ * the font `xkcd script `_ or `Comic Neue `_ * the font "Times New Roman" diff --git a/doc/install/index.rst b/doc/install/index.rst index 21804aa81622..68ccfb8634ff 100644 --- a/doc/install/index.rst +++ b/doc/install/index.rst @@ -28,11 +28,20 @@ precompiled wheel for your OS and Python. .. note:: - The following backends work out of the box: Agg, ps, pdf, svg + The following non-interactive backends work out of the box: Agg, + ps, pdf, svg - Python is typically shipped with tk bindings which are used by - TkAgg. Notably, python-build-standalone – used by ``uv`` – does - not include tk bindings that are usable by Matplotlib. + The TkAgg interactive backend also typically works out of the box. + It requires Tk bindings, which are usually provided via the Python + standard library's ``tkinter`` module. On some OSes, you may need + to install a separate package like ``python3-tk`` to add this + component of the standard library. + + Some tools like ``uv`` make use of Python builds from the + python-build-standalone project, which only gained usable Tk + bindings recently (August 2025). If you are having trouble with the + TkAgg backend, ensure you have an up-to-date build, e.g. ``uv self + update && uv python upgrade --reinstall``. For support of other GUI frameworks, LaTeX rendering, saving animations and a larger selection of file formats, you can @@ -61,7 +70,7 @@ Python distributions Matplotlib is part of major Python distributions: -- `Anaconda `_ +- Anaconda_ - `ActiveState ActivePython `_ - `WinPython `_ diff --git a/doc/install/quick_install.inc.rst b/doc/install/quick_install.inc.rst index 2e75b332f6ed..0604a3c8fe75 100644 --- a/doc/install/quick_install.inc.rst +++ b/doc/install/quick_install.inc.rst @@ -29,20 +29,19 @@ .. warning:: - If you install Python with ``uv`` then the ``tkagg`` backend - will not be available because python-build-standalone (used by uv - to distribute Python) does not contain tk bindings that are usable by - Matplotlib (see `this issue`_ for details). If you want Matplotlib - to be able to display plots in a window, you should install one of - the other :ref:`supported GUI frameworks `, - e.g. + uv usually installs its own versions of Python from the + python-build-standalone project, and only recent versions of those + Python builds (August 2025) work properly with the ``tkagg`` backend + for displaying plots in a window. Please make sure you are using uv + 0.8.7 or newer (update with e.g. ``uv self update``) and that your + bundled Python installs are up to date (with ``uv python upgrade + --reinstall``). Alternatively, you can use one of the other + :ref:`supported GUI frameworks `, e.g. .. code-block:: bash uv add matplotlib pyside6 - .. _this issue: https://github.com/astral-sh/uv/issues/6893#issuecomment-2565965851 - .. tab-item:: other :ref:`install-official` diff --git a/doc/project/citing.rst b/doc/project/citing.rst index 249f568625db..c5e56e6f12d4 100644 --- a/doc/project/citing.rst +++ b/doc/project/citing.rst @@ -32,6 +32,12 @@ By version .. START OF AUTOGENERATED +v3.10.7 + .. image:: ../_static/zenodo_cache/17298696.svg + :target: https://doi.org/10.5281/zenodo.17298696 +v3.10.6 + .. image:: ../_static/zenodo_cache/16999430.svg + :target: https://doi.org/10.5281/zenodo.16999430 v3.10.5 .. image:: ../_static/zenodo_cache/16644850.svg :target: https://doi.org/10.5281/zenodo.16644850 diff --git a/doc/release/github_stats.rst b/doc/release/github_stats.rst index e46098219a5d..99ea3fa9c167 100644 --- a/doc/release/github_stats.rst +++ b/doc/release/github_stats.rst @@ -2,23 +2,22 @@ .. _github-stats: -GitHub statistics for 3.10.5 (Jul 31, 2025) +GitHub statistics for 3.10.7 (Oct 08, 2025) =========================================== -GitHub statistics for 2024/12/14 (tag: v3.10.0) - 2025/07/31 +GitHub statistics for 2024/12/14 (tag: v3.10.0) - 2025/10/08 These lists are automatically generated, and may be incomplete or contain duplicates. -We closed 18 issues and merged 67 pull requests. -The full list can be seen `on GitHub `__ +We closed 4 issues and merged 16 pull requests. +The full list can be seen `on GitHub `__ -The following 36 authors contributed 371 commits. +The following 32 authors contributed 422 commits. +* Aasma Gupta +* AASMA GUPTA * Antony Lee -* Brian Christian -* chrisjbillington * Christine P. Chai -* Clément Robert * David Stansby * dependabot[bot] * Elliott Sales de Andrade @@ -27,9 +26,6 @@ The following 36 authors contributed 371 commits. * hannah * hu-xiaonan * Ian Thomas -* ianlv -* IdiotCoffee -* Ines Cachola * Inês Cachola * Jody Klymak * Jouni K. Seppänen @@ -43,105 +39,41 @@ The following 36 authors contributed 371 commits. * Qian Zhang * Raphael Erik Hviding * Roman -* Roman A * Ruth Comer * saikarna913 * Scott Shambaugh * Thomas A Caswell +* Tim Heap * Tim Hoffmann * Trygve Magnus Ræder GitHub issues and pull requests: -Pull Requests (67): - -* :ghpull:`30357`: CIBW updates: fix pypy sections, update cibw version -* :ghpull:`30356`: Manual Backport PR #30195 on branch v3.10.x (ci: Enable wheel builds on Python 3.14) -* :ghpull:`30352`: Backport PR #28554 on branch v3.10.x (BLD: Enable wheels on Windows-on-ARM) -* :ghpull:`30353`: Backport PR #30345 on branch v3.10.x (qt: Use better devicePixelRatio event to refresh scaling) -* :ghpull:`30350`: Backport PR #30344 on branch v3.10.x (Support fractional HiDPI in GTK4 backend) -* :ghpull:`30277`: Backport PR #30271 on branch v3.10.x (Reduce pause time in interactive timer test) -* :ghpull:`30351`: Backport PR #30327 on branch v3.10.x (FIX Update Axes limits from Axes.add_collection(... autolim=True)) -* :ghpull:`30345`: qt: Use better devicePixelRatio event to refresh scaling -* :ghpull:`28554`: BLD: Enable wheels on Windows-on-ARM -* :ghpull:`30292`: Backport PR #30237: Add explicit ``**options: Any`` for ``add_subplot`` m… -* :ghpull:`29935`: Backport PR #29908 on branch v3.10.x (TST: Use text placeholders for empty legends) -* :ghpull:`30327`: FIX Update Axes limits from Axes.add_collection(... autolim=True) -* :ghpull:`30344`: Support fractional HiDPI in GTK4 backend -* :ghpull:`30326`: Backport PR #30321 on branch v3.10.x (Fix type annotation for Axes.get_legend() to include None) -* :ghpull:`30321`: Fix type annotation for Axes.get_legend() to include None -* :ghpull:`30287`: Backport PR #30286 on branch v3.10.x (Fix whitespace in _axes.py error message) -* :ghpull:`30288`: Backport PR #30283 on branch v3.10.x (changed the FAQ link to point to the correct path) -* :ghpull:`30293`: Backport PR #30289 on branch v3.10.x (DOC: Fix build with pybind11 3) -* :ghpull:`30283`: changed the FAQ link to point to the correct path -* :ghpull:`30286`: Fix whitespace in _axes.py error message -* :ghpull:`30271`: Reduce pause time in interactive timer test -* :ghpull:`30269`: Backport PR #30186 on branch v3.10.x (Fix figure legend when drawing stackplots) -* :ghpull:`30186`: Fix figure legend when drawing stackplots -* :ghpull:`30268`: Backport PR #30233 on branch v3.10.x (Check that stem input is 1D) -* :ghpull:`30233`: Check that stem input is 1D -* :ghpull:`30259`: Backport PR #30256 on branch v3.10.x (Time out in _get_executable_info) -* :ghpull:`30256`: Time out in _get_executable_info -* :ghpull:`30237`: Add explicit ``**options: Any`` for ``add_subplot`` method -* :ghpull:`30253`: Backport PR #30243 on branch v3.10.x (Fix FancyArrow rendering for zero-length arrows) -* :ghpull:`30243`: Fix FancyArrow rendering for zero-length arrows -* :ghpull:`30250`: Backport PR #30244 on branch v3.10.x (DOC: Recommend to use bare Figure instances for saving to file) -* :ghpull:`30247`: Backport PR #30246 on branch v3.10.x (chore: remove redundant words in comment) -* :ghpull:`30246`: chore: remove redundant words in comment -* :ghpull:`30240`: Backport PR #30236 on branch v3.10.x (Copy-edit the docstring of AuxTransformBox.) -* :ghpull:`30236`: Copy-edit the docstring of AuxTransformBox. -* :ghpull:`30234`: Backport PR #30209 on branch v3.10.x (Clean up Qt socket notifier to avoid spurious interrupt handler calls) -* :ghpull:`30209`: Clean up Qt socket notifier to avoid spurious interrupt handler calls -* :ghpull:`30195`: ci: Enable wheel builds on Python 3.14 -* :ghpull:`30229`: Backport PR #30221 on branch v3.10.x (BUG: fix future incompatibility with Pillow 13) -* :ghpull:`30221`: BUG: fix future incompatibility with Pillow 13 -* :ghpull:`30228`: Backport PR #30098 on branch v3.10.x (Fix label_outer in the presence of colorbars.) -* :ghpull:`30227`: Backport PR #30223 on branch v3.10.x (Polar log scale: fix inner patch boundary and spine location) -* :ghpull:`30098`: Fix label_outer in the presence of colorbars. -* :ghpull:`30223`: Polar log scale: fix inner patch boundary and spine location -* :ghpull:`30217`: Backport PR #30198 on branch v3.10.x (Implement Path.__deepcopy__ avoiding infinite recursion) -* :ghpull:`30198`: Implement Path.__deepcopy__ avoiding infinite recursion -* :ghpull:`30213`: Backport PR #30212 on branch v3.10.x ([Doc]: fix bug in release notes for matplotlib v3.5.0 and v3.7.0) -* :ghpull:`30189`: Backport PR #30180 on branch v3.10.x (DOC: expand polar example) -* :ghpull:`30167`: Backport PR #30162 on branch v3.10.x (TST: Fix runtime error checking NaN input to format_cursor_data) -* :ghpull:`30162`: TST: Fix runtime error checking NaN input to format_cursor_data -* :ghpull:`30146`: Backport PR #30144 on branch v3.10.x (js: Fix externally-controlled format strings) -* :ghpull:`30144`: js: Fix externally-controlled format strings -* :ghpull:`30140`: Backport PR #30118 on branch v3.10.x (CI: Skip jobs on forks) -* :ghpull:`30120`: Backport PR #30114 on branch v3.10.x (Fix _is_tensorflow_array.) -* :ghpull:`30122`: Backport PR #30119 on branch v3.10.x (Add some types to _mathtext.py) -* :ghpull:`30119`: Add some types to _mathtext.py -* :ghpull:`30114`: Fix _is_tensorflow_array. -* :ghpull:`30106`: Backport PR #30089 on branch v3.10.x (FIX: fix submerged margins algorithm being applied twice) -* :ghpull:`30089`: FIX: fix submerged margins algorithm being applied twice -* :ghpull:`30101`: Backport PR #30096 on branch v3.10.x (Fix OffsetBox custom picker) -* :ghpull:`30096`: Fix OffsetBox custom picker -* :ghpull:`30081`: Backport PR #30079 on branch v3.10.x (FIX: cast legend handles to list) -* :ghpull:`30079`: FIX: cast legend handles to list -* :ghpull:`30057`: Backport PR #29895 on branch v3.10.x (The 'lines.markeredgecolor' now doesn't interfere on the color of errorbar caps)" -* :ghpull:`29895`: The 'lines.markeredgecolor' now doesn't interfere on the color of errorbar caps -* :ghpull:`30033`: Backport PR #30029 on branch v3.10.x (Update diagram in subplots_adjust documentation to clarify parameters) - -Issues (18): - -* :ghissue:`30370`: [Bug]: matplotlib simple example fails in Python 3.14 -* :ghissue:`30218`: [Bug]: Rendering on Wayland with fractional scaling looks bad -* :ghissue:`30318`: [Bug]: type annotation of ``Axes.get_legend()`` misses ``None`` -* :ghissue:`30169`: [Doc]: Incorrect FAQ Link on Tutorials Page -* :ghissue:`30285`: [Bug]: Missing whitespace in _axes.py error message -* :ghissue:`30280`: [Bug]: Pillow 11.3 raises a deprecation warning when using TkAgg -* :ghissue:`30158`: [Bug]: Stackplot in SubFigure raises when drawing Legend -* :ghissue:`30216`: [Bug]: stem complaining about PyTorch's Tensor -* :ghissue:`30242`: [Bug]: Cannot create empty FancyArrow (expired numpy deprecation) -* :ghissue:`30249`: [Bug]: DeprecationWarning from Pillow 11.3.0 about 'mode' parameter of PIL.Image.fromarray() -* :ghissue:`29688`: [Bug]: "Bad file descriptor" raised repeatedly when plt.pause() interrupted in IPython -* :ghissue:`27305`: [Bug]: Axes.label_outer() does not work when there is a colorbar -* :ghissue:`30179`: [Bug]: Inner border is not rendered correctly when using log-scale and polar projection. -* :ghissue:`29157`: FUTURE BUG: reconsider how we deep-copy path objects -* :ghissue:`30152`: [Bug]: Test pipeline failure on windows -* :ghissue:`30076`: [Bug]: Layout Managers are confused by complex arrangement of sub-figures and gridspec's -* :ghissue:`30078`: [Bug]: legend no longer works with itertools.chain -* :ghissue:`29780`: [Bug]: Setting 'lines.markeredgecolor' affects color of errorbar caps. +Pull Requests (16): + +* :ghpull:`30628`: Backport PR #30626 on branch v3.10.x (MNT: Fix new F401 unused imports warnings) +* :ghpull:`30626`: MNT: Fix new F401 unused imports warnings +* :ghpull:`30589`: Backport PR #29745: Use PEP8 style method and function names from +* :ghpull:`30614`: Backport PR #30612 on branch v3.10.x (MNT: update black pin) +* :ghpull:`30612`: MNT: update black pin +* :ghpull:`30572`: Backport PR #30571 on branch v3.10.x (CI: remove macos13) +* :ghpull:`30571`: CI: remove macos13 +* :ghpull:`30570`: Backport PR #30558 on branch v3.10.x (Fix stubtest with mypy 18) +* :ghpull:`30558`: Fix stubtest with mypy 18 +* :ghpull:`30540`: Backport PR #30539 on branch v3.10.x (Fix scale_unit/scale_units typo in quiver docs) +* :ghpull:`30539`: Fix scale_unit/scale_units typo in quiver docs +* :ghpull:`30518`: Backport PR #30497 on branch v3.10.x (TST: Use a temporary directory for test_save_figure_return) +* :ghpull:`30497`: TST: Use a temporary directory for test_save_figure_return +* :ghpull:`30506`: Backport PR #30490 on branch v3.10.x (Fix SVG rendering error in def update_background) +* :ghpull:`30490`: Fix SVG rendering error in def update_background +* :ghpull:`30494`: Backport PR #30492 on branch v3.10.x (DOC: pytz link should be from PyPI) + +Issues (4): + +* :ghissue:`30611`: [MNT]: black version +* :ghissue:`30551`: [Bug]: Mypy stubtest failure on disjoint_base +* :ghissue:`30493`: [Bug]: test_save_figure_return seems flaky +* :ghissue:`30485`: [Bug]: figures with SpanSelector(..., useblit=True) can't be saved to SVG or PDF Previous GitHub statistics diff --git a/doc/release/next_whats_new/colormap_with_alpha b/doc/release/next_whats_new/colormap_with_alpha.rst similarity index 100% rename from doc/release/next_whats_new/colormap_with_alpha rename to doc/release/next_whats_new/colormap_with_alpha.rst diff --git a/doc/release/next_whats_new/legend_line_width.rst b/doc/release/next_whats_new/legend_line_width.rst new file mode 100644 index 000000000000..d8cfd57640a8 --- /dev/null +++ b/doc/release/next_whats_new/legend_line_width.rst @@ -0,0 +1,21 @@ +``legend.linewidth`` rcParam and parameter +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A new rcParam ``legend.linewidth`` has been added to control the line width of +the legend's box edges. When set to ``None`` (the default), it inherits the +value from ``patch.linewidth``. This allows for independent control of the +legend frame line width without affecting other elements. + +The `.Legend` constructor also accepts a new *linewidth* parameter to set the +legend frame line width directly, overriding the rcParam value. + +.. plot:: + :include-source: true + :alt: A line plot with a legend showing a thick border around the legend box. + + import matplotlib.pyplot as plt + + fig, ax = plt.subplots() + ax.plot([1, 2, 3], label='data') + ax.legend(linewidth=2.0) # Thick legend box edge + plt.show() diff --git a/doc/release/next_whats_new/patchcollection_legend.rst b/doc/release/next_whats_new/patchcollection_legend.rst new file mode 100644 index 000000000000..58574e9e6757 --- /dev/null +++ b/doc/release/next_whats_new/patchcollection_legend.rst @@ -0,0 +1,22 @@ +``PatchCollection`` legends now supported +------------------------------------------ +`.PatchCollection` instances now properly display in legends when given a label. +Previously, labels on `~.PatchCollection` objects were ignored by the legend +system, requiring users to create manual legend entries. + +.. plot:: + :include-source: true + :alt: The legend entry displays a rectangle matching the visual properties (colors, line styles, line widths) of the first patch in the collection. + + import matplotlib.pyplot as plt + import matplotlib.patches as mpatches + from matplotlib.collections import PatchCollection + + fig, ax = plt.subplots() + patches = [mpatches.Circle((0, 0), 0.1), mpatches.Rectangle((0.5, 0.5), 0.2, 0.3)] + pc = PatchCollection(patches, facecolor='blue', edgecolor='black', label='My patches') + ax.add_collection(pc) + ax.legend() # Now displays the label "My patches" + plt.show() + +This fix resolves :ghissue:`23998`. diff --git a/doc/release/next_whats_new/pie_label.rst b/doc/release/next_whats_new/pie_label.rst new file mode 100644 index 000000000000..6dc9a3f619c2 --- /dev/null +++ b/doc/release/next_whats_new/pie_label.rst @@ -0,0 +1,28 @@ +Adding labels to pie chart wedges +--------------------------------- + +The new `~.Axes.pie_label` method adds a label to each wedge in a pie chart created with +`~.Axes.pie`. It can take + +* a list of strings, similar to the existing *labels* parameter of `~.Axes.pie` +* a format string similar to the existing *autopct* parameter of `~.Axes.pie` except + that it uses the `str.format` method and it can handle absolute values as well as + fractions/percentages + +For more examples, see :doc:`/gallery/pie_and_polar_charts/pie_label`. + +.. plot:: + :include-source: true + :alt: A pie chart with three labels on each wedge, showing a food type, number, and fraction associated with the wedge. + + import matplotlib.pyplot as plt + + data = [36, 24, 8, 12] + labels = ['spam', 'eggs', 'bacon', 'sausage'] + + fig, ax = plt.subplots() + pie = ax.pie(data) + + ax.pie_label(pie, labels, distance=1.1) + ax.pie_label(pie, '{frac:.1%}', distance=0.7) + ax.pie_label(pie, '{absval:d}', distance=0.4) diff --git a/doc/release/next_whats_new/pyplot-register-figure.rst b/doc/release/next_whats_new/pyplot-register-figure.rst new file mode 100644 index 000000000000..86ffcbf2294a --- /dev/null +++ b/doc/release/next_whats_new/pyplot-register-figure.rst @@ -0,0 +1,63 @@ +Figures can be attached to and removed from pyplot +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Figures can now be attached to and removed from management through pyplot, which in +the background also means a less strict coupling to backends. + +In particular, standalone figures (created with the `.Figure` constructor) can now be +registered with the `.pyplot` module by calling ``plt.figure(fig)``. This allows to +show them with ``plt.show()`` as you would do with any figure created with pyplot +factory methods such as ``plt.figure()`` or ``plt.subplots()``. + +When closing a shown figure window, the related figure is reset to the standalone +state, i.e. it's not visible to pyplot anymore, but if you still hold a reference +to it, you can continue to work with it (e.g. do ``fig.savefig()``, or re-add it +to pyplot with ``plt.figure(fig)`` and then show it again). + +The following is now possible - though the example is exaggerated to show what's +possible. In practice, you'll stick with much simpler versions for better +consistency :: + + import matplotlib.pyplot as plt + from matplotlib.figure import Figure + + # Create a standalone figure + fig = Figure() + ax = fig.add_subplot() + ax.plot([1, 2, 3], [4, 5, 6]) + + # Register it with pyplot + plt.figure(fig) + + # Modify the figure through pyplot + plt.xlabel("x label") + + # Show the figure + plt.show() + + # Close the figure window through the GUI + + # Continue to work on the figure + fig.savefig("my_figure.png") + ax.set_ylabel("y label") + + # Re-register the figure and show it again + plt.figure(fig) + plt.show() + +Technical detail: Standalone figures use `.FigureCanvasBase` as canvas. This is +replaced by a backend-dependent subclass when registering with pyplot, and is +reset to `.FigureCanvasBase` when the figure is closed. `.Figure.savefig` uses +the current canvas to save the figure (if possible). Since `.FigureCanvasBase` +can not render the figure, when saving the figure, it will fallback to a suitable +canvas subclass, e.g. `.FigureCanvasAgg` for raster outputs such as png. +Any Agg-based backend will create the same file output. However, there may be +slight differences for non-Agg backends; e.g. if you use "GTK4Cairo" as +interactive backend, ``fig.savefig("file.png")`` may create a slightly different +image depending on whether the figure is registered with pyplot or not. In +general, you should not store a reference to the canvas, but rather always +obtain it from the figure with ``fig.canvas``. This will return the current +canvas, which is either the original `.FigureCanvasBase` or a backend-dependent +subclass, depending on whether the figure is registered with pyplot or not. +Additionally, the swapping of the canvas currently does not play well with +blitting of matplotlib widgets; in such cases either deactivate blitting or do not +continue to use the figure (e.g. saving it after closing the window). diff --git a/doc/release/next_whats_new/scroll_to_zoom.rst b/doc/release/next_whats_new/scroll_to_zoom.rst new file mode 100644 index 000000000000..1be522b7a255 --- /dev/null +++ b/doc/release/next_whats_new/scroll_to_zoom.rst @@ -0,0 +1,9 @@ +Zooming using mouse wheel +~~~~~~~~~~~~~~~~~~~~~~~~~ + +``Ctrl+MouseWheel`` can be used to zoom in the plot windows. +Additionally, ``x+MouseWheel`` zooms only the x-axis and ``y+MouseWheel`` zooms only the y-axis. + +The zoom focusses on the mouse pointer. With ``Ctrl``, the axes aspect ratio is kept; with ``x`` or ``y``, only the respective axis is scaled. + +Zooming is currently only supported on rectilinear Axes. diff --git a/doc/release/next_whats_new/stackplot_style_sequences.rst b/doc/release/next_whats_new/stackplot_style_sequences.rst new file mode 100644 index 000000000000..209d30a15218 --- /dev/null +++ b/doc/release/next_whats_new/stackplot_style_sequences.rst @@ -0,0 +1,6 @@ +Stackplot styling +----------------- + +`~.Axes.stackplot` now accepts sequences for the style parameters *facecolor*, +*edgecolor*, *linestyle*, and *linewidth*, similar to how the *hatch* parameter +is already handled. diff --git a/doc/release/next_whats_new/violin_stats.rst b/doc/release/next_whats_new/violin_stats.rst new file mode 100644 index 000000000000..a764db01da59 --- /dev/null +++ b/doc/release/next_whats_new/violin_stats.rst @@ -0,0 +1,31 @@ +``violin_stats`` simpler *method* parameter +------------------------------------------- + +The *method* parameter of `~.cbook.violin_stats` may now be specified as tuple of +strings, and has a new default ``("GaussianKDE", "scott")``. Calling +`~.cbook.violin_stats` followed by `~.Axes.violin` is therefore now equivalent to +calling `~.Axes.violinplot`. + +.. plot:: + :include-source: true + :alt: Example showing violin_stats followed by violin gives the same result as violinplot + + import matplotlib.pyplot as plt + from matplotlib.cbook import violin_stats + import numpy as np + + rng = np.random.default_rng(19680801) + data = rng.normal(size=(10, 3)) + + fig, (ax1, ax2) = plt.subplots(ncols=2, layout='constrained', figsize=(6.4, 3.5)) + + # Create the violin plot in one step + ax1.violinplot(data) + ax1.set_title('One Step') + + # Process the data and then create the violin plot + vstats = violin_stats(data) + ax2.violin(vstats) + ax2.set_title('Two Steps') + + plt.show() diff --git a/doc/release/next_whats_new/zoom_boxes.rst b/doc/release/next_whats_new/zoom_boxes.rst new file mode 100644 index 000000000000..8cc0cc1645a3 --- /dev/null +++ b/doc/release/next_whats_new/zoom_boxes.rst @@ -0,0 +1,4 @@ +Consistent zoom boxes +--------------------- + +Zooming now has a consistent dashed box style across all backends. diff --git a/doc/release/prev_whats_new/github_stats_3.10.5.rst b/doc/release/prev_whats_new/github_stats_3.10.5.rst new file mode 100644 index 000000000000..319086baebe5 --- /dev/null +++ b/doc/release/prev_whats_new/github_stats_3.10.5.rst @@ -0,0 +1,142 @@ +.. _github-stats-3_10_5: + +GitHub statistics for 3.10.5 (Jul 31, 2025) +=========================================== + +GitHub statistics for 2024/12/14 (tag: v3.10.0) - 2025/07/31 + +These lists are automatically generated, and may be incomplete or contain duplicates. + +We closed 18 issues and merged 67 pull requests. +The full list can be seen `on GitHub `__ + +The following 36 authors contributed 371 commits. + +* Antony Lee +* Brian Christian +* chrisjbillington +* Christine P. Chai +* Clément Robert +* David Stansby +* dependabot[bot] +* Elliott Sales de Andrade +* G.D. McBain +* Greg Lucas +* hannah +* hu-xiaonan +* Ian Thomas +* ianlv +* IdiotCoffee +* Ines Cachola +* Inês Cachola +* Jody Klymak +* Jouni K. Seppänen +* Khushi_29 +* Kyle Sunden +* Lumberbot (aka Jack) +* N R Navaneet +* Nathan G. Wiseman +* Oscar Gustafsson +* Praful Gulani +* Qian Zhang +* Raphael Erik Hviding +* Roman +* Roman A +* Ruth Comer +* saikarna913 +* Scott Shambaugh +* Thomas A Caswell +* Tim Hoffmann +* Trygve Magnus Ræder + +GitHub issues and pull requests: + +Pull Requests (67): + +* :ghpull:`30357`: CIBW updates: fix pypy sections, update cibw version +* :ghpull:`30356`: Manual Backport PR #30195 on branch v3.10.x (ci: Enable wheel builds on Python 3.14) +* :ghpull:`30352`: Backport PR #28554 on branch v3.10.x (BLD: Enable wheels on Windows-on-ARM) +* :ghpull:`30353`: Backport PR #30345 on branch v3.10.x (qt: Use better devicePixelRatio event to refresh scaling) +* :ghpull:`30350`: Backport PR #30344 on branch v3.10.x (Support fractional HiDPI in GTK4 backend) +* :ghpull:`30277`: Backport PR #30271 on branch v3.10.x (Reduce pause time in interactive timer test) +* :ghpull:`30351`: Backport PR #30327 on branch v3.10.x (FIX Update Axes limits from Axes.add_collection(... autolim=True)) +* :ghpull:`30345`: qt: Use better devicePixelRatio event to refresh scaling +* :ghpull:`28554`: BLD: Enable wheels on Windows-on-ARM +* :ghpull:`30292`: Backport PR #30237: Add explicit ``**options: Any`` for ``add_subplot`` m… +* :ghpull:`29935`: Backport PR #29908 on branch v3.10.x (TST: Use text placeholders for empty legends) +* :ghpull:`30327`: FIX Update Axes limits from Axes.add_collection(... autolim=True) +* :ghpull:`30344`: Support fractional HiDPI in GTK4 backend +* :ghpull:`30326`: Backport PR #30321 on branch v3.10.x (Fix type annotation for Axes.get_legend() to include None) +* :ghpull:`30321`: Fix type annotation for Axes.get_legend() to include None +* :ghpull:`30287`: Backport PR #30286 on branch v3.10.x (Fix whitespace in _axes.py error message) +* :ghpull:`30288`: Backport PR #30283 on branch v3.10.x (changed the FAQ link to point to the correct path) +* :ghpull:`30293`: Backport PR #30289 on branch v3.10.x (DOC: Fix build with pybind11 3) +* :ghpull:`30283`: changed the FAQ link to point to the correct path +* :ghpull:`30286`: Fix whitespace in _axes.py error message +* :ghpull:`30271`: Reduce pause time in interactive timer test +* :ghpull:`30269`: Backport PR #30186 on branch v3.10.x (Fix figure legend when drawing stackplots) +* :ghpull:`30186`: Fix figure legend when drawing stackplots +* :ghpull:`30268`: Backport PR #30233 on branch v3.10.x (Check that stem input is 1D) +* :ghpull:`30233`: Check that stem input is 1D +* :ghpull:`30259`: Backport PR #30256 on branch v3.10.x (Time out in _get_executable_info) +* :ghpull:`30256`: Time out in _get_executable_info +* :ghpull:`30237`: Add explicit ``**options: Any`` for ``add_subplot`` method +* :ghpull:`30253`: Backport PR #30243 on branch v3.10.x (Fix FancyArrow rendering for zero-length arrows) +* :ghpull:`30243`: Fix FancyArrow rendering for zero-length arrows +* :ghpull:`30250`: Backport PR #30244 on branch v3.10.x (DOC: Recommend to use bare Figure instances for saving to file) +* :ghpull:`30247`: Backport PR #30246 on branch v3.10.x (chore: remove redundant words in comment) +* :ghpull:`30246`: chore: remove redundant words in comment +* :ghpull:`30240`: Backport PR #30236 on branch v3.10.x (Copy-edit the docstring of AuxTransformBox.) +* :ghpull:`30236`: Copy-edit the docstring of AuxTransformBox. +* :ghpull:`30234`: Backport PR #30209 on branch v3.10.x (Clean up Qt socket notifier to avoid spurious interrupt handler calls) +* :ghpull:`30209`: Clean up Qt socket notifier to avoid spurious interrupt handler calls +* :ghpull:`30195`: ci: Enable wheel builds on Python 3.14 +* :ghpull:`30229`: Backport PR #30221 on branch v3.10.x (BUG: fix future incompatibility with Pillow 13) +* :ghpull:`30221`: BUG: fix future incompatibility with Pillow 13 +* :ghpull:`30228`: Backport PR #30098 on branch v3.10.x (Fix label_outer in the presence of colorbars.) +* :ghpull:`30227`: Backport PR #30223 on branch v3.10.x (Polar log scale: fix inner patch boundary and spine location) +* :ghpull:`30098`: Fix label_outer in the presence of colorbars. +* :ghpull:`30223`: Polar log scale: fix inner patch boundary and spine location +* :ghpull:`30217`: Backport PR #30198 on branch v3.10.x (Implement Path.__deepcopy__ avoiding infinite recursion) +* :ghpull:`30198`: Implement Path.__deepcopy__ avoiding infinite recursion +* :ghpull:`30213`: Backport PR #30212 on branch v3.10.x ([Doc]: fix bug in release notes for matplotlib v3.5.0 and v3.7.0) +* :ghpull:`30189`: Backport PR #30180 on branch v3.10.x (DOC: expand polar example) +* :ghpull:`30167`: Backport PR #30162 on branch v3.10.x (TST: Fix runtime error checking NaN input to format_cursor_data) +* :ghpull:`30162`: TST: Fix runtime error checking NaN input to format_cursor_data +* :ghpull:`30146`: Backport PR #30144 on branch v3.10.x (js: Fix externally-controlled format strings) +* :ghpull:`30144`: js: Fix externally-controlled format strings +* :ghpull:`30140`: Backport PR #30118 on branch v3.10.x (CI: Skip jobs on forks) +* :ghpull:`30120`: Backport PR #30114 on branch v3.10.x (Fix _is_tensorflow_array.) +* :ghpull:`30122`: Backport PR #30119 on branch v3.10.x (Add some types to _mathtext.py) +* :ghpull:`30119`: Add some types to _mathtext.py +* :ghpull:`30114`: Fix _is_tensorflow_array. +* :ghpull:`30106`: Backport PR #30089 on branch v3.10.x (FIX: fix submerged margins algorithm being applied twice) +* :ghpull:`30089`: FIX: fix submerged margins algorithm being applied twice +* :ghpull:`30101`: Backport PR #30096 on branch v3.10.x (Fix OffsetBox custom picker) +* :ghpull:`30096`: Fix OffsetBox custom picker +* :ghpull:`30081`: Backport PR #30079 on branch v3.10.x (FIX: cast legend handles to list) +* :ghpull:`30079`: FIX: cast legend handles to list +* :ghpull:`30057`: Backport PR #29895 on branch v3.10.x (The 'lines.markeredgecolor' now doesn't interfere on the color of errorbar caps)" +* :ghpull:`29895`: The 'lines.markeredgecolor' now doesn't interfere on the color of errorbar caps +* :ghpull:`30033`: Backport PR #30029 on branch v3.10.x (Update diagram in subplots_adjust documentation to clarify parameters) + +Issues (18): + +* :ghissue:`30370`: [Bug]: matplotlib simple example fails in Python 3.14 +* :ghissue:`30218`: [Bug]: Rendering on Wayland with fractional scaling looks bad +* :ghissue:`30318`: [Bug]: type annotation of ``Axes.get_legend()`` misses ``None`` +* :ghissue:`30169`: [Doc]: Incorrect FAQ Link on Tutorials Page +* :ghissue:`30285`: [Bug]: Missing whitespace in _axes.py error message +* :ghissue:`30280`: [Bug]: Pillow 11.3 raises a deprecation warning when using TkAgg +* :ghissue:`30158`: [Bug]: Stackplot in SubFigure raises when drawing Legend +* :ghissue:`30216`: [Bug]: stem complaining about PyTorch's Tensor +* :ghissue:`30242`: [Bug]: Cannot create empty FancyArrow (expired numpy deprecation) +* :ghissue:`30249`: [Bug]: DeprecationWarning from Pillow 11.3.0 about 'mode' parameter of PIL.Image.fromarray() +* :ghissue:`29688`: [Bug]: "Bad file descriptor" raised repeatedly when plt.pause() interrupted in IPython +* :ghissue:`27305`: [Bug]: Axes.label_outer() does not work when there is a colorbar +* :ghissue:`30179`: [Bug]: Inner border is not rendered correctly when using log-scale and polar projection. +* :ghissue:`29157`: FUTURE BUG: reconsider how we deep-copy path objects +* :ghissue:`30152`: [Bug]: Test pipeline failure on windows +* :ghissue:`30076`: [Bug]: Layout Managers are confused by complex arrangement of sub-figures and gridspec's +* :ghissue:`30078`: [Bug]: legend no longer works with itertools.chain +* :ghissue:`29780`: [Bug]: Setting 'lines.markeredgecolor' affects color of errorbar caps. diff --git a/doc/release/prev_whats_new/github_stats_3.10.6.rst b/doc/release/prev_whats_new/github_stats_3.10.6.rst new file mode 100644 index 000000000000..fb88af0ae10f --- /dev/null +++ b/doc/release/prev_whats_new/github_stats_3.10.6.rst @@ -0,0 +1,76 @@ +.. _github-stats-3_10_6: + +GitHub statistics for 3.10.6 (Aug 29, 2025) +=========================================== + +GitHub statistics for 2024/12/14 (tag: v3.10.0) - 2025/08/29 + +These lists are automatically generated, and may be incomplete or contain duplicates. + +We closed 4 issues and merged 19 pull requests. +The full list can be seen `on GitHub `__ + +The following 31 authors contributed 380 commits. + +* Alan Burlot +* Antony Lee +* Christine P. Chai +* David Stansby +* dependabot[bot] +* Doron Behar +* Elliott Sales de Andrade +* G.D. McBain +* Greg Lucas +* hannah +* hu-xiaonan +* Ian Thomas +* Inês Cachola +* Jody Klymak +* Jouni K. Seppänen +* Khushi_29 +* Kyle Sunden +* Lumberbot (aka Jack) +* N R Navaneet +* Nathan G. Wiseman +* Oscar Gustafsson +* Praful Gulani +* Qian Zhang +* Raphael Erik Hviding +* Roman +* Ruth Comer +* saikarna913 +* Scott Shambaugh +* Thomas A Caswell +* Tim Hoffmann +* Trygve Magnus Ræder + +GitHub issues and pull requests: + +Pull Requests (19): + +* :ghpull:`30487`: Backport PR #30484 on branch v3.10.x (FIX: be more cautious about checking widget size) +* :ghpull:`30484`: FIX: be more cautious about checking widget size +* :ghpull:`30481`: Backport PR #30394 on branch v3.10.x (ENH: Gracefully handle python-build-standalone ImportError with Tk) +* :ghpull:`30477`: Backport PR #30476 on branch v3.10.x (ci: Remove cibuildwheel override for win_arm64/Py3.14) +* :ghpull:`30394`: ENH: Gracefully handle python-build-standalone ImportError with Tk +* :ghpull:`30476`: ci: Remove cibuildwheel override for win_arm64/Py3.14 +* :ghpull:`30461`: Backport PR #30451 on branch v3.10.x (doc: factor out quick install tab for reuse) +* :ghpull:`30448`: Backport PR #30412 on branch v3.10.x ({Check,Radio}Buttons: Improve docs of label_props) +* :ghpull:`30412`: {Check,Radio}Buttons: Improve docs of label_props +* :ghpull:`30445`: Backport PR #30444 on branch v3.10.x (Small correction of a typo in the galleries: axis instead of axes) +* :ghpull:`30444`: Small correction of a typo in the galleries: axis instead of axes +* :ghpull:`30430`: Backport PR #30426 on branch v3.10.x (Fix a race condition in TexManager.make_dvi.) +* :ghpull:`30434`: Backport PR #30426: Fix a race condition in TexManager.make_dvi & make_png. +* :ghpull:`30431`: Use pathlib in texmanager. +* :ghpull:`30428`: Backport PR #30399 on branch v3.10.x (Qt: Fix HiDPI handling on X11/Windows) +* :ghpull:`30426`: Fix a race condition in TexManager.make_dvi. +* :ghpull:`30399`: Qt: Fix HiDPI handling on X11/Windows +* :ghpull:`30415`: Backport PR #30414 on branch v3.10.x (DOC: update Cartopy url) +* :ghpull:`30414`: DOC: update Cartopy url + +Issues (4): + +* :ghissue:`29618`: [Bug]: FigureCanvasQT is seemingly prematurely freed under certain conditions +* :ghissue:`30390`: [ENH]: Gracefully handle python-build-standalone ImportError +* :ghissue:`30420`: [ENH]: Support parallel plotting +* :ghissue:`30386`: BUG: Qt hi-dpi regression on windows and X11 with mpl 3.10.5 diff --git a/doc/release/prev_whats_new/whats_new_3.10.0.rst b/doc/release/prev_whats_new/whats_new_3.10.0.rst index 82e67368805d..232ab6036100 100644 --- a/doc/release/prev_whats_new/whats_new_3.10.0.rst +++ b/doc/release/prev_whats_new/whats_new_3.10.0.rst @@ -62,13 +62,6 @@ colour maps version 8.0.1 (DOI: https://doi.org/10.5281/zenodo.1243862). ax[2].imshow(img, cmap="vanimo") - -Plotting and Annotation improvements -==================================== - - - - Plotting and Annotation improvements ==================================== diff --git a/doc/release/release_notes.rst b/doc/release/release_notes.rst index a31bcbe9d811..b8bd756ce69b 100644 --- a/doc/release/release_notes.rst +++ b/doc/release/release_notes.rst @@ -19,9 +19,12 @@ Version 3.10 :maxdepth: 1 prev_whats_new/whats_new_3.10.0.rst + ../api/prev_api_changes/api_changes_3.10.7.rst ../api/prev_api_changes/api_changes_3.10.1.rst ../api/prev_api_changes/api_changes_3.10.0.rst github_stats.rst + prev_whats_new/github_stats_3.10.6.rst + prev_whats_new/github_stats_3.10.5.rst prev_whats_new/github_stats_3.10.3.rst prev_whats_new/github_stats_3.10.1.rst prev_whats_new/github_stats_3.10.0.rst diff --git a/doc/users/faq.rst b/doc/users/faq.rst index d13625ec9907..5aec1e08fb14 100644 --- a/doc/users/faq.rst +++ b/doc/users/faq.rst @@ -77,8 +77,8 @@ empty if it was rendered pure white (there may be artists present, but they could be outside the drawing area or transparent)? For the purpose here, we define empty as: "The figure does not contain any -artists except it's background patch." The exception for the background is -necessary, because by default every figure contains a `.Rectangle` as it's +artists except its background patch." The exception for the background is +necessary, because by default every figure contains a `.Rectangle` as its background patch. This definition could be checked via:: def is_empty(figure): @@ -91,8 +91,8 @@ background patch. This definition could be checked via:: We've decided not to include this as a figure method because this is only one way of defining empty, and checking the above is only rarely necessary. -Usually the user or program handling the figure know if they have added -something to the figure. +Whether or not something has been added to the figure is usually defined +within the context of the program. The only reliable way to check whether a figure would render empty is to actually perform such a rendering and inspect the result. @@ -281,7 +281,7 @@ locators as desired because the two axes are independent. Generate images without having a window appear ---------------------------------------------- -The recommended approach since matplotlib 3.1 is to explicitly create a Figure +The recommended approach since Matplotlib 3.1 is to explicitly create a Figure instance:: from matplotlib.figure import Figure @@ -292,12 +292,10 @@ instance:: This prevents any interaction with GUI frameworks and the window manager. -It's alternatively still possible to use the pyplot interface. Instead of -calling `matplotlib.pyplot.show`, call `matplotlib.pyplot.savefig`. - -Additionally, you must ensure to close the figure after saving it. Not -closing the figure is a memory leak, because pyplot keeps references -to all not-yet-shown figures:: +It's alternatively still possible to use the pyplot interface: instead of +calling `matplotlib.pyplot.show`, call `matplotlib.pyplot.savefig`. In that +case, you must close the figure after saving it. Not closing the figure causes +a memory leak, because pyplot keeps references to all not-yet-shown figures. :: import matplotlib.pyplot as plt plt.plot([1, 2, 3]) diff --git a/doc/users/glossary.rst b/doc/users/glossary.rst new file mode 100644 index 000000000000..8a2a3fd96bd1 --- /dev/null +++ b/doc/users/glossary.rst @@ -0,0 +1,44 @@ +======== +Glossary +======== + +.. Note for glossary authors: + The glossary is primarily intended for Matplotlib's own concepts and + terminology, e.g. figure, artist, backend, etc. We don't want to list + general terms like "GUI framework", "event loop" or similar. + The glossary should contain a short definition of the term, aiming at + a high-level understanding. Use links to redirect to more comprehensive + explanations and API reference when possible. + +This glossary defines concepts and terminology specific to Matplotlib. + +.. glossary:: + + Figure + The outermost container for a Matplotlib graphic. Think of this as the + canvas to draw on. + + This is implemented in the class `.Figure`. For more details see + :ref:`figure-intro`. + + Axes + This is a container for what is often colloquially called a plot/chart/graph. + It's a data area with :term:`Axis`\ es, i.e. coordinate directions, + and includes data artists like lines, bars etc. as well as + decorations like title, axis labels, legend. + + Since most "plotting operations" are realized as methods on `~.axes.Axes` + this is the object users will mostly interact with. + + Note: The term *Axes* was taken over from MATLAB. Think of this as + a container spanned by the *x*- and *y*-axis, including decoration + and data. + + Axis + A direction with a scale. The scale defines the mapping from + data coordinates to screen coordinates. The Axis also includes + the ticks and axis label. + + Artist + The base class for all graphical element that can be drawn. + Examples are Lines, Rectangles, Text, Ticks, Legend, Axes, ... diff --git a/doc/users/index.rst b/doc/users/index.rst index 2991e7d2b324..733f176e556c 100644 --- a/doc/users/index.rst +++ b/doc/users/index.rst @@ -103,3 +103,4 @@ Using Matplotlib getting_started/index ../install/index + glossary diff --git a/environment.yml b/environment.yml index eaa6ed6386b6..418062d0d237 100644 --- a/environment.yml +++ b/environment.yml @@ -37,7 +37,7 @@ dependencies: - ipywidgets - numpydoc>=1.0 - packaging>=20 - - pydata-sphinx-theme~=0.15.0 + - pydata-sphinx-theme=0.16.1 # required by mpl-sphinx-theme=3.10 - pyyaml - sphinx>=3.0.0,!=6.1.2 - sphinx-copybutton @@ -46,14 +46,14 @@ dependencies: - sphinx-design - sphinx-tags>=0.4.0 - pystemmer + - pikepdf - pip - pip: - - mpl-sphinx-theme~=3.8.0 + - mpl-sphinx-theme~=3.10.0 - sphinxcontrib-svg2pdfconverter>=1.1.0 - sphinxcontrib-video>=0.2.1 - - pikepdf # testing - - black<24 + - black<26 - coverage - gtk4 - ipykernel diff --git a/extern/agg24-svn/include/ctrl/agg_gamma_spline.h b/extern/agg24-svn/include/ctrl/agg_gamma_spline.h index 4f21710d9f29..052f972e85c1 100644 --- a/extern/agg24-svn/include/ctrl/agg_gamma_spline.h +++ b/extern/agg24-svn/include/ctrl/agg_gamma_spline.h @@ -56,7 +56,7 @@ namespace agg // bounding rectangle. Function values() calculates the curve by these // 4 values. After calling it one can get the gamma-array with call gamma(). // Class also supports the vertex source interface, i.e rewind() and - // vertex(). It's made for convinience and used in class gamma_ctrl. + // vertex(). It's made for convenience and used in class gamma_ctrl. // Before calling rewind/vertex one must set the bounding box // box() using pixel coordinates. //------------------------------------------------------------------------ diff --git a/galleries/examples/axes_grid1/demo_colorbar_with_inset_locator.py b/galleries/examples/axes_grid1/demo_colorbar_with_inset_locator.py index f62a0f58e3bc..352c8527910e 100644 --- a/galleries/examples/axes_grid1/demo_colorbar_with_inset_locator.py +++ b/galleries/examples/axes_grid1/demo_colorbar_with_inset_locator.py @@ -1,17 +1,17 @@ """ .. _demo-colorbar-with-inset-locator: -=========================================================== -Control the position and size of a colorbar with Inset Axes -=========================================================== +========================================================================= +Control the position and size of a colorbar with inset_locator.inset_axes +========================================================================= This example shows how to control the position, height, and width of colorbars -using `~mpl_toolkits.axes_grid1.inset_locator.inset_axes`. +using `.inset_locator.inset_axes`. -Inset Axes placement is controlled as for legends: either by providing a *loc* -option ("upper right", "best", ...), or by providing a locator with respect to -the parent bbox. Parameters such as *bbox_to_anchor* and *borderpad* likewise -work in the same way, and are also demonstrated here. +`.inset_locator.inset_axes` placement is controlled as for legends: either +by providing a *loc* option ("upper right", "best", ...), or by providing a +locator with respect to the parent bbox. Parameters such as *bbox_to_anchor* +and *borderpad* likewise work in the same way, and are also demonstrated here. Users should consider using `.Axes.inset_axes` instead (see :ref:`colorbar_placement`). @@ -21,12 +21,12 @@ import matplotlib.pyplot as plt -from mpl_toolkits.axes_grid1.inset_locator import inset_axes +from mpl_toolkits.axes_grid1 import inset_locator fig, (ax1, ax2) = plt.subplots(1, 2, figsize=[6, 3]) im1 = ax1.imshow([[1, 2], [2, 3]]) -axins1 = inset_axes( +axins1 = inset_locator.inset_axes( ax1, width="50%", # width: 50% of parent_bbox width height="5%", # height: 5% @@ -36,7 +36,7 @@ fig.colorbar(im1, cax=axins1, orientation="horizontal", ticks=[1, 2, 3]) im = ax2.imshow([[1, 2], [2, 3]]) -axins = inset_axes( +axins = inset_locator.inset_axes( ax2, width="5%", # width: 5% of parent_bbox width height="50%", # height: 50% diff --git a/galleries/examples/color/colormap_reference.py b/galleries/examples/color/colormap_reference.py index 6f550161f2e9..2dc091d71a45 100644 --- a/galleries/examples/color/colormap_reference.py +++ b/galleries/examples/color/colormap_reference.py @@ -24,9 +24,8 @@ 'YlOrBr', 'YlOrRd', 'OrRd', 'PuRd', 'RdPu', 'BuPu', 'GnBu', 'PuBu', 'YlGnBu', 'PuBuGn', 'BuGn', 'YlGn']), ('Sequential (2)', [ - 'binary', 'gist_yarg', 'gist_gray', 'gray', 'bone', 'pink', - 'spring', 'summer', 'autumn', 'winter', 'cool', 'Wistia', - 'hot', 'afmhot', 'gist_heat', 'copper']), + 'gray', 'bone', 'pink', 'spring', 'summer', 'autumn', 'winter', + 'cool', 'Wistia', 'hot', 'afmhot', 'gist_heat', 'copper']), ('Diverging', [ 'PiYG', 'PRGn', 'BrBG', 'PuOr', 'RdGy', 'RdBu', 'RdYlBu', 'RdYlGn', 'Spectral', 'coolwarm', 'bwr', 'seismic', @@ -70,6 +69,22 @@ def plot_color_gradients(cmap_category, cmap_list): # %% +# +# .. admonition:: Discouraged +# +# For backward compatibility we additionally support the following colormap +# names, which are identical to other builtin colormaps. Their use is +# discouraged. Use the suggested replacement instead. +# +# ========= ================================= +# Colormap Use identical replacement instead +# ========= ================================= +# gist_gray gray +# gist_yarg gray_r +# binary gray_r +# ========= ================================= +# +# # .. _reverse-cmap: # # Reversed colormaps diff --git a/galleries/examples/misc/demo_agg_filter.py b/galleries/examples/misc/demo_agg_filter.py index 278fd998dd78..c736013e9718 100644 --- a/galleries/examples/misc/demo_agg_filter.py +++ b/galleries/examples/misc/demo_agg_filter.py @@ -269,19 +269,19 @@ def drop_shadow_patches(ax): def light_filter_pie(ax): fracs = [15, 30, 45, 10] explode = (0.1, 0.2, 0.1, 0.1) - pies = ax.pie(fracs, explode=explode) + pie = ax.pie(fracs, explode=explode) light_filter = LightFilter(9) - for p in pies[0]: + for p in pie.wedges: p.set_agg_filter(light_filter) p.set_rasterized(True) # to support mixed-mode renderers p.set(ec="none", lw=2) gauss = DropShadowFilter(9, offsets=(3, -4), alpha=0.7) - shadow = FilteredArtistList(pies[0], gauss) + shadow = FilteredArtistList(pie.wedges, gauss) ax.add_artist(shadow) - shadow.set_zorder(pies[0][0].get_zorder() - 0.1) + shadow.set_zorder(pie.wedges[0].get_zorder() - 0.1) if __name__ == "__main__": diff --git a/galleries/examples/misc/svg_filter_pie.py b/galleries/examples/misc/svg_filter_pie.py index b19867be9a2f..f8ccc5bcb22b 100644 --- a/galleries/examples/misc/svg_filter_pie.py +++ b/galleries/examples/misc/svg_filter_pie.py @@ -28,16 +28,16 @@ # 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. -pies = ax.pie(fracs, explode=explode, labels=labels, autopct='%1.1f%%') +pie = ax.pie(fracs, explode=explode, labels=labels, autopct='%1.1f%%') -for w in pies[0]: +for w in pie.wedges: # set the id with the label. w.set_gid(w.get_label()) # we don't want to draw the edge of the pie w.set_edgecolor("none") -for w in pies[0]: +for w in pie.wedges: # create shadow patch s = Shadow(w, -0.01, -0.01) s.set_gid(w.get_gid() + "_shadow") 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 6f18b964cef7..7c703976db2e 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,8 @@ explode = [0.1, 0, 0] # rotate so that first wedge is split by the x-axis angle = -180 * overall_ratios[0] -wedges, *_ = ax1.pie(overall_ratios, autopct='%1.1f%%', startangle=angle, - labels=labels, explode=explode) +pie = ax1.pie(overall_ratios, autopct='%1.1f%%', startangle=angle, + labels=labels, explode=explode) # bar chart parameters age_ratios = [.33, .54, .07, .06] @@ -47,8 +47,8 @@ ax2.set_xlim(- 2.5 * width, 2.5 * width) # use ConnectionPatch to draw lines between the two plots -theta1, theta2 = wedges[0].theta1, wedges[0].theta2 -center, r = wedges[0].center, wedges[0].r +theta1, theta2 = pie.wedges[0].theta1, pie.wedges[0].theta2 +center, r = pie.wedges[0].center, pie.wedges[0].r bar_height = sum(age_ratios) # draw top connecting line diff --git a/galleries/examples/pie_and_polar_charts/pie_and_donut_labels.py b/galleries/examples/pie_and_polar_charts/pie_and_donut_labels.py index 13e3019bc7ba..78e884128d1e 100644 --- a/galleries/examples/pie_and_polar_charts/pie_and_donut_labels.py +++ b/galleries/examples/pie_and_polar_charts/pie_and_donut_labels.py @@ -6,7 +6,8 @@ Welcome to the Matplotlib bakery. We will create a pie and a donut chart through the `pie method ` and show how to label them with a `legend ` -as well as with `annotations `. +as well as with the `pie_label method ` and +`annotations `. """ # %% @@ -15,12 +16,14 @@ # Now it's time for the pie. Starting with a pie recipe, we create the data # and a list of labels from it. # -# We can provide a function to the ``autopct`` argument, which will expand -# automatic percentage labeling by showing absolute values; we calculate -# the latter back from relative data and the known sum of all values. +# We then create the pie and store the returned `~matplotlib.container.PieContainer` +# object for later. # -# We then create the pie and store the returned objects for later. The first -# returned element of the returned tuple is a list of the wedges. Those are +# We can provide the `~matplotlib.container.PieContainer` and a format string to +# the `~matplotlib.axes.Axes.pie_label` method to automatically label each +# ingredient's wedge with its weight in grams and percentages. +# +# The `~.PieContainer` has a list of patches as one of its attributes. Those are # `matplotlib.patches.Wedge` patches, which can directly be used as the handles # for a legend. We can use the legend's ``bbox_to_anchor`` argument to position # the legend outside of the pie. Here we use the axes coordinates ``(1, 0, 0.5, @@ -31,32 +34,26 @@ import matplotlib.pyplot as plt import numpy as np -fig, ax = plt.subplots(figsize=(6, 3), subplot_kw=dict(aspect="equal")) +fig, ax = plt.subplots(figsize=(6, 3)) recipe = ["375 g flour", "75 g sugar", "250 g butter", "300 g berries"] -data = [float(x.split()[0]) for x in recipe] +data = [int(x.split()[0]) for x in recipe] ingredients = [x.split()[-1] for x in recipe] +pie = ax.pie(data) -def func(pct, allvals): - absolute = int(np.round(pct/100.*np.sum(allvals))) - return f"{pct:.1f}%\n({absolute:d} g)" - +ax.pie_label(pie, '{frac:.1%}\n({absval:d}g)', + textprops=dict(color="w", size=8, weight="bold")) -wedges, texts, autotexts = ax.pie(data, autopct=lambda pct: func(pct, data), - textprops=dict(color="w")) - -ax.legend(wedges, ingredients, +ax.legend(pie.wedges, ingredients, title="Ingredients", loc="center left", bbox_to_anchor=(1, 0, 0.5, 1)) -plt.setp(autotexts, size=8, weight="bold") - ax.set_title("Matplotlib bakery: A pie") plt.show() @@ -97,13 +94,13 @@ def func(pct, allvals): data = [225, 90, 50, 60, 100, 5] -wedges, texts = ax.pie(data, wedgeprops=dict(width=0.5), startangle=-40) +pie = ax.pie(data, wedgeprops=dict(width=0.5), startangle=-40) bbox_props = dict(boxstyle="square,pad=0.3", fc="w", ec="k", lw=0.72) kw = dict(arrowprops=dict(arrowstyle="-"), bbox=bbox_props, zorder=0, va="center") -for i, p in enumerate(wedges): +for i, p in enumerate(pie.wedges): ang = (p.theta2 - p.theta1)/2. + p.theta1 y = np.sin(np.deg2rad(ang)) x = np.cos(np.deg2rad(ang)) @@ -131,6 +128,7 @@ def func(pct, allvals): # in this example: # # - `matplotlib.axes.Axes.pie` / `matplotlib.pyplot.pie` +# - `matplotlib.axes.Axes.pie_label` / `matplotlib.pyplot.pie_label` # - `matplotlib.axes.Axes.legend` / `matplotlib.pyplot.legend` # # .. tags:: diff --git a/galleries/examples/pie_and_polar_charts/pie_label.py b/galleries/examples/pie_and_polar_charts/pie_label.py new file mode 100644 index 000000000000..d7f690bd6f85 --- /dev/null +++ b/galleries/examples/pie_and_polar_charts/pie_label.py @@ -0,0 +1,100 @@ +""" +=================== +Labeling pie charts +=================== + +This example illustrates some features of the `~matplotlib.axes.Axes.pie_label` +method, which adds labels to an existing pie chart created with +`~matplotlib.axes.Axes.pie`. +""" + +# %% +# The simplest option is to provide a list of strings to label each slice of the pie. + +import matplotlib.pyplot as plt + +data = [36, 24, 8, 12] +labels = ['spam', 'eggs', 'bacon', 'sausage'] + +fig, ax = plt.subplots() +pie = ax.pie(data) +ax.pie_label(pie, labels) + +# %% +# +# If you want the labels outside the pie, set a *distance* greater than 1. +# This is the distance from the center of the pie as a fraction of its radius. + +fig, ax = plt.subplots() +pie = ax.pie(data) +ax.pie_label(pie, labels, distance=1.1) + +# %% +# +# You can also rotate the labels so they are oriented away from the pie center. + +fig, ax = plt.subplots() +pie = ax.pie(data) +ax.pie_label(pie, labels, rotate=True) + +# %% +# +# Instead of explicit labels, pass a format string to label slices with their values... + +fig, ax = plt.subplots() +pie = ax.pie(data) +ax.pie_label(pie, '{absval:.1f}') + +# %% +# +# ...or with their percentages... + +fig, ax = plt.subplots() +pie = ax.pie(data) +ax.pie_label(pie, '{frac:.1%}') + +# %% +# +# ...or both. + +fig, ax = plt.subplots() +pie = ax.pie(data) +ax.pie_label(pie, '{absval:d}\n{frac:.1%}') + +# %% +# +# Font styling can be configured by passing a dictionary to the *textprops* parameter. + +fig, ax = plt.subplots() +pie = ax.pie(data) +ax.pie_label(pie, labels, textprops={'fontsize': 'large', 'color': 'white'}) + +# %% +# +# `~matplotlib.axes.Axes.pie_label` can be called repeatedly to add multiple sets +# of labels. + +# sphinx_gallery_thumbnail_number = -1 + +fig, ax = plt.subplots() +pie = ax.pie(data) + +ax.pie_label(pie, labels, distance=1.1) +ax.pie_label(pie, '{frac:.1%}', distance=0.7) +ax.pie_label(pie, '{absval:d}', distance=0.4) + +plt.show() + +# %% +# .. admonition:: References +# +# The use of the following functions, methods, classes and modules is shown +# in this example: +# +# - `matplotlib.axes.Axes.pie` / `matplotlib.pyplot.pie` +# - `matplotlib.axes.Axes.pie_label` / `matplotlib.pyplot.pie_label` +# +# .. tags:: +# +# plot-type: pie +# level: beginner diff --git a/galleries/examples/shapes_and_collections/donut.py b/galleries/examples/shapes_and_collections/donut.py index 8764101beb3e..3aa5b0b1a75c 100644 --- a/galleries/examples/shapes_and_collections/donut.py +++ b/galleries/examples/shapes_and_collections/donut.py @@ -15,27 +15,21 @@ def wise(v): - if v == 1: - return "CCW" - else: - return "CW" + return {+1: "CCW", -1: "CW"}[v] def make_circle(r): t = np.arange(0, np.pi * 2.0, 0.01) - t = t.reshape((len(t), 1)) x = r * np.cos(t) y = r * np.sin(t) - return np.hstack((x, y)) + return np.column_stack((x, y)) -Path = mpath.Path fig, ax = plt.subplots() inside_vertices = make_circle(0.5) outside_vertices = make_circle(1.0) -codes = np.ones( - len(inside_vertices), dtype=mpath.Path.code_type) * mpath.Path.LINETO +codes = np.full(len(inside_vertices), mpath.Path.LINETO) codes[0] = mpath.Path.MOVETO for i, (inside, outside) in enumerate(((1, 1), (1, -1), (-1, 1), (-1, -1))): @@ -44,23 +38,20 @@ def make_circle(r): vertices = np.concatenate((outside_vertices[::outside], inside_vertices[::inside])) # Shift the path - vertices[:, 0] += i * 2.5 + vertices += (i * 2.5, 0) # The codes will be all "LINETO" commands, except for "MOVETO"s at the # beginning of each subpath all_codes = np.concatenate((codes, codes)) # Create the Path object path = mpath.Path(vertices, all_codes) - # Add plot it + # And plot it patch = mpatches.PathPatch(path, facecolor='#885500', edgecolor='black') ax.add_patch(patch) ax.annotate(f"Outside {wise(outside)},\nInside {wise(inside)}", (i * 2.5, -1.5), va="top", ha="center") -ax.set_xlim(-2, 10) -ax.set_ylim(-3, 2) -ax.set_title('Mmm, donuts!') -ax.set_aspect(1.0) +ax.set(xlim=(-2, 10), ylim=(-3, 2), aspect=1, title="Mmm, donuts!") plt.show() # %% diff --git a/galleries/examples/ticks/align_ticklabels.py b/galleries/examples/ticks/align_ticklabels.py new file mode 100644 index 000000000000..ec36e0db4d07 --- /dev/null +++ b/galleries/examples/ticks/align_ticklabels.py @@ -0,0 +1,32 @@ +""" +================= +Align tick labels +================= + +By default, tick labels are aligned towards the axis. This means the set of +*y* tick labels appear right-aligned. Because the alignment reference point +is on the axis, left-aligned tick labels would overlap the plotting area. +To achieve a good-looking left-alignment, you have to additionally increase +the padding. +""" +import matplotlib.pyplot as plt + +population = { + "Sydney": 5.2, + "Mexico City": 8.8, + "São Paulo": 12.2, + "Istanbul": 15.9, + "Lagos": 15.9, + "Shanghai": 21.9, +} + +fig, ax = plt.subplots(layout="constrained") +ax.barh(population.keys(), population.values()) +ax.set_xlabel('Population (in millions)') + +# left-align all ticklabels +for ticklabel in ax.get_yticklabels(): + ticklabel.set_horizontalalignment("left") + +# increase padding +ax.tick_params("y", pad=70) diff --git a/galleries/examples/user_interfaces/canvasagg.py b/galleries/examples/user_interfaces/canvasagg.py index 0e460cc64539..2786a2518dd3 100644 --- a/galleries/examples/user_interfaces/canvasagg.py +++ b/galleries/examples/user_interfaces/canvasagg.py @@ -32,10 +32,6 @@ from matplotlib.figure import Figure fig = Figure(figsize=(5, 4), dpi=100) -# A canvas must be manually attached to the figure (pyplot would automatically -# do it). This is done by instantiating the canvas with the figure as -# argument. -canvas = FigureCanvasAgg(fig) # Do some plotting. ax = fig.add_subplot() @@ -45,8 +41,12 @@ # etc.). fig.savefig("test.png") -# Option 2: Retrieve a memoryview on the renderer buffer, and convert it to a +# Option 2 (low-level approach to directly save to a numpy array): Manually +# attach a canvas to the figure (pyplot or savefig would automatically do +# it), by instantiating the canvas with the figure as argument; then draw the +# figure, retrieve a memoryview on the renderer buffer, and convert it to a # numpy array. +canvas = FigureCanvasAgg(fig) canvas.draw() rgba = np.asarray(canvas.buffer_rgba()) # ... and pass it to PIL. diff --git a/galleries/examples/user_interfaces/web_application_server_sgskip.py b/galleries/examples/user_interfaces/web_application_server_sgskip.py index 60c321e02eb9..f125916db54b 100644 --- a/galleries/examples/user_interfaces/web_application_server_sgskip.py +++ b/galleries/examples/user_interfaces/web_application_server_sgskip.py @@ -5,7 +5,7 @@ When using Matplotlib in a web server it is strongly recommended to not use pyplot (pyplot maintains references to the opened figures to make -`~.matplotlib.pyplot.show` work, but this will cause memory leaks unless the +`~.pyplot.show` work, but this will cause memory leaks unless the figures are properly closed). Since Matplotlib 3.1, one can directly create figures using the `.Figure` @@ -45,21 +45,14 @@ def hello(): # %% # # Since the above code is a Flask application, it should be run using the -# `flask command-line tool `_ -# Assuming that the working directory contains this script: -# -# Unix-like systems +# `flask command-line tool `_: +# run # # .. code-block:: console # -# FLASK_APP=web_application_server_sgskip flask run -# -# Windows -# -# .. code-block:: console +# flask --app web_application_server_sgskip run # -# set FLASK_APP=web_application_server_sgskip -# flask run +# from the directory containing this script. # # # Clickable images for HTML diff --git a/galleries/tutorials/images.py b/galleries/tutorials/images.py index a7c474dab40b..6c4e68c32416 100644 --- a/galleries/tutorials/images.py +++ b/galleries/tutorials/images.py @@ -7,40 +7,6 @@ Image tutorial ============== -A short tutorial on plotting images with Matplotlib. - -.. _imaging_startup: - -Startup commands -=================== - -First, let's start IPython. It is a most excellent enhancement to the -standard Python prompt, and it ties in especially well with -Matplotlib. Start IPython either directly at a shell, or with the Jupyter -Notebook (where IPython as a running kernel). - -With IPython started, we now need to connect to a GUI event loop. This -tells IPython where (and how) to display plots. To connect to a GUI -loop, execute the **%matplotlib** magic at your IPython prompt. There's more -detail on exactly what this does at `IPython's documentation on GUI -event loops -`_. - -If you're using Jupyter Notebook, the same commands are available, but -people commonly use a specific argument to the %matplotlib magic: - -.. sourcecode:: ipython - - In [1]: %matplotlib inline - -This turns on inline plotting, where plot graphics will appear in your notebook. This -has important implications for interactivity. For inline plotting, commands in -cells below the cell that outputs a plot will not affect the plot. For example, -changing the colormap is not possible from cells below the cell that creates a plot. -However, for other backends, such as Qt, that open a separate window, -cells below those that create the plot will change the plot - it is a -live object in memory. - This tutorial will use Matplotlib's implicit plotting interface, pyplot. This interface maintains global state, and is very useful for quickly and easily experimenting with various plot settings. The alternative is the explicit, @@ -147,15 +113,6 @@ # %% # -# .. note:: -# -# However, remember that in the Jupyter Notebook with the inline backend, -# you can't make changes to plots that have already been rendered. If you -# create imgplot here in one cell, you cannot call set_cmap() on it in a later -# cell and expect the earlier plot to change. Make sure that you enter these -# commands together in one cell. plt commands will not change plots from earlier -# cells. -# # There are many other colormap schemes available. See the :ref:`list and images # of the colormaps`. # @@ -201,9 +158,7 @@ # %% # This can also be done by calling the # :meth:`~matplotlib.cm.ScalarMappable.set_clim` method of the returned image -# plot object, but make sure that you do so in the same cell as your plot -# command when working with the Jupyter Notebook - it will not change -# plots from earlier cells. +# plot object. imgplot = plt.imshow(lum_img) imgplot.set_clim(0, 175) diff --git a/galleries/users_explain/colors/colormapnorms.py b/galleries/users_explain/colors/colormapnorms.py index af50cef357b3..66bff534b3b0 100644 --- a/galleries/users_explain/colors/colormapnorms.py +++ b/galleries/users_explain/colors/colormapnorms.py @@ -281,6 +281,32 @@ cb.set_ticks([-500, 0, 1000, 2000, 3000, 4000]) plt.show() +# %% +# Using a linear scale on the colormap +# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +# +# By default, colorbars adopt the same axis scaling as their associated norm. +# For example, for a `.TwoSlopeNorm`, colormap segments are distributed +# linearly and the colorbar ticks positions are spaced non-linearly (as above, +# and the left-hand colorbar below). To make the tick spacing linear instead, +# you can change the scale by calling ``cb.ax.set_yscale('linear')``, as shown +# in the right-hand colorbar below. The ticks will then be evenly spaced, the +# colormap will appear compressed in the smaller of the two slope regions. + +fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 4)) +divnorm = colors.TwoSlopeNorm(vmin=-500., vcenter=0, vmax=4000) + +for ax, title in zip([ax1, ax2], + ['Default: Scaled colorbar', 'Linear colorbar spacing']): + pcm = ax.pcolormesh(longitude, latitude, topo, rasterized=True, norm=divnorm, + cmap=terrain_map, shading='auto') + ax.set_aspect(1 / np.cos(np.deg2rad(49))) + ax.set_title(title) + cb = fig.colorbar(pcm, ax=ax, shrink=0.6) + cb.set_ticks(np.arange(-500, 4001, 500)) + +# Set linear scale for the right colorbar +cb.ax.set_yscale('linear') # %% # FuncNorm: Arbitrary function normalization @@ -290,6 +316,7 @@ # `~.colors.FuncNorm` to define your own. Note that this example is the same # as `~.colors.PowerNorm` with a power of 0.5: + def _forward(x): return np.sqrt(x) diff --git a/galleries/users_explain/colors/colormaps.py b/galleries/users_explain/colors/colormaps.py index 026ffc9922e2..55989c258802 100644 --- a/galleries/users_explain/colors/colormaps.py +++ b/galleries/users_explain/colors/colormaps.py @@ -161,11 +161,26 @@ def plot_color_gradients(category, cmap_list): # an excellent example of this). plot_color_gradients('Sequential (2)', - ['binary', 'gist_yarg', 'gist_gray', 'gray', 'bone', - 'pink', 'spring', 'summer', 'autumn', 'winter', 'cool', - 'Wistia', 'hot', 'afmhot', 'gist_heat', 'copper']) + ['gray', 'bone', 'pink', 'spring', 'summer', 'autumn', + 'winter', 'cool', 'Wistia', 'hot', 'afmhot', 'gist_heat', + 'copper']) # %% +# .. admonition:: Discouraged +# +# For backward compatibility we additionally support the following colormap +# names, which are identical to other builtin colormaps. Their use is +# discouraged. Use the suggested replacement instead. +# +# ========= ================================= +# Colormap Use identical replacement instead +# ========= ================================= +# gist_gray gray +# gist_yarg gray_r +# binary gray_r +# ========= ================================= +# +# # Diverging # --------- # diff --git a/galleries/users_explain/figure/api_interfaces.rst b/galleries/users_explain/figure/api_interfaces.rst index 981359dbee0b..c3ac06aa27ab 100644 --- a/galleries/users_explain/figure/api_interfaces.rst +++ b/galleries/users_explain/figure/api_interfaces.rst @@ -148,7 +148,7 @@ interfaces and how to translate from one to the other. - Axes: ``label = ax.get_xlabel()`` - pyplot: ``label = plt.xlabel()`` -- Functions that set properties like the property in pyplot and are prefixed with +- Functions that set properties are named like the property in pyplot and are prefixed with ``set_`` on the Axes. Example: - Axes: ``ax.set_xlabel("time")`` @@ -174,7 +174,7 @@ referenced by ``plt.gca()``? One simple way is to call ``subplot`` again with the same arguments. However, that quickly becomes inelegant. You can also inspect the Figure object and get its list of Axes objects, however, that can be misleading (colorbars are Axes too!). The best solution is probably to save a -handle to every Axes you create, but if you do that, why not simply create the +handle to every Axes you create, but if you do that, why not simply create all the Axes objects at the start? The first approach is to call ``plt.subplot`` again: diff --git a/galleries/users_explain/text/fonts.py b/galleries/users_explain/text/fonts.py index 067ed2f3932a..40cc9eaa93eb 100644 --- a/galleries/users_explain/text/fonts.py +++ b/galleries/users_explain/text/fonts.py @@ -27,28 +27,24 @@ Matplotlib supports three font specifications (in addition to pdf 'core fonts', which are explained later in the guide): -.. table:: Type of Fonts - - +--------------------------+----------------------------+----------------------------+ - | Type 1 (PDF with usetex) | Type 3 (PDF/PS) | TrueType (PDF) | - +==========================+============================+============================+ - | One of the oldest types, | Similar to Type 1 in | Newer than previous types, | - | introduced by Adobe | terms of introduction | used commonly today, | - | | | introduced by Apple | - +--------------------------+----------------------------+----------------------------+ - | Restricted subset of | Full PostScript language, | Includes a virtual machine | - | PostScript, charstrings | allows embedding arbitrary | that can execute code! | - | are in bytecode | code (in theory, even | | - | | render fractals when | | - | | rasterizing!) | | - +--------------------------+----------------------------+----------------------------+ - | Supports font | Does not support font | Supports font hinting | - | hinting | hinting | (virtual machine processes | - | | | the "hints") | - +--------------------------+----------------------------+----------------------------+ - | Subsetted by code in | Subsetted via external module | - | `matplotlib._type1font` | `fontTools `__ | - +--------------------------+----------------------------+----------------------------+ +.. table:: Types of Fonts + + +--------------------------+----------------------------+-------------------------------+ + | Type 1 (PDF with usetex) | Type 3 (PDF/PS) | TrueType (PDF) / Type 42 (PS) | + +==========================+============================+===============================+ + | Old font types introduced by Adobe. | Newer font type introduced by | + | | Apple; commonly used today. | + +--------------------------+----------------------------+-------------------------------+ + | Restricted subset of | Full PostScript language, | Includes a virtual machine | + | PostScript, charstrings | allows embedding arbitrary | that can execute code. | + | are in bytecode. | code. | | + +--------------------------+----------------------------+-------------------------------+ + | Supports font hinting. | Does not support font | Supports font hinting, | + | | hinting. | through the virtual machine. | + +--------------------------+----------------------------+-------------------------------+ + | Subsetted by code in | Subsetted via external module | + | `matplotlib._type1font`. | `fontTools `__. | + +--------------------------+----------------------------+-------------------------------+ .. note:: @@ -59,23 +55,9 @@ __ https://helpx.adobe.com/fonts/kb/postscript-type-1-fonts-end-of-support.html -Other font specifications which Matplotlib supports: - -- Type 42 fonts (PS): - - - PostScript wrapper around TrueType fonts - - 42 is the `Answer to Life, the Universe, and Everything! - `_ - - Matplotlib uses the external library - `fontTools `__ to subset these types of - fonts - -- OpenType fonts: - - - OpenType is a new standard for digital type fonts, developed jointly by - Adobe and Microsoft - - Generally contain a much larger character set! - - Limited support with Matplotlib +Matplotlib also provides limited support for OpenType fonts, a newer standard +developed jointly by Adobe and Microsoft; such fonts generally contain a much +larger character set. Font subsetting ^^^^^^^^^^^^^^^ @@ -201,4 +183,4 @@ A majority of this work was done by Aitik Gupta supported by Google Summer of Code 2021. -""" +""" # noqa: E501 diff --git a/galleries/users_explain/text/mathtext.py b/galleries/users_explain/text/mathtext.py index 7ff317804f98..4a4f80c12695 100644 --- a/galleries/users_explain/text/mathtext.py +++ b/galleries/users_explain/text/mathtext.py @@ -370,4 +370,4 @@ # If a particular symbol does not have a name (as is true of many of the more # obscure symbols in the STIX fonts), Unicode characters can also be used:: # -# r'$\u23ce$' +# '$\u23ce$' diff --git a/lib/matplotlib/__init__.pyi b/lib/matplotlib/__init__.pyi index b20ee184149d..321c5a4b90b2 100644 --- a/lib/matplotlib/__init__.pyi +++ b/lib/matplotlib/__init__.pyi @@ -39,6 +39,7 @@ import contextlib from packaging.version import Version from matplotlib._api import MatplotlibDeprecationWarning +from matplotlib.typing import RcKeyType, RcGroupKeyType from typing import Any, Literal, NamedTuple, overload from matplotlib.typing import LogLevel @@ -69,18 +70,18 @@ def get_cachedir() -> str: ... def get_data_path() -> str: ... def matplotlib_fname() -> str: ... -class RcParams(dict[str, Any]): +class RcParams(dict[RcKeyType, Any]): validate: dict[str, Callable] def __init__(self, *args, **kwargs) -> None: ... - def _set(self, key: str, val: Any) -> None: ... - def _get(self, key: str) -> Any: ... + def _set(self, key: RcKeyType, val: Any) -> None: ... + def _get(self, key: RcKeyType) -> Any: ... def _update_raw(self, other_params: dict | RcParams) -> None: ... def _ensure_has_backend(self) -> None: ... - def __setitem__(self, key: str, val: Any) -> None: ... - def __getitem__(self, key: str) -> Any: ... - def __iter__(self) -> Generator[str, None, None]: ... + def __setitem__(self, key: RcKeyType, val: Any) -> None: ... + def __getitem__(self, key: RcKeyType) -> Any: ... + def __iter__(self) -> Generator[RcKeyType, None, None]: ... def __len__(self) -> int: ... def find_all(self, pattern: str) -> RcParams: ... def copy(self) -> RcParams: ... @@ -95,9 +96,9 @@ def rc_params_from_file( rcParamsDefault: RcParams rcParams: RcParams rcParamsOrig: RcParams -defaultParams: dict[str, Any] +defaultParams: dict[RcKeyType, Any] -def rc(group: str, **kwargs) -> None: ... +def rc(group: RcGroupKeyType, **kwargs) -> None: ... def rcdefaults() -> None: ... def rc_file_defaults() -> None: ... def rc_file( @@ -105,7 +106,7 @@ def rc_file( ) -> None: ... @contextlib.contextmanager def rc_context( - rc: dict[str, Any] | None = ..., fname: str | Path | os.PathLike | None = ... + rc: dict[RcKeyType, Any] | None = ..., fname: str | Path | os.PathLike | None = ... ) -> Generator[None, None, None]: ... def use(backend: str, *, force: bool = ...) -> None: ... @overload diff --git a/lib/matplotlib/_api/__init__.py b/lib/matplotlib/_api/__init__.py index 39496cfb0e82..216cc885ea24 100644 --- a/lib/matplotlib/_api/__init__.py +++ b/lib/matplotlib/_api/__init__.py @@ -116,6 +116,9 @@ def check_in_list(values, /, *, _print_supported_values=True, **kwargs): ---------- values : iterable Sequence of values to check on. + + Note: All values must support == comparisons. + This means in particular the entries must not be numpy arrays. _print_supported_values : bool, default: True Whether to print *values* when raising ValueError. **kwargs : dict @@ -133,7 +136,18 @@ def check_in_list(values, /, *, _print_supported_values=True, **kwargs): if not kwargs: raise TypeError("No argument to check!") for key, val in kwargs.items(): - if val not in values: + try: + exists = val in values + except ValueError: + # `in` internally uses `val == values[i]`. There are some objects + # that do not support == to arbitrary other objects, in particular + # numpy arrays. + # Since such objects are not allowed in values, we can gracefully + # handle the case that val (typically provided by users) is of such + # type and directly state it's not in the list instead of letting + # the individual `val == values[i]` ValueError surface. + exists = False + if not exists: msg = f"{val!r} is not a valid value for {key}" if _print_supported_values: msg += f"; supported values are {', '.join(map(repr, values))}" diff --git a/lib/matplotlib/_api/deprecation.py b/lib/matplotlib/_api/deprecation.py index 65a754bbb43d..ce346e02e83d 100644 --- a/lib/matplotlib/_api/deprecation.py +++ b/lib/matplotlib/_api/deprecation.py @@ -419,6 +419,23 @@ def make_keyword_only(since, name, func=None): When used on a method that has a pyplot wrapper, this should be the outermost decorator, so that :file:`boilerplate.py` can access the original signature. + + Examples + -------- + Assume we want to only allow *dataset* and *positions* as positional + parameters on the method :: + + def violinplot(self, dataset, positions=None, vert=None, ...) + + Introduce the deprecation by adding the decorator :: + + @_api.make_keyword_only("3.10", "vert") + def violinplot(self, dataset, positions=None, vert=None, ...) + + When the deprecation expires, switch to :: + + def violinplot(self, dataset, positions=None, *, vert=None, ...) + """ decorator = functools.partial(make_keyword_only, since, name) diff --git a/lib/matplotlib/_constrained_layout.py b/lib/matplotlib/_constrained_layout.py index f5f23581bd9d..33ec8ef985e7 100644 --- a/lib/matplotlib/_constrained_layout.py +++ b/lib/matplotlib/_constrained_layout.py @@ -137,7 +137,8 @@ def do_constrained_layout(fig, h_pad, w_pad, layoutgrids[fig].update_variables() if check_no_collapsed_axes(layoutgrids, fig): reposition_axes(layoutgrids, fig, renderer, h_pad=h_pad, - w_pad=w_pad, hspace=hspace, wspace=wspace) + w_pad=w_pad, hspace=hspace, wspace=wspace, + compress=True) else: _api.warn_external(warn_collapsed) @@ -651,7 +652,7 @@ def get_pos_and_bbox(ax, renderer): def reposition_axes(layoutgrids, fig, renderer, *, - w_pad=0, h_pad=0, hspace=0, wspace=0): + w_pad=0, h_pad=0, hspace=0, wspace=0, compress=False): """ Reposition all the Axes based on the new inner bounding box. """ @@ -662,7 +663,7 @@ def reposition_axes(layoutgrids, fig, renderer, *, bbox=bbox.transformed(trans_fig_to_subfig)) reposition_axes(layoutgrids, sfig, renderer, w_pad=w_pad, h_pad=h_pad, - wspace=wspace, hspace=hspace) + wspace=wspace, hspace=hspace, compress=compress) for ax in fig._localaxes: if ax.get_subplotspec() is None or not ax.get_in_layout(): @@ -689,10 +690,10 @@ def reposition_axes(layoutgrids, fig, renderer, *, for nn, cbax in enumerate(ax._colorbars[::-1]): if ax == cbax._colorbar_info['parents'][0]: reposition_colorbar(layoutgrids, cbax, renderer, - offset=offset) + offset=offset, compress=compress) -def reposition_colorbar(layoutgrids, cbax, renderer, *, offset=None): +def reposition_colorbar(layoutgrids, cbax, renderer, *, offset=None, compress=False): """ Place the colorbar in its new place. @@ -706,6 +707,8 @@ def reposition_colorbar(layoutgrids, cbax, renderer, *, offset=None): offset : array-like Offset the colorbar needs to be pushed to in order to account for multiple colorbars. + compress : bool + Whether we're in compressed layout mode. """ parents = cbax._colorbar_info['parents'] @@ -724,6 +727,31 @@ def reposition_colorbar(layoutgrids, cbax, renderer, *, offset=None): aspect = cbax._colorbar_info['aspect'] shrink = cbax._colorbar_info['shrink'] + # For colorbars with a single parent in compressed layout, + # use the actual visual size of the parent axis after apply_aspect() + # has been called. This ensures colorbars align with their parent axes. + # This fix is specific to single-parent colorbars where alignment is critical. + if compress and len(parents) == 1: + from matplotlib.transforms import Bbox + # Get the actual parent position after apply_aspect() + parent_ax = parents[0] + actual_pos = parent_ax.get_position(original=False) + # Transform to figure coordinates + actual_pos_fig = actual_pos.transformed(fig.transSubfigure - fig.transFigure) + + if location in ('left', 'right'): + # For vertical colorbars, use the actual parent bbox height + # for colorbar sizing + # Keep the pb x-coordinates but use actual y-coordinates + pb = Bbox.from_extents(pb.x0, actual_pos_fig.y0, + pb.x1, actual_pos_fig.y1) + elif location in ('top', 'bottom'): + # For horizontal colorbars, use the actual parent bbox width + # for colorbar sizing + # Keep the pb y-coordinates but use actual x-coordinates + pb = Bbox.from_extents(actual_pos_fig.x0, pb.y0, + actual_pos_fig.x1, pb.y1) + cbpos, cbbbox = get_pos_and_bbox(cbax, renderer) # Colorbar gets put at extreme edge of outer bbox of the subplotspec diff --git a/lib/matplotlib/_mathtext.py b/lib/matplotlib/_mathtext.py index 2258c63e1bc2..914cf7323b8b 100644 --- a/lib/matplotlib/_mathtext.py +++ b/lib/matplotlib/_mathtext.py @@ -570,7 +570,7 @@ def __init__(self, default_font_prop: FontProperties, load_glyph_flags: LoadFlag super().__init__(default_font_prop, load_glyph_flags) for texfont in "cal rm tt it bf sf bfit".split(): - prop = mpl.rcParams['mathtext.' + texfont] + prop = mpl.rcParams['mathtext.' + texfont] # type: ignore[index] font = findfont(prop) self.fontmap[texfont] = font prop = FontProperties('cmex10') diff --git a/lib/matplotlib/_pylab_helpers.py b/lib/matplotlib/_pylab_helpers.py index e3c3d98cb156..05f6d8aa02b3 100644 --- a/lib/matplotlib/_pylab_helpers.py +++ b/lib/matplotlib/_pylab_helpers.py @@ -53,6 +53,8 @@ def destroy(cls, num): two managers share the same number. """ if all(hasattr(num, attr) for attr in ["num", "destroy"]): + # num is a manager-like instance (not necessarily a + # FigureManagerBase subclass) manager = num if cls.figs.get(manager.num) is manager: cls.figs.pop(manager.num) diff --git a/lib/matplotlib/_style_helpers.py b/lib/matplotlib/_style_helpers.py new file mode 100644 index 000000000000..9b98d90593f9 --- /dev/null +++ b/lib/matplotlib/_style_helpers.py @@ -0,0 +1,51 @@ +import collections.abc +import itertools + +import numpy as np + +import matplotlib.cbook as cbook +import matplotlib.colors as mcolors +import matplotlib.lines as mlines + + +def check_non_empty(key, value): + """Raise a TypeError if an empty sequence is passed""" + if (not cbook.is_scalar_or_string(value) and + isinstance(value, collections.abc.Sized) and len(value) == 0): + raise TypeError(f'{key} must not be an empty sequence') + + +def style_generator(kw): + """ + Helper for handling style sequences (e.g. facecolor=['r', 'b', 'k']) within plotting + methods that repeatedly call other plotting methods (e.g. hist, stackplot). Remove + style keywords from the given dictionary. Return the reduced dictionary together + with a generator which provides a series of dictionaries to be used in each call to + the wrapped function. + """ + kw_iterators = {} + remaining_kw = {} + for key, value in kw.items(): + if key in ['facecolor', 'edgecolor']: + if value is None or cbook._str_lower_equal(value, 'none'): + kw_iterators[key] = itertools.repeat(value) + else: + check_non_empty(key, value) + kw_iterators[key] = itertools.cycle(mcolors.to_rgba_array(value)) + + elif key in ['hatch', 'linewidth']: + check_non_empty(key, value) + kw_iterators[key] = itertools.cycle(np.atleast_1d(value)) + + elif key == 'linestyle': + check_non_empty(key, value) + kw_iterators[key] = itertools.cycle(mlines._get_dash_patterns(value)) + + else: + remaining_kw[key] = value + + def style_gen(): + while True: + yield {key: next(val) for key, val in kw_iterators.items()} + + return remaining_kw, style_gen() diff --git a/lib/matplotlib/animation.py b/lib/matplotlib/animation.py index 8756cb0c1439..1a398c91dbef 100644 --- a/lib/matplotlib/animation.py +++ b/lib/matplotlib/animation.py @@ -612,6 +612,12 @@ class FFMpegFileWriter(FFMpegBase, FileMovieWriter): ``-framerate``, so see also `their notes on frame rates`_ for further details. .. _their notes on frame rates: https://trac.ffmpeg.org/wiki/Slideshow#Framerates + + Parameters + ---------- + *args, **kwargs + All arguments are forwarded to `FileMovieWriter`. See + `FileMovieWriter` for a list of all possible parameters. """ supported_formats = ['png', 'jpeg', 'tiff', 'raw', 'rgba'] @@ -861,7 +867,7 @@ class Animation: fig : `~matplotlib.figure.Figure` The figure object used to get needed events, such as draw or resize. - event_source : object, optional + event_source : object A class that can run a callback when desired events are generated, as well as be stopped and started. @@ -877,7 +883,7 @@ class Animation: FuncAnimation, ArtistAnimation """ - def __init__(self, fig, event_source=None, blit=False): + def __init__(self, fig, event_source, blit=False): self._draw_was_started = False self._fig = fig diff --git a/lib/matplotlib/animation.pyi b/lib/matplotlib/animation.pyi index f725df8ebb22..e90a0103aefd 100644 --- a/lib/matplotlib/animation.pyi +++ b/lib/matplotlib/animation.pyi @@ -6,7 +6,7 @@ from matplotlib.artist import Artist from matplotlib.backend_bases import TimerBase from matplotlib.figure import Figure -from typing import Any +from typing import Any, Protocol subprocess_creation_flags: int @@ -152,11 +152,17 @@ class HTMLWriter(FileMovieWriter): def grab_frame(self, **savefig_kwargs): ... def finish(self) -> None: ... +class EventSourceProtocol(Protocol): + def add_callback(self, func: Callable): ... + def remove_callback(self, func: Callable): ... + def start(self): ... + def stop(self): ... + class Animation: frame_seq: Iterable[Artist] - event_source: Any + event_source: EventSourceProtocol | None # TODO: We should remove None def __init__( - self, fig: Figure, event_source: Any | None = ..., blit: bool = ... + self, fig: Figure, event_source: EventSourceProtocol, blit: bool = ... ) -> None: ... def __del__(self) -> None: ... def save( diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index f0bc139bdc11..470e096eb033 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -32,11 +32,13 @@ import matplotlib.transforms as mtransforms import matplotlib.tri as mtri import matplotlib.units as munits -from matplotlib import _api, _docstring, _preprocess_data +from matplotlib import _api, _docstring, _preprocess_data, _style_helpers from matplotlib.axes._base import ( _AxesBase, _TransformedBoundsLocator, _process_plot_format) from matplotlib.axes._secondary_axes import SecondaryAxis -from matplotlib.container import BarContainer, ErrorbarContainer, StemContainer +from matplotlib.container import ( + BarContainer, ErrorbarContainer, PieContainer, StemContainer) +from matplotlib.text import Text from matplotlib.transforms import _ScaledRotation _log = logging.getLogger(__name__) @@ -1183,7 +1185,7 @@ def hlines(self, y, xmin, xmax, colors=None, linestyles='solid', if self.name == "rectilinear": datalim = lines.get_datalim(self.transData) t = lines.get_transform() - updatex, updatey = t.contains_branch_seperately(self.transData) + updatex, updatey = t.contains_branch_separately(self.transData) minx = np.nanmin(datalim.xmin) maxx = np.nanmax(datalim.xmax) miny = np.nanmin(datalim.ymin) @@ -1275,7 +1277,7 @@ def vlines(self, x, ymin, ymax, colors=None, linestyles='solid', if self.name == "rectilinear": datalim = lines.get_datalim(self.transData) t = lines.get_transform() - updatex, updatey = t.contains_branch_seperately(self.transData) + updatex, updatey = t.contains_branch_separately(self.transData) minx = np.nanmin(datalim.xmin) maxx = np.nanmax(datalim.xmax) miny = np.nanmin(datalim.ymin) @@ -3192,6 +3194,16 @@ def grouped_bar(self, heights, *, positions=None, group_spacing=1.5, bar_spacing **kwargs : `.Rectangle` properties + Properties applied to all bars. The following properties additionally + accept a sequence of values corresponding to the datasets in + *heights*: + + - *edgecolor* + - *facecolor* + - *linewidth* + - *linestyle* + - *hatch* + %(Rectangle:kwdoc)s Returns @@ -3318,6 +3330,8 @@ def grouped_bar(self, heights, *, positions=None, group_spacing=1.5, bar_spacing # TODO: do we want to be more restrictive and check lengths? colors = itertools.cycle(colors) + kwargs, style_gen = _style_helpers.style_generator(kwargs) + bar_width = (group_distance / (num_datasets + (num_datasets - 1) * bar_spacing + group_spacing)) bar_spacing_abs = bar_spacing * bar_width @@ -3331,15 +3345,16 @@ def grouped_bar(self, heights, *, positions=None, group_spacing=1.5, bar_spacing # place the bars, but only use numerical positions, categorical tick labels # are handled separately below bar_containers = [] - for i, (hs, label, color) in enumerate(zip(heights, labels, colors)): + for i, (hs, label, color, styles) in enumerate(zip(heights, labels, colors, + style_gen)): lefts = (group_centers - 0.5 * group_distance + margin_abs + i * (bar_width + bar_spacing_abs)) if orientation == "vertical": bc = self.bar(lefts, hs, width=bar_width, align="edge", - label=label, color=color, **kwargs) + label=label, color=color, **styles, **kwargs) else: bc = self.barh(lefts, hs, height=bar_width, align="edge", - label=label, color=color, **kwargs) + label=label, color=color, **styles, **kwargs) bar_containers.append(bc) if tick_labels is not None: @@ -3594,7 +3609,7 @@ def pie(self, x, explode=None, labels=None, colors=None, keywords, properties passed to *wedgeprops* take precedence. textprops : dict, default: None - Dict of arguments to pass to the text objects. + Dict of arguments to pass to the `.Text` objects. center : (float, float), default: (0, 0) The coordinates of the center of the chart. @@ -3615,15 +3630,11 @@ def pie(self, x, explode=None, labels=None, colors=None, Returns ------- - patches : list - A sequence of `matplotlib.patches.Wedge` instances + `.PieContainer` + Container with all the wedge patches and any associated text objects. - texts : list - A list of the label `.Text` instances. - - autotexts : list - A list of `.Text` instances for the numeric labels. This will only - be returned if the parameter *autopct* is not *None*. + .. versionchanged:: 3.11 + Previously the wedges and texts were returned in a tuple. Notes ----- @@ -3633,9 +3644,7 @@ def pie(self, x, explode=None, labels=None, colors=None, The Axes aspect ratio can be controlled with `.Axes.set_aspect`. """ self.set_aspect('equal') - # The use of float32 is "historical", but can't be changed without - # regenerating the test baselines. - x = np.asarray(x, np.float32) + x = np.asarray(x) if x.ndim > 1: raise ValueError("x must be 1D") @@ -3651,9 +3660,11 @@ def pie(self, x, explode=None, labels=None, colors=None, raise ValueError('All wedge sizes are zero') if normalize: - x = x / sx + fracs = x / sx elif sx > 1: raise ValueError('Cannot plot an unnormalized pie with sum(x) > 1') + else: + fracs = x if labels is None: labels = [''] * len(x) if explode is None: @@ -3681,21 +3692,17 @@ def get_next_color(): if wedgeprops is None: wedgeprops = {} - if textprops is None: - textprops = {} - texts = [] slices = [] - autotexts = [] - for frac, label, expl in zip(x, labels, explode): - x, y = center + for frac, label, expl in zip(fracs, labels, explode): + x_pos, y_pos = center theta2 = (theta1 + frac) if counterclock else (theta1 - frac) thetam = 2 * np.pi * 0.5 * (theta1 + theta2) - x += expl * math.cos(thetam) - y += expl * math.sin(thetam) + x_pos += expl * math.cos(thetam) + y_pos += expl * math.sin(thetam) - w = mpatches.Wedge((x, y), radius, 360. * min(theta1, theta2), + w = mpatches.Wedge((x_pos, y_pos), radius, 360. * min(theta1, theta2), 360. * max(theta1, theta2), facecolor=get_next_color(), hatch=next(hatch_cycle), @@ -3713,28 +3720,28 @@ def get_next_color(): shadow_dict.update(shadow) self.add_patch(mpatches.Shadow(w, **shadow_dict)) - if labeldistance is not None: - xt = x + labeldistance * radius * math.cos(thetam) - yt = y + labeldistance * radius * math.sin(thetam) - label_alignment_h = 'left' if xt > 0 else 'right' - label_alignment_v = 'center' - label_rotation = 'horizontal' - if rotatelabels: - label_alignment_v = 'bottom' if yt > 0 else 'top' - label_rotation = (np.rad2deg(thetam) - + (0 if xt > 0 else 180)) - t = self.text(xt, yt, label, - clip_on=False, - horizontalalignment=label_alignment_h, - verticalalignment=label_alignment_v, - rotation=label_rotation, - size=mpl.rcParams['xtick.labelsize']) - t.set(**textprops) - texts.append(t) - - if autopct is not None: - xt = x + pctdistance * radius * math.cos(thetam) - yt = y + pctdistance * radius * math.sin(thetam) + theta1 = theta2 + + pc = PieContainer(slices, x, normalize) + + if labeldistance is None: + # Insert an empty list of texts for backwards compatibility of the + # return value. + pc.add_texts([]) + else: + # Add labels to the wedges. + labels_textprops = { + 'fontsize': mpl.rcParams['xtick.labelsize'], + **cbook.normalize_kwargs(textprops or {}, Text) + } + self.pie_label(pc, labels, distance=labeldistance, + alignment='outer', rotate=rotatelabels, + textprops=labels_textprops) + + if autopct is not None: + # Add automatic percentage labels to wedges + auto_labels = [] + for frac in fracs: if isinstance(autopct, str): s = autopct % (100. * frac) elif callable(autopct): @@ -3742,17 +3749,15 @@ def get_next_color(): else: raise TypeError( 'autopct must be callable or a format string') - if mpl._val_or_rc(textprops.get("usetex"), "text.usetex"): + if textprops is not None and mpl._val_or_rc(textprops.get("usetex"), + "text.usetex"): # escape % (i.e. \%) if it is not already escaped s = re.sub(r"([^\\])%", r"\1\\%", s) - t = self.text(xt, yt, s, - clip_on=False, - horizontalalignment='center', - verticalalignment='center') - t.set(**textprops) - autotexts.append(t) + auto_labels.append(s) - theta1 = theta2 + self.pie_label(pc, auto_labels, distance=pctdistance, + alignment='center', + textprops=textprops) if frame: self._request_autoscale_view() @@ -3761,10 +3766,107 @@ def get_next_color(): xlim=(-1.25 + center[0], 1.25 + center[0]), ylim=(-1.25 + center[1], 1.25 + center[1])) - if autopct is None: - return slices, texts - else: - return slices, texts, autotexts + return pc + + def pie_label(self, container, /, labels, *, distance=0.6, + textprops=None, rotate=False, alignment='auto'): + """ + Label a pie chart. + + .. versionadded:: 3.11 + + Adds labels to wedges in the given `.PieContainer`. + + Parameters + ---------- + container : `.PieContainer` + Container with all the wedges, likely returned from `.pie`. + + labels : str or list of str + 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%})" + + distance : float, default: 0.6 + The radial position of the labels, relative to the pie radius. Values > 1 + are outside the wedge and values < 1 are inside the wedge. + + textprops : dict, default: None + Dict of arguments to pass to the `.Text` objects. + + rotate : bool, default: False + Rotate each label to the angle of the corresponding slice if true. + + alignment : {'center', 'outer', 'auto'}, default: 'auto' + Controls the horizontal alignment of the text objects relative to their + nominal position. + + - 'center': The labels are centered on their points. + - 'outer': Labels are aligned away from the center of the pie, i.e., labels + on the left side of the pie are right-aligned and labels on the right + side are left-aligned. + - 'auto': Translates to 'outer' if *distance* > 1 (so that the labels do not + overlap the wedges) and 'center' if *distance* < 1. + + If *rotate* is True, the vertical alignment is also affected in an + analogous way. + + - 'center': The labels are centered on their points. + - 'outer': Labels are aligned away from the center of the pie, i.e., labels + on the top half of the pie are bottom-aligned and labels on the bottom + half are top-aligned. + + Returns + ------- + list + A list of the label `.Text` instances. + """ + _api.check_in_list(['center', 'outer', 'auto'], alignment=alignment) + if alignment == 'auto': + alignment = 'outer' if distance > 1 else 'center' + + if textprops is None: + textprops = {} + + if isinstance(labels, str): + # Assume we have a format string + labels = [labels.format(absval=val, frac=frac) for val, frac in + zip(container.values, container.fracs)] + if mpl._val_or_rc(textprops.get("usetex"), "text.usetex"): + # escape % (i.e. \%) if it is not already escaped + labels = [re.sub(r"([^\\])%", r"\1\\%", s) for s in labels] + elif (nw := len(container.wedges)) != (nl := len(labels)): + raise ValueError( + f'The number of labels ({nl}) must match the number of wedges ({nw})') + + texts = [] + + for wedge, label in zip(container.wedges, labels): + thetam = 2 * np.pi * 0.5 * (wedge.theta1 + wedge.theta2) / 360 + xt = wedge.center[0] + distance * wedge.r * math.cos(thetam) + yt = wedge.center[1] + distance * wedge.r * math.sin(thetam) + if alignment == 'outer': + label_alignment_h = 'left' if xt > 0 else 'right' + else: + label_alignment_h = 'center' + label_alignment_v = 'center' + label_rotation = 'horizontal' + if rotate: + if alignment == 'outer': + label_alignment_v = 'bottom' if yt > 0 else 'top' + label_rotation = (np.rad2deg(thetam) + (0 if xt > 0 else 180)) + t = self.text(xt, yt, label, clip_on=False, rotation=label_rotation, + horizontalalignment=label_alignment_h, + verticalalignment=label_alignment_v) + t.set(**textprops) + texts.append(t) + + container.add_texts(texts) + + return texts + @staticmethod def _errorevery_to_mask(x, errorevery): @@ -5220,12 +5322,12 @@ def scatter(self, x, y, s=None, c=None, marker=None, cmap=None, norm=None, s = (20 if mpl.rcParams['_internal.classic_mode'] else mpl.rcParams['lines.markersize'] ** 2.0) s = np.ma.ravel(s) - if (len(s) not in (1, x.size) or - (not np.issubdtype(s.dtype, np.floating) and - not np.issubdtype(s.dtype, np.integer))): - raise ValueError( - "s must be a scalar, " - "or float array-like with the same size as x and y") + if not (np.issubdtype(s.dtype, np.floating) + or np.issubdtype(s.dtype, np.integer)): + raise ValueError(f"s must be float, but has type {s.dtype}") + if len(s) not in (1, x.size): + raise ValueError(f"s (size {len(s)}) cannot be broadcast " + f"to match x and y (size {len(x)})") # get the original edgecolor the user passed before we normalize orig_edgecolor = edgecolors @@ -5271,7 +5373,7 @@ def scatter(self, x, y, s=None, c=None, marker=None, cmap=None, norm=None, if not marker_obj.is_filled(): if orig_edgecolor is not None: _api.warn_external( - f"You passed a edgecolor/edgecolors ({orig_edgecolor!r}) " + f"You passed an edgecolor/edgecolors ({orig_edgecolor!r}) " f"for an unfilled marker ({marker!r}). Matplotlib is " "ignoring the edgecolor in favor of the facecolor. This " "behavior may change in the future." @@ -5420,8 +5522,9 @@ def hexbin(self, x, y, C=None, gridsize=100, bins=None, - If *None*, no binning is applied; the color of each hexagon directly corresponds to its count value. - If 'log', use a logarithmic scale for the colormap. - Internally, :math:`log_{10}(i+1)` is used to determine the + Internally, :math:`log_{10}(i)` is used to determine the hexagon color. This is equivalent to ``norm=LogNorm()``. + Note that 0 counts are thus marked with the "bad" color. - If an integer, divide the counts in the specified number of bins, and color the hexagons accordingly. - If a sequence of values, the values of the lower bound of @@ -6809,7 +6912,7 @@ def _update_pcolor_lims(self, collection, coords): hasattr(t, '_as_mpl_transform')): t = t._as_mpl_transform(self.axes) - if t and any(t.contains_branch_seperately(self.transData)): + if t and any(t.contains_branch_separately(self.transData)): trans_to_data = t - self.transData coords = trans_to_data.transform(coords) @@ -7542,38 +7645,15 @@ def hist(self, x, bins=None, range=None, density=False, weights=None, labels = [] if label is None else np.atleast_1d(np.asarray(label, str)) if histtype == "step": - ec = kwargs.get('edgecolor', colors) - else: - ec = kwargs.get('edgecolor', None) - if ec is None or cbook._str_lower_equal(ec, 'none'): - edgecolors = itertools.repeat(ec) - else: - edgecolors = itertools.cycle(mcolors.to_rgba_array(ec)) - - fc = kwargs.get('facecolor', colors) - if cbook._str_lower_equal(fc, 'none'): - facecolors = itertools.repeat(fc) - else: - facecolors = itertools.cycle(mcolors.to_rgba_array(fc)) + kwargs.setdefault('edgecolor', colors) - hatches = itertools.cycle(np.atleast_1d(kwargs.get('hatch', None))) - linewidths = itertools.cycle(np.atleast_1d(kwargs.get('linewidth', None))) - if 'linestyle' in kwargs: - linestyles = itertools.cycle(mlines._get_dash_patterns(kwargs['linestyle'])) - else: - linestyles = itertools.repeat(None) + kwargs, style_gen = _style_helpers.style_generator(kwargs) for patch, lbl in itertools.zip_longest(patches, labels): if not patch: continue p = patch[0] - kwargs.update({ - 'hatch': next(hatches), - 'linewidth': next(linewidths), - 'linestyle': next(linestyles), - 'edgecolor': next(edgecolors), - 'facecolor': next(facecolors), - }) + kwargs.update(next(style_gen)) p._internal_update(kwargs) if lbl is not None: p.set_label(lbl) @@ -7772,13 +7852,15 @@ def hist2d(self, x, y, bins=10, range=None, density=False, weights=None, Notes ----- - - Currently ``hist2d`` calculates its own axis limits, and any limits - previously set are ignored. - - Rendering the histogram with a logarithmic color scale is - accomplished by passing a `.colors.LogNorm` instance to the *norm* - keyword argument. Likewise, power-law normalization (similar - in effect to gamma correction) can be accomplished with - `.colors.PowerNorm`. + Rendering the histogram with a logarithmic color scale is accomplished + by passing a `.colors.LogNorm` instance to the *norm* keyword + argument. Likewise, power-law normalization (similar in effect to gamma + correction) can be accomplished with `.colors.PowerNorm`. + + .. versionchanged:: 3.11 + Previously, `~.Axes.hist2d` would force the axes limits to match the + extents of the histogram; now, autoscaling also takes other plot + elements into account. """ h, xedges, yedges = np.histogram2d(x, y, bins=bins, range=range, @@ -7790,8 +7872,6 @@ def hist2d(self, x, y, bins=10, range=None, density=False, weights=None, h[h > cmax] = None pc = self.pcolormesh(xedges, yedges, h.T, **kwargs) - self.set_xlim(xedges[0], xedges[-1]) - self.set_ylim(yedges[0], yedges[-1]) return h, xedges, yedges, pc @@ -8878,18 +8958,8 @@ def violinplot(self, dataset, positions=None, vert=None, .Axes.violin : Draw a violin from pre-computed statistics. boxplot : Draw a box and whisker plot. """ - - def _kde_method(X, coords): - # Unpack in case of e.g. Pandas or xarray object - X = cbook._unpack_to_numpy(X) - # fallback gracefully if the vector contains only one value - if np.all(X[0] == X): - return (X[0] == coords).astype(float) - kde = mlab.GaussianKDE(X, bw_method) - return kde.evaluate(coords) - - vpstats = cbook.violin_stats(dataset, _kde_method, points=points, - quantiles=quantiles) + vpstats = cbook.violin_stats(dataset, ("GaussianKDE", bw_method), + points=points, quantiles=quantiles) return self.violin(vpstats, positions=positions, vert=vert, orientation=orientation, widths=widths, showmeans=showmeans, showextrema=showextrema, @@ -8977,6 +9047,14 @@ def violin(self, vpstats, positions=None, vert=None, .. versionadded:: 3.11 + For backward compatibility, if *facecolor* is not given, the body + will get an Artist-level transparency `alpha <.Artist.set_alpha>` + of 0.3, which will persist if you afterwards change the facecolor, + e.g. via ``result['bodies'][0].set_facecolor('red')``. + If *facecolor* is given, there is no Artist-level transparency. + To set transparency for *facecolor* or *edgecolor* use + ``(color, alpha)`` tuples. + linecolor : :mpltype:`color` or list of :mpltype:`color`, optional If provided, will set the line color(s) of the violins (the horizontal and vertical spines and body edges). @@ -9016,6 +9094,8 @@ def violin(self, vpstats, positions=None, vert=None, -------- violinplot : Draw a violin plot from data instead of pre-computed statistics. + .cbook.violin_stats: + Calculate a *vpstats* dictionary from data, suitable for passing to violin. """ # Statistical quantities to be plotted on the violins @@ -9082,13 +9162,14 @@ def cycle_color(color, alpha=None): if facecolor is not None: facecolor = cycle_color(facecolor) + body_artist_alpha = None else: - default_facealpha = 0.3 + body_artist_alpha = 0.3 # Use default colors if user doesn't provide them if mpl.rcParams['_internal.classic_mode']: - facecolor = cycle_color('y', alpha=default_facealpha) + facecolor = cycle_color('y') else: - facecolor = cycle_color(next_color, alpha=default_facealpha) + facecolor = cycle_color(next_color) if mpl.rcParams['_internal.classic_mode']: # Classic mode uses patch.force_edgecolor=True, so we need to @@ -9137,7 +9218,8 @@ def cycle_color(color, alpha=None): bodies += [fill(stats['coords'], -vals + pos if side in ['both', 'low'] else pos, vals + pos if side in ['both', 'high'] else pos, - facecolor=facecolor, edgecolor=body_edgecolor)] + facecolor=facecolor, edgecolor=body_edgecolor, + alpha=body_artist_alpha)] means.append(stats['mean']) mins.append(stats['min']) maxes.append(stats['max']) diff --git a/lib/matplotlib/axes/_axes.pyi b/lib/matplotlib/axes/_axes.pyi index 69d251aa21f7..09587ab753a3 100644 --- a/lib/matplotlib/axes/_axes.pyi +++ b/lib/matplotlib/axes/_axes.pyi @@ -13,7 +13,8 @@ from matplotlib.collections import ( ) from matplotlib.colorizer import Colorizer from matplotlib.colors import Colormap, Normalize -from matplotlib.container import BarContainer, ErrorbarContainer, StemContainer +from matplotlib.container import ( + BarContainer, PieContainer, ErrorbarContainer, StemContainer) from matplotlib.contour import ContourSet, QuadContourSet from matplotlib.image import AxesImage, PcolorImage from matplotlib.inset import InsetIndicator @@ -21,7 +22,7 @@ from matplotlib.legend import Legend from matplotlib.legend_handler import HandlerBase from matplotlib.lines import Line2D, AxLine from matplotlib.mlab import GaussianKDE -from matplotlib.patches import Rectangle, FancyArrow, Polygon, StepPatch, Wedge +from matplotlib.patches import Rectangle, FancyArrow, Polygon, StepPatch from matplotlib.quiver import Quiver, QuiverKey, Barbs from matplotlib.text import Annotation, Text from matplotlib.transforms import Transform @@ -324,9 +325,18 @@ class Axes(_AxesBase): normalize: bool = ..., hatch: str | Sequence[str] | None = ..., data=..., - ) -> tuple[list[Wedge], list[Text]] | tuple[ - list[Wedge], list[Text], list[Text] - ]: ... + ) -> PieContainer: ... + def pie_label( + self, + container: PieContainer, + /, + labels: str | Sequence[str], + *, + distance: float = ..., + textprops: dict | None = ..., + rotate: bool = ..., + alignment: str = ..., + ) -> list[Text]: ... def errorbar( self, x: float | ArrayLike, diff --git a/lib/matplotlib/axes/_base.py b/lib/matplotlib/axes/_base.py index fa628b3f34e0..ecff24540690 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -1736,6 +1736,8 @@ def get_adjustable(self): Return whether the Axes will adjust its physical dimension ('box') or its data limits ('datalim') to achieve the desired aspect ratio. + Newly created Axes default to 'box'. + See Also -------- matplotlib.axes.Axes.set_adjustable @@ -1761,6 +1763,8 @@ def set_adjustable(self, adjustable, share=False): See Also -------- + matplotlib.axes.Axes.get_adjustable + Return the current value of *adjustable*. matplotlib.axes.Axes.set_aspect For a description of aspect handling. @@ -1792,7 +1796,7 @@ def set_adjustable(self, adjustable, share=False): "Axes which override 'get_data_ratio'") for ax in axs: ax._adjustable = adjustable - self.stale = True + ax.stale = True def get_box_aspect(self): """ @@ -2380,9 +2384,9 @@ def add_collection(self, collection, autolim=True): # only update the dataLim for x/y if the collection uses transData # in this direction. x_is_data, y_is_data = (collection.get_transform() - .contains_branch_seperately(self.transData)) + .contains_branch_separately(self.transData)) ox_is_data, oy_is_data = (collection.get_offset_transform() - .contains_branch_seperately(self.transData)) + .contains_branch_separately(self.transData)) self.update_datalim( points, updatex=x_is_data or ox_is_data, @@ -2451,7 +2455,7 @@ def _update_line_limits(self, line): if line_trf == self.transData: data_path = path - elif any(line_trf.contains_branch_seperately(self.transData)): + elif any(line_trf.contains_branch_separately(self.transData)): # Compute the transform from line coordinates to data coordinates. trf_to_data = line_trf - self.transData # If transData is affine we can use the cached non-affine component @@ -2474,7 +2478,7 @@ def _update_line_limits(self, line): if not data_path.vertices.size: return - updatex, updatey = line_trf.contains_branch_seperately(self.transData) + updatex, updatey = line_trf.contains_branch_separately(self.transData) if self.name != "rectilinear": # This block is mostly intended to handle axvline in polar plots, # for which updatey would otherwise be True. @@ -2527,7 +2531,7 @@ def _update_patch_limits(self, patch): vertices = np.vstack(vertices) patch_trf = patch.get_transform() - updatex, updatey = patch_trf.contains_branch_seperately(self.transData) + updatex, updatey = patch_trf.contains_branch_separately(self.transData) if not (updatex or updatey): return if self.name != "rectilinear": @@ -3202,7 +3206,7 @@ def draw(self, renderer): if not self.get_figure(root=True).canvas.is_saving(): artists = [ a for a in artists - if not a.get_animated() or isinstance(a, mimage.AxesImage)] + if not a.get_animated()] artists = sorted(artists, key=attrgetter('zorder')) # rasterize artists with negative zorder @@ -4628,9 +4632,7 @@ def get_tightbbox(self, renderer=None, *, call_axes_locator=True, for a in bbox_artists: bbox = a.get_tightbbox(renderer) - if (bbox is not None - and 0 < bbox.width < np.inf - and 0 < bbox.height < np.inf): + if bbox is not None and bbox._is_finite(): bb.append(bbox) return mtransforms.Bbox.union( [b for b in bb if b.width != 0 or b.height != 0]) diff --git a/lib/matplotlib/axis.py b/lib/matplotlib/axis.py index d80e7b4dafb9..c3b6fcac569f 100644 --- a/lib/matplotlib/axis.py +++ b/lib/matplotlib/axis.py @@ -1333,9 +1333,11 @@ def _update_ticks(self): def _get_ticklabel_bboxes(self, ticks, renderer): """Return lists of bboxes for ticks' label1's and label2's.""" return ([tick.label1.get_window_extent(renderer) - for tick in ticks if tick.label1.get_visible()], + for tick in ticks + if tick.label1.get_visible() and tick.label1.get_in_layout()], [tick.label2.get_window_extent(renderer) - for tick in ticks if tick.label2.get_visible()]) + for tick in ticks + if tick.label2.get_visible() and tick.label2.get_in_layout()]) def get_tightbbox(self, renderer=None, *, for_layout_only=False): """ @@ -1381,8 +1383,7 @@ def get_tightbbox(self, renderer=None, *, for_layout_only=False): bb.y0 = (bb.y0 + bb.y1) / 2 - 0.5 bb.y1 = bb.y0 + 1.0 bboxes.append(bb) - bboxes = [b for b in bboxes - if 0 < b.width < np.inf and 0 < b.height < np.inf] + bboxes = [b for b in bboxes if b._is_finite()] if bboxes: return mtransforms.Bbox.union(bboxes) else: diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 8107471955fe..e7edb0e7448f 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -1262,7 +1262,7 @@ class LocationEvent(Event): xdata, ydata : float or None Data coordinates of the mouse within *inaxes*, or *None* if the mouse is not over an Axes. - modifiers : frozenset + modifiers : frozenset[str] The keyboard modifiers currently being pressed (except for KeyEvent). """ @@ -1433,7 +1433,7 @@ def _from_ax_coords(cls, name, ax, xy, *args, **kwargs): def __str__(self): return (f"{self.name}: " f"xy=({self.x}, {self.y}) xydata=({self.xdata}, {self.ydata}) " - f"button={self.button} dblclick={self.dblclick} " + f"button={self.button} dblclick={self.dblclick} step={self.step} " f"inaxes={self.inaxes}") @@ -1763,7 +1763,7 @@ def __init__(self, figure=None): self.toolbar = None # NavigationToolbar2 will set me self._is_idle_drawing = False # We don't want to scale up the figure DPI more than once. - figure._original_dpi = figure.dpi + figure._original_dpi = getattr(figure, '_original_dpi', figure.dpi) self._device_pixel_ratio = 1 super().__init__() # Typically the GUI widget init (if any). @@ -2574,6 +2574,62 @@ def button_press_handler(event, canvas=None, toolbar=None): toolbar.forward() +def scroll_handler(event, canvas=None, toolbar=None): + ax = event.inaxes + if ax is None: + return + if ax.name != "rectilinear": + # zooming is currently only supported on rectilinear axes + return + + if toolbar is None: + toolbar = (canvas or event.canvas).toolbar + + if toolbar is None: + # technically we do not need a toolbar, but until wheel zoom was + # introduced, any interactive modification was only possible through + # the toolbar tools. For now, we keep the restriction that a toolbar + # is required for interactive navigation. + return + + if event.key in {"control", "x", "y"}: # zoom towards the mouse position + toolbar.push_current() + + xmin, xmax = ax.get_xlim() + ymin, ymax = ax.get_ylim() + (xmin, ymin), (xmax, ymax) = ax.transScale.transform( + [(xmin, ymin), (xmax, ymax)]) + + # mouse position in scaled (e.g., log) data coordinates + x, y = ax.transScale.transform((event.xdata, event.ydata)) + + scale_factor = 0.85 ** event.step + # Determine which axes to scale based on key + zoom_x = event.key in {"control", "x"} + zoom_y = event.key in {"control", "y"} + + if zoom_x: + new_xmin = x - (x - xmin) * scale_factor + new_xmax = x + (xmax - x) * scale_factor + else: + new_xmin, new_xmax = xmin, xmax + + if zoom_y: + new_ymin = y - (y - ymin) * scale_factor + new_ymax = y + (ymax - y) * scale_factor + else: + new_ymin, new_ymax = ymin, ymax + + inv_scale = ax.transScale.inverted() + (new_xmin, new_ymin), (new_xmax, new_ymax) = inv_scale.transform( + [(new_xmin, new_ymin), (new_xmax, new_ymax)]) + + ax.set_xlim(new_xmin, new_xmax) + ax.set_ylim(new_ymin, new_ymax) + + ax.figure.canvas.draw_idle() + + class NonGuiException(Exception): """Raised when trying show a figure in a non-GUI backend.""" pass @@ -2653,11 +2709,14 @@ def __init__(self, canvas, num): self.key_press_handler_id = None self.button_press_handler_id = None + self.scroll_handler_id = None if rcParams['toolbar'] != 'toolmanager': self.key_press_handler_id = self.canvas.mpl_connect( 'key_press_event', key_press_handler) self.button_press_handler_id = self.canvas.mpl_connect( 'button_press_event', button_press_handler) + self.scroll_handler_id = self.canvas.mpl_connect( + 'scroll_event', scroll_handler) self.toolmanager = (ToolManager(canvas.figure) if mpl.rcParams['toolbar'] == 'toolmanager' @@ -2758,7 +2817,9 @@ def show(self): f"shown") def destroy(self): - pass + # managers may have swapped the canvas to a GUI-framework specific one. + # restore the base canvas when the manager is destroyed. + self.canvas.figure._set_base_canvas() def full_screen_toggle(self): pass diff --git a/lib/matplotlib/backend_bases.pyi b/lib/matplotlib/backend_bases.pyi index c65d39415472..a69b36093839 100644 --- a/lib/matplotlib/backend_bases.pyi +++ b/lib/matplotlib/backend_bases.pyi @@ -237,6 +237,7 @@ class LocationEvent(Event): inaxes: Axes | None xdata: float | None ydata: float | None + modifiers: frozenset[str] def __init__( self, name: str, @@ -407,6 +408,11 @@ def button_press_handler( canvas: FigureCanvasBase | None = ..., toolbar: NavigationToolbar2 | None = ..., ) -> None: ... +def scroll_handler( + event: MouseEvent, + canvas: FigureCanvasBase | None = ..., + toolbar: NavigationToolbar2 | None = ..., +) -> None: ... class NonGuiException(Exception): ... @@ -415,6 +421,7 @@ class FigureManagerBase: num: int | str key_press_handler_id: int | None button_press_handler_id: int | None + scroll_handler_id: int | None toolmanager: ToolManager | None toolbar: NavigationToolbar2 | ToolContainerBase | None def __init__(self, canvas: FigureCanvasBase, num: int | str) -> None: ... diff --git a/lib/matplotlib/backends/_backend_gtk.py b/lib/matplotlib/backends/_backend_gtk.py index ac443730e28a..ce6982a72526 100644 --- a/lib/matplotlib/backends/_backend_gtk.py +++ b/lib/matplotlib/backends/_backend_gtk.py @@ -195,6 +195,7 @@ def destroy(self, *args): self._destroying = True self.window.destroy() self.canvas.destroy() + super().destroy() @classmethod def start_main_loop(cls): diff --git a/lib/matplotlib/backends/_backend_tk.py b/lib/matplotlib/backends/_backend_tk.py index eaf868fd8bec..42782b2f00e1 100644 --- a/lib/matplotlib/backends/_backend_tk.py +++ b/lib/matplotlib/backends/_backend_tk.py @@ -22,8 +22,36 @@ TimerBase, ToolContainerBase, cursors, _Mode, MouseButton, CloseEvent, KeyEvent, LocationEvent, MouseEvent, ResizeEvent) from matplotlib._pylab_helpers import Gcf -from . import _tkagg -from ._tkagg import TK_PHOTO_COMPOSITE_OVERLAY, TK_PHOTO_COMPOSITE_SET + +try: + from . import _tkagg + from ._tkagg import TK_PHOTO_COMPOSITE_OVERLAY, TK_PHOTO_COMPOSITE_SET +except ImportError as e: + # catch incompatibility of python-build-standalone with Tk + cause1 = getattr(e, '__cause__', None) + cause2 = getattr(cause1, '__cause__', None) + if (isinstance(cause1, ImportError) and + isinstance(cause2, AttributeError) and + "'_tkinter' has no attribute '__file__'" in str(cause2)): + + is_uv_python = "/uv/python" in (os.path.realpath(sys.executable)) + if is_uv_python: + raise ImportError( + "Failed to import tkagg backend. You appear to be using an outdated " + "version of uv's managed Python distribution which is not compatible " + "with Tk. Please upgrade to the latest uv version, then update " + "Python with: `uv python upgrade --reinstall`" + ) from e + else: + raise ImportError( + "Failed to import tkagg backend. This is likely caused by using a " + "Python executable based on python-build-standalone, which is not " + "compatible with Tk. Recent versions of python-build-standalone " + "should be compatible with Tk. Please update your python version " + "or select another backend." + ) from e + else: + raise _log = logging.getLogger(__name__) @@ -606,6 +634,7 @@ def delayed_destroy(): else: self.window.update() delayed_destroy() + super().destroy() def get_window_title(self): return self.window.wm_title() @@ -638,6 +667,14 @@ def __init__(self, canvas, window=None, *, pack_toolbar=True): window = canvas.get_tk_widget().master tk.Frame.__init__(self, master=window, borderwidth=2, width=int(canvas.figure.bbox.width), height=50) + # Avoid message_label expanding the toolbar size, and in turn expanding the + # canvas size. + # Without pack_propagate(False), when the user defines a small figure size + # (e.g. 2x2): + # 1. Figure size that is bigger than the user's expectation. + # 2. When message_label is refreshed by mouse enter/leave, the canvas + # size will also be changed. + self.pack_propagate(False) self._buttons = {} for text, tooltip_text, image_file, callback in self.toolitems: @@ -732,7 +769,7 @@ def draw_rubberband(self, event, x0, y0, x1, y1): y1 = height - y1 self.canvas._rubberband_rect_black = ( self.canvas._tkcanvas.create_rectangle( - x0, y0, x1, y1)) + x0, y0, x1, y1, outline='black')) self.canvas._rubberband_rect_white = ( self.canvas._tkcanvas.create_rectangle( x0, y0, x1, y1, outline='white', dash=(3, 3))) diff --git a/lib/matplotlib/backends/backend_gtk3.py b/lib/matplotlib/backends/backend_gtk3.py index 888f5a770f5d..20a1a3c8f0a9 100644 --- a/lib/matplotlib/backends/backend_gtk3.py +++ b/lib/matplotlib/backends/backend_gtk3.py @@ -10,14 +10,15 @@ CloseEvent, KeyEvent, LocationEvent, MouseEvent, ResizeEvent) try: - import gi + from gi import require_version as gi_require_version except ImportError as err: raise ImportError("The GTK3 backends require PyGObject") from err try: # :raises ValueError: If module/version is already loaded, already # required, or unavailable. - gi.require_version("Gtk", "3.0") + gi_require_version("Gtk", "3.0") + gi_require_version("Gdk", "3.0") except ValueError as e: # in this case we want to re-raise as ImportError so the # auto-backend selection logic correctly skips. @@ -89,6 +90,7 @@ def __init__(self, figure=None): def destroy(self): CloseEvent("close_event", self)._process() + super().destroy() def set_cursor(self, cursor): # docstring inherited diff --git a/lib/matplotlib/backends/backend_gtk4.py b/lib/matplotlib/backends/backend_gtk4.py index cd38968779ed..95b116e9a6ba 100644 --- a/lib/matplotlib/backends/backend_gtk4.py +++ b/lib/matplotlib/backends/backend_gtk4.py @@ -9,19 +9,22 @@ KeyEvent, LocationEvent, MouseEvent, ResizeEvent, CloseEvent) try: - import gi + from gi import require_version as gi_require_version except ImportError as err: raise ImportError("The GTK4 backends require PyGObject") from err try: # :raises ValueError: If module/version is already loaded, already # required, or unavailable. - gi.require_version("Gtk", "4.0") + gi_require_version("Gtk", "4.0") + gi_require_version("Gdk", "4.0") + gi_require_version("GdkPixbuf", "2.0") except ValueError as e: # in this case we want to re-raise as ImportError so the # auto-backend selection logic correctly skips. raise ImportError(e) from e +import gi from gi.repository import Gio, GLib, Gtk, Gdk, GdkPixbuf from . import _backend_gtk from ._backend_gtk import ( # noqa: F401 # pylint: disable=W0611 diff --git a/lib/matplotlib/backends/backend_nbagg.py b/lib/matplotlib/backends/backend_nbagg.py index 4d18e1e9fb88..3ffec0910d79 100644 --- a/lib/matplotlib/backends/backend_nbagg.py +++ b/lib/matplotlib/backends/backend_nbagg.py @@ -142,6 +142,7 @@ def destroy(self): for comm in list(self.web_sockets): comm.on_close() self.clearup_closed() + super().destroy() def clearup_closed(self): """Clear up any closed Comms.""" diff --git a/lib/matplotlib/backends/backend_pdf.py b/lib/matplotlib/backends/backend_pdf.py index a75a8a86eb92..d63808eb3925 100644 --- a/lib/matplotlib/backends/backend_pdf.py +++ b/lib/matplotlib/backends/backend_pdf.py @@ -1035,11 +1035,10 @@ def _embedTeXFont(self, dvifont): fontdict['Encoding'] = self._generate_encoding(encoding) fc = fontdict['FirstChar'] = min(encoding.keys(), default=0) lc = fontdict['LastChar'] = max(encoding.keys(), default=255) - # Convert glyph widths from TeX 12.20 fixed point to 1/1000 text space units - tfm = dvifont._tfm - widths = [(1000 * metrics.tex_width) >> 20 - if (metrics := tfm.get_metrics(char)) else 0 + font_metrics = dvifont._metrics + widths = [(1000 * glyph_metrics.tex_width) >> 20 + if (glyph_metrics := font_metrics.get_metrics(char)) else 0 for char in range(fc, lc + 1)] fontdict['Widths'] = widthsObject = self.reserveObject('glyph widths') self.writeObject(widthsObject, widths) diff --git a/lib/matplotlib/backends/backend_qt.py b/lib/matplotlib/backends/backend_qt.py index dd614e516de5..0b0240c90310 100644 --- a/lib/matplotlib/backends/backend_qt.py +++ b/lib/matplotlib/backends/backend_qt.py @@ -14,7 +14,7 @@ import matplotlib.backends.qt_editor.figureoptions as figureoptions from . import qt_compat from .qt_compat import ( - QtCore, QtGui, QtWidgets, __version__, QT_API, _to_int, _isdeleted) + QtCore, QtGui, QtWidgets, QtSvg, __version__, QT_API, _to_int, _isdeleted) # SPECIAL_KEYS are Qt::Key that do *not* return their Unicode name @@ -55,7 +55,7 @@ ("Key_F8", "f8"), ("Key_F9", "f9"), ("Key_F10", "f10"), - ("Key_F10", "f11"), + ("Key_F11", "f11"), ("Key_F12", "f12"), ("Key_Super_L", "super"), ("Key_Super_R", "super"), @@ -517,7 +517,7 @@ def _draw_idle(self): if not self._draw_pending: return self._draw_pending = False - if self.height() <= 0 or self.width() <= 0: + if _isdeleted(self) or self.height() <= 0 or self.width() <= 0: return try: self.draw() @@ -674,6 +674,7 @@ def destroy(self, *args): if self.toolbar: self.toolbar.destroy() self.window.close() + super().destroy() def get_window_title(self): return self.window.windowTitle() @@ -682,6 +683,120 @@ def set_window_title(self, title): self.window.setWindowTitle(title) +class _IconEngine(QtGui.QIconEngine): + """ + Custom QIconEngine that automatically handles DPI scaling for tools icons. + + This engine provides icons on-demand with proper scaling based on the current + device pixel ratio, eliminating the need for manual refresh when DPI changes. + """ + + def __init__(self, image_path, toolbar=None): + super().__init__() + self.image_path = image_path + self.toolbar = toolbar + + def _is_dark_mode(self): + return self.toolbar.palette().color(self.toolbar.backgroundRole()).value() < 128 + + def paint(self, painter, rect, mode, state): + """Paint the icon at the requested size and state.""" + pixmap = self.pixmap(rect.size(), mode, state) + if not pixmap.isNull(): + painter.drawPixmap(rect, pixmap) + + def pixmap(self, size, mode, state): + """Generate a pixmap for the requested size, mode, and state.""" + if size.width() <= 0 or size.height() <= 0: + return QtGui.QPixmap() + + # Try SVG first, then fall back to PNG + svg_path = self.image_path.with_suffix('.svg') + if svg_path.exists(): + pixmap = self._create_pixmap_from_svg(svg_path, size) + if not pixmap.isNull(): + return pixmap + return self._create_pixmap_from_png(self.image_path, size) + + def _devicePixelRatio(self): + """Return the current device pixel ratio for the toolbar, defaulting to 1.""" + return (self.toolbar.devicePixelRatioF() or 1) if self.toolbar else 1 + + def _create_pixmap_from_svg(self, svg_path, size): + """Create a pixmap from SVG with proper scaling and dark mode support.""" + QSvgRenderer = getattr(QtSvg, "QSvgRenderer", None) + if QSvgRenderer is None: + return QtGui.QPixmap() + + svg_content = svg_path.read_bytes() + + if self._is_dark_mode(): + svg_content = svg_content.replace(b'fill:black;', b'fill:white;') + svg_content = svg_content.replace(b'stroke:black;', b'stroke:white;') + + renderer = QSvgRenderer(QtCore.QByteArray(svg_content)) + if not renderer.isValid(): + return QtGui.QPixmap() + + dpr = self._devicePixelRatio() + scaled_size = QtCore.QSize(int(size.width() * dpr), int(size.height() * dpr)) + pixmap = QtGui.QPixmap(scaled_size) + pixmap.setDevicePixelRatio(dpr) + pixmap.fill(QtCore.Qt.GlobalColor.transparent) + + painter = QtGui.QPainter() + try: + painter.begin(pixmap) + renderer.render(painter, QtCore.QRectF(0, 0, size.width(), size.height())) + finally: + if painter.isActive(): + painter.end() + + return pixmap + + def _create_pixmap_from_png(self, base_path, size): + """ + Create a pixmap from PNG with scaling and dark mode support. + + Prefer to use the *_large.png with the same name; otherwise, use base_path. + """ + large_path = base_path.with_name(base_path.stem + '_large.png') + source_pixmap = QtGui.QPixmap() + for candidate in (large_path, base_path): + if not candidate.exists(): + continue + candidate_pixmap = QtGui.QPixmap(str(candidate)) + if not candidate_pixmap.isNull(): + source_pixmap = candidate_pixmap + break + if source_pixmap.isNull(): + return source_pixmap + + dpr = self._devicePixelRatio() + + # Scale to requested size + scaled_size = QtCore.QSize(int(size.width() * dpr), int(size.height() * dpr)) + scaled_pixmap = source_pixmap.scaled( + scaled_size, + QtCore.Qt.AspectRatioMode.KeepAspectRatio, + QtCore.Qt.TransformationMode.SmoothTransformation + ) + scaled_pixmap.setDevicePixelRatio(dpr) + + if self._is_dark_mode(): + # On some platforms (e.g., macOS with Qt5 in dark mode), this may + # incorrectly return a black color instead of a light one. + # See issue #27590 for details. + icon_color = self.toolbar.palette().color(self.toolbar.foregroundRole()) + mask = scaled_pixmap.createMaskFromColor( + QtGui.QColor('black'), + QtCore.Qt.MaskMode.MaskOutColor) + scaled_pixmap.fill(icon_color) + scaled_pixmap.setMask(mask) + + return scaled_pixmap + + class NavigationToolbar2QT(NavigationToolbar2, QtWidgets.QToolBar): toolitems = [*NavigationToolbar2.toolitems] toolitems.insert( @@ -739,25 +854,16 @@ def _icon(self, name): """ Construct a `.QIcon` from an image file *name*, including the extension and relative to Matplotlib's "images" data directory. + + Uses _IconEngine for automatic DPI scaling. """ - # use a high-resolution icon with suffix '_large' if available - # note: user-provided icons may not have '_large' versions + # Get the image path path_regular = cbook._get_data_path('images', name) - path_large = path_regular.with_name( - path_regular.name.replace('.png', '_large.png')) - filename = str(path_large if path_large.exists() else path_regular) - - pm = QtGui.QPixmap(filename) - pm.setDevicePixelRatio( - self.devicePixelRatioF() or 1) # rarely, devicePixelRatioF=0 - if self.palette().color(self.backgroundRole()).value() < 128: - icon_color = self.palette().color(self.foregroundRole()) - mask = pm.createMaskFromColor( - QtGui.QColor('black'), - QtCore.Qt.MaskMode.MaskOutColor) - pm.fill(icon_color) - pm.setMask(mask) - return QtGui.QIcon(pm) + + # Create icon using our custom engine for automatic DPI handling + engine = _IconEngine(path_regular, self) + return QtGui.QIcon(engine) + def edit_parameters(self): axes = self.canvas.figure.get_axes() diff --git a/lib/matplotlib/backends/backend_webagg.py b/lib/matplotlib/backends/backend_webagg.py index dfc5747ef77c..e4808e8d0d32 100644 --- a/lib/matplotlib/backends/backend_webagg.py +++ b/lib/matplotlib/backends/backend_webagg.py @@ -21,14 +21,12 @@ import threading try: - import tornado + import tornado.web + import tornado.ioloop + import tornado.websocket except ImportError as err: raise RuntimeError("The WebAgg backend requires Tornado.") from err -import tornado.web -import tornado.ioloop -import tornado.websocket - import matplotlib as mpl from matplotlib.backend_bases import _Backend from matplotlib._pylab_helpers import Gcf diff --git a/lib/matplotlib/backends/backend_wx.py b/lib/matplotlib/backends/backend_wx.py index f83a69d8361e..0acb4499ed87 100644 --- a/lib/matplotlib/backends/backend_wx.py +++ b/lib/matplotlib/backends/backend_wx.py @@ -30,7 +30,7 @@ from matplotlib.transforms import Affine2D import wx -import wx.svg +import wx.svg # noqa: F401 _log = logging.getLogger(__name__) @@ -1012,6 +1012,7 @@ def destroy(self, *args): # As this can be called from non-GUI thread from plt.close use # wx.CallAfter to ensure thread safety. wx.CallAfter(frame.Close) + super().destroy() def full_screen_toggle(self): # docstring inherited diff --git a/lib/matplotlib/backends/qt_compat.py b/lib/matplotlib/backends/qt_compat.py index b57a98b1138a..8f666c734b06 100644 --- a/lib/matplotlib/backends/qt_compat.py +++ b/lib/matplotlib/backends/qt_compat.py @@ -64,11 +64,11 @@ def _setup_pyqt5plus(): - global QtCore, QtGui, QtWidgets, __version__ + global QtCore, QtGui, QtWidgets, QtSvg, __version__ global _isdeleted, _to_int if QT_API == QT_API_PYQT6: - from PyQt6 import QtCore, QtGui, QtWidgets, sip + from PyQt6 import QtCore, QtGui, QtWidgets, sip, QtSvg __version__ = QtCore.PYQT_VERSION_STR QtCore.Signal = QtCore.pyqtSignal QtCore.Slot = QtCore.pyqtSlot @@ -76,7 +76,7 @@ def _setup_pyqt5plus(): _isdeleted = sip.isdeleted _to_int = operator.attrgetter('value') elif QT_API == QT_API_PYSIDE6: - from PySide6 import QtCore, QtGui, QtWidgets, __version__ + from PySide6 import QtCore, QtGui, QtWidgets, QtSvg, __version__ import shiboken6 def _isdeleted(obj): return not shiboken6.isValid(obj) if parse_version(__version__) >= parse_version('6.4'): @@ -84,7 +84,7 @@ def _isdeleted(obj): return not shiboken6.isValid(obj) else: _to_int = int elif QT_API == QT_API_PYQT5: - from PyQt5 import QtCore, QtGui, QtWidgets + from PyQt5 import QtCore, QtGui, QtWidgets, QtSvg import sip __version__ = QtCore.PYQT_VERSION_STR QtCore.Signal = QtCore.pyqtSignal @@ -93,7 +93,7 @@ def _isdeleted(obj): return not shiboken6.isValid(obj) _isdeleted = sip.isdeleted _to_int = int elif QT_API == QT_API_PYSIDE2: - from PySide2 import QtCore, QtGui, QtWidgets, __version__ + from PySide2 import QtCore, QtGui, QtWidgets, QtSvg, __version__ try: from PySide2 import shiboken2 except ImportError: diff --git a/lib/matplotlib/backends/qt_editor/figureoptions.py b/lib/matplotlib/backends/qt_editor/figureoptions.py index 9d31fa9ced2c..9c57b7c4e968 100644 --- a/lib/matplotlib/backends/qt_editor/figureoptions.py +++ b/lib/matplotlib/backends/qt_editor/figureoptions.py @@ -194,7 +194,7 @@ def apply_callback(data): raise ValueError("Unexpected field") title = general.pop(0) - axes.set_title(title) + axes.title.set_text(title) generate_legend = general.pop() for i, (name, axis) in enumerate(axis_map.items()): diff --git a/lib/matplotlib/backends/web_backend/js/mpl.js b/lib/matplotlib/backends/web_backend/js/mpl.js index f2bfc43bd0e4..7745cbcf1e98 100644 --- a/lib/matplotlib/backends/web_backend/js/mpl.js +++ b/lib/matplotlib/backends/web_backend/js/mpl.js @@ -325,7 +325,6 @@ mpl.figure.prototype._init_canvas = function () { canvas_div.appendChild(rubberband_canvas); this.rubberband_context = rubberband_canvas.getContext('2d'); - this.rubberband_context.strokeStyle = '#000000'; this._resize_canvas = function (width, height, forward) { if (forward) { @@ -469,19 +468,38 @@ mpl.figure.prototype.handle_rubberband = function (fig, msg) { y0 = Math.floor(y0) + 0.5; x1 = Math.floor(x1) + 0.5; y1 = Math.floor(y1) + 0.5; - var min_x = Math.min(x0, x1); - var min_y = Math.min(y0, y1); - var width = Math.abs(x1 - x0); - var height = Math.abs(y1 - y0); - fig.rubberband_context.clearRect( + var ctx = fig.rubberband_context; + ctx.clearRect( 0, 0, fig.canvas.width / fig.ratio, fig.canvas.height / fig.ratio ); - fig.rubberband_context.strokeRect(min_x, min_y, width, height); + var drawRubberband = function () { + // Draw the lines from x0, y0 towards x1, y1 so that the + // dashes don't "jump" when moving the zoom box. + ctx.beginPath(); + ctx.moveTo(x0, y0); + ctx.lineTo(x0, y1); + ctx.moveTo(x0, y0); + ctx.lineTo(x1, y0); + ctx.moveTo(x0, y1); + ctx.lineTo(x1, y1); + ctx.moveTo(x1, y0); + ctx.lineTo(x1, y1); + ctx.stroke(); + }; + + fig.rubberband_context.lineWidth = 1; + fig.rubberband_context.setLineDash([3]); + fig.rubberband_context.lineDashOffset = 0; + fig.rubberband_context.strokeStyle = '#000000'; + drawRubberband(); + fig.rubberband_context.strokeStyle = '#ffffff'; + fig.rubberband_context.lineDashOffset = 3; + drawRubberband(); }; mpl.figure.prototype.handle_figure_label = function (fig, msg) { diff --git a/lib/matplotlib/cbook.py b/lib/matplotlib/cbook.py index a09780965b0c..a2a9e54792d9 100644 --- a/lib/matplotlib/cbook.py +++ b/lib/matplotlib/cbook.py @@ -29,7 +29,7 @@ from numpy import VisibleDeprecationWarning import matplotlib -from matplotlib import _api, _c_internal_utils +from matplotlib import _api, _c_internal_utils, mlab class _ExceptionInfo: @@ -43,16 +43,20 @@ class _ExceptionInfo: users and result in incorrect tracebacks. """ - def __init__(self, cls, *args): + def __init__(self, cls, *args, notes=None): self._cls = cls self._args = args + self._notes = notes if notes is not None else [] @classmethod def from_exception(cls, exc): - return cls(type(exc), *exc.args) + return cls(type(exc), *exc.args, notes=getattr(exc, "__notes__", [])) def to_exception(self): - return self._cls(*self._args) + exc = self._cls(*self._args) + for note in self._notes: + exc.add_note(note) + return exc def _get_running_interactive_framework(): @@ -690,7 +694,21 @@ def safe_masked_invalid(x, copy=False): try: xm = np.ma.masked_where(~(np.isfinite(x)), x, copy=False) except TypeError: - return x + if len(x.dtype.descr) == 1: + # Arrays with dtype 'object' get returned here. + # For example the 'c' kwarg of scatter, which supports multiple types. + # `plt.scatter([3, 4], [2, 5], c=[(1, 0, 0), 'y'])` + return x + else: + # In case of a dtype with multiple fields + # for example image data using a MultiNorm + try: + mask = np.empty(x.shape, dtype=np.dtype('bool, '*len(x.dtype.descr))) + for dd, dm in zip(x.dtype.descr, mask.dtype.descr): + mask[dm[0]] = ~np.isfinite(x[dd[0]]) + xm = np.ma.array(x, mask=mask, copy=False) + except TypeError: + return x return xm @@ -1430,7 +1448,7 @@ def _reshape_2D(X, name): return result -def violin_stats(X, method, points=100, quantiles=None): +def violin_stats(X, method=("GaussianKDE", "scott"), points=100, quantiles=None): """ Return a list of dictionaries of data which can be used to draw a series of violin plots. @@ -1439,7 +1457,7 @@ def violin_stats(X, method, points=100, quantiles=None): dictionary. Users can skip this function and pass a user-defined set of dictionaries - with the same keys to `~.axes.Axes.violinplot` instead of using Matplotlib + with the same keys to `~.axes.Axes.violin` instead of using Matplotlib to do the calculations. See the *Returns* section below for the keys that must be present in the dictionaries. @@ -1449,11 +1467,26 @@ def violin_stats(X, method, points=100, quantiles=None): Sample data that will be used to produce the gaussian kernel density estimates. Must have 2 or fewer dimensions. - method : callable + method : (name, bw_method) or callable, The method used to calculate the kernel density estimate for each - column of data. When called via ``method(v, coords)``, it should - return a vector of the values of the KDE evaluated at the values - specified in coords. + column of data. Valid values: + + - a tuple of the form ``(name, bw_method)`` where *name* currently must + always be ``"GaussianKDE"`` and *bw_method* is the method used to + calculate the estimator bandwidth. Supported values are 'scott', + 'silverman' or a float or a callable. If a float, this will be used + directly as `!kde.factor`. If a callable, it should take a + `matplotlib.mlab.GaussianKDE` instance as its only parameter and + return a float. + + - a callable with the signature :: + + def method(data: ndarray, coords: ndarray) -> ndarray + + It should return the KDE of *data* evaluated at *coords*. + + .. versionadded:: 3.11 + Support for ``(name, bw_method)`` tuple. points : int, default: 100 Defines the number of points to evaluate each of the gaussian kernel @@ -1481,6 +1514,20 @@ def violin_stats(X, method, points=100, quantiles=None): - max: The maximum value for this column of data. - quantiles: The quantile values for this column of data. """ + if isinstance(method, tuple): + name, bw_method = method + if name != "GaussianKDE": + raise ValueError(f"Unknown KDE method name {name!r}. The only supported " + 'named method is "GaussianKDE"') + + def _kde_method(x, coords): + # fallback gracefully if the vector contains only one value + if np.all(x[0] == x): + return (x[0] == coords).astype(float) + kde = mlab.GaussianKDE(x, bw_method) + return kde.evaluate(coords) + + method = _kde_method # List of dictionaries describing each of the violins. vpstats = [] diff --git a/lib/matplotlib/cbook.pyi b/lib/matplotlib/cbook.pyi index ad14841463e8..f7959a6fd0bb 100644 --- a/lib/matplotlib/cbook.pyi +++ b/lib/matplotlib/cbook.pyi @@ -133,7 +133,10 @@ ls_mapper_r: dict[str, str] def contiguous_regions(mask: ArrayLike) -> list[np.ndarray]: ... def is_math_text(s: str) -> bool: ... def violin_stats( - X: ArrayLike, method: Callable, points: int = ..., quantiles: ArrayLike | None = ... + X: ArrayLike, + method: tuple[Literal["GaussianKDE"], Literal["scott", "silverman"] | float | Callable] | Callable = ..., + points: int = ..., + quantiles: ArrayLike | None = ... ) -> list[dict[str, Any]]: ... def pts_to_prestep(x: ArrayLike, *args: ArrayLike) -> np.ndarray: ... def pts_to_poststep(x: ArrayLike, *args: ArrayLike) -> np.ndarray: ... diff --git a/lib/matplotlib/cm.py b/lib/matplotlib/cm.py index 299059177a20..497f0c2debdf 100644 --- a/lib/matplotlib/cm.py +++ b/lib/matplotlib/cm.py @@ -239,32 +239,3 @@ def get_cmap(self, cmap): _multivar_colormaps = ColormapRegistry(multivar_cmaps) _bivar_colormaps = ColormapRegistry(bivar_cmaps) - - -def _ensure_cmap(cmap): - """ - Ensure that we have a `.Colormap` object. - - For internal use to preserve type stability of errors. - - Parameters - ---------- - cmap : None, str, Colormap - - - if a `Colormap`, return it - - if a string, look it up in mpl.colormaps - - if None, look up the default color map in mpl.colormaps - - Returns - ------- - Colormap - - """ - if isinstance(cmap, colors.Colormap): - return cmap - cmap_name = mpl._val_or_rc(cmap, "image.cmap") - # use check_in_list to ensure type stability of the exception raised by - # the internal usage of this (ValueError vs KeyError) - if cmap_name not in _colormaps: - _api.check_in_list(sorted(_colormaps), cmap=cmap_name) - return mpl.colormaps[cmap_name] diff --git a/lib/matplotlib/collections.py b/lib/matplotlib/collections.py index ec6d40805da0..684e15cdf854 100644 --- a/lib/matplotlib/collections.py +++ b/lib/matplotlib/collections.py @@ -283,7 +283,7 @@ def get_datalim(self, transData): offsets = self.get_offsets() - if any(transform.contains_branch_seperately(transData)): + if any(transform.contains_branch_separately(transData)): # collections that are just in data units (like quiver) # can properly have the axes limits set by their shape + # offset. LineCollections that have no offsets can diff --git a/lib/matplotlib/colorizer.py b/lib/matplotlib/colorizer.py index a94790979078..120e816fed45 100644 --- a/lib/matplotlib/colorizer.py +++ b/lib/matplotlib/colorizer.py @@ -24,7 +24,7 @@ import numpy as np from numpy import ma -from matplotlib import _api, colors, cbook, scale, artist +from matplotlib import _api, colors, cbook, artist, scale import matplotlib as mpl mpl._docstring.interpd.register( @@ -78,7 +78,7 @@ def _scale_norm(self, norm, vmin, vmax, A): raise ValueError( "Passing a Normalize instance simultaneously with " "vmin/vmax is not supported. Please pass vmin/vmax " - "directly to the norm when creating it.") + "as arguments to the norm object when creating it") # always resolve the autoscaling so we have concrete limits # rather than deferring to draw time. @@ -90,19 +90,7 @@ def norm(self): @norm.setter def norm(self, norm): - _api.check_isinstance((colors.Norm, str, None), norm=norm) - if norm is None: - norm = colors.Normalize() - elif isinstance(norm, str): - try: - scale_cls = scale._scale_mapping[norm] - except KeyError: - raise ValueError( - "Invalid norm str name; the following values are " - f"supported: {', '.join(scale._scale_mapping)}" - ) from None - norm = _auto_norm_from_scale(scale_cls)() - + norm = _ensure_norm(norm, n_components=self.cmap.n_variates) if norm is self.norm: # We aren't updating anything return @@ -186,7 +174,7 @@ def _pass_image_data(x, alpha=None, bytes=False, norm=True): if norm and (xx.max() > 1 or xx.min() < 0): raise ValueError("Floating point image RGB values " - "must be in the 0..1 range.") + "must be in the [0,1] range") if bytes: xx = (xx * 255).astype(np.uint8) elif xx.dtype == np.uint8: @@ -231,10 +219,13 @@ def _set_cmap(self, cmap): ---------- cmap : `.Colormap` or str or None """ - # bury import to avoid circular imports - from matplotlib import cm in_init = self._cmap is None - self._cmap = cm._ensure_cmap(cmap) + cmap_obj = _ensure_cmap(cmap, accept_multivariate=True) + if not in_init and self.norm.n_components != cmap_obj.n_variates: + raise ValueError(f"The colormap {cmap} does not support " + f"{self.norm.n_components} variates as required by " + f"the {type(self.norm)} on this Colorizer") + self._cmap = cmap_obj if not in_init: self.changed() # Things are not set up properly yet. @@ -255,25 +246,25 @@ def set_clim(self, vmin=None, vmax=None): vmin, vmax : float The limits. - The limits may also be passed as a tuple (*vmin*, *vmax*) as a - single positional argument. + For scalar data, the limits may also be passed as a + tuple (*vmin*, *vmax*) single positional argument. .. ACCEPTS: (vmin: float, vmax: float) """ - # If the norm's limits are updated self.changed() will be called - # through the callbacks attached to the norm, this causes an inconsistent - # state, to prevent this blocked context manager is used - if vmax is None: - try: - vmin, vmax = vmin - except (TypeError, ValueError): - pass + if self.norm.n_components == 1: + if vmax is None: + try: + vmin, vmax = vmin + except (TypeError, ValueError): + pass orig_vmin_vmax = self.norm.vmin, self.norm.vmax # Blocked context manager prevents callbacks from being triggered # until both vmin and vmax are updated with self.norm.callbacks.blocked(signal='changed'): + # Since the @vmin/vmax.setter invokes colors._sanitize_extrema() + # to sanitize the input, the input is not sanitized here if vmin is not None: self.norm.vmin = vmin if vmax is not None: @@ -476,31 +467,51 @@ def _format_cursor_data_override(self, data): # Note if cm.ScalarMappable is depreciated, this functionality should be # implemented as format_cursor_data() on ColorizingArtist. - n = self.cmap.N - if np.ma.getmask(data): + if np.ma.getmask(data) or data is None: + # NOTE: for multivariate data, if *any* of the fields are masked, + # "[]" is returned here return "[]" - normed = self.norm(data) + + if isinstance(self.norm, colors.MultiNorm): + norms = self.norm.norms + if isinstance(self.cmap, colors.BivarColormap): + n_s = (self.cmap.N, self.cmap.M) + else: # colors.MultivarColormap + n_s = [part.N for part in self.cmap] + else: # colors.Colormap + norms = [self.norm] + data = [data] + n_s = [self.cmap.N] + + os = [f"{d:-#.{self._sig_digits_from_norm(no, d, n)}g}" + for no, d, n in zip(norms, data, n_s)] + return f"[{', '.join(os)}]" + + @staticmethod + def _sig_digits_from_norm(norm, data, n): + # Determines the number of significant digits + # to use for a number given a norm, and n, where n is the + # number of colors in the colormap. + normed = norm(data) if np.isfinite(normed): - if isinstance(self.norm, colors.BoundaryNorm): + if isinstance(norm, colors.BoundaryNorm): # not an invertible normalization mapping - cur_idx = np.argmin(np.abs(self.norm.boundaries - data)) + cur_idx = np.argmin(np.abs(norm.boundaries - data)) neigh_idx = max(0, cur_idx - 1) # use max diff to prevent delta == 0 - delta = np.diff( - self.norm.boundaries[neigh_idx:cur_idx + 2] - ).max() - elif self.norm.vmin == self.norm.vmax: + delta = np.diff(norm.boundaries[neigh_idx:cur_idx + 2]).max() + elif norm.vmin == norm.vmax: # singular norms, use delta of 10% of only value - delta = np.abs(self.norm.vmin * .1) + delta = np.abs(norm.vmin * .1) else: # Midpoints of neighboring color intervals. - neighbors = self.norm.inverse( - (int(normed * n) + np.array([0, 1])) / n) + neighbors = norm.inverse((int(normed * n) + np.array([0, 1])) / n) delta = abs(neighbors - data).max() + g_sig_digits = cbook._g_sig_digits(data, delta) else: g_sig_digits = 3 # Consistent with default below. - return f"[{data:-#.{g_sig_digits}g}]" + return g_sig_digits class _ScalarMappable(_ColorizerInterface): @@ -563,11 +574,19 @@ def set_array(self, A): self._A = None return + A = _ensure_multivariate_data(A, self.norm.n_components) + A = cbook.safe_masked_invalid(A, copy=True) if not np.can_cast(A.dtype, float, "same_kind"): - raise TypeError(f"Image data of dtype {A.dtype} cannot be " - "converted to float") + if A.dtype.fields is None: + raise TypeError(f"Image data of dtype {A.dtype} cannot be " + f"converted to float") + else: + for key in A.dtype.fields: + if not np.can_cast(A[key].dtype, float, "same_kind"): + raise TypeError(f"Image data of dtype {A.dtype} cannot be " + f"converted to a sequence of floats") self._A = A if not self.norm.scaled(): self._colorizer.autoscale_None(A) @@ -615,6 +634,15 @@ def _get_colorizer(cmap, norm, colorizer): cmap : str or `~matplotlib.colors.Colormap`, default: :rc:`image.cmap` The Colormap instance or registered colormap name used to map scalar data to colors.""", + multi_cmap_doc="""\ +cmap : str, `~matplotlib.colors.Colormap`, `~matplotlib.colors.BivarColormap`\ + or `~matplotlib.colors.MultivarColormap`, default: :rc:`image.cmap` + The Colormap instance or registered colormap name used to map + data values to colors. + + Multivariate data is only accepted if a multivariate colormap + (`~matplotlib.colors.BivarColormap` or `~matplotlib.colors.MultivarColormap`) + is used.""", norm_doc="""\ norm : str or `~matplotlib.colors.Normalize`, optional The normalization method used to scale scalar data to the [0, 1] range @@ -629,6 +657,21 @@ def _get_colorizer(cmap, norm, colorizer): list of available scales, call `matplotlib.scale.get_scale_names()`. In that case, a suitable `.Normalize` subclass is dynamically generated and instantiated.""", + multi_norm_doc="""\ +norm : str, `~matplotlib.colors.Normalize` or list, optional + The normalization method used to scale data to the [0, 1] range + before mapping to colors using *cmap*. By default, a linear scaling is + used, mapping the lowest value to 0 and the highest to 1. + This can be one of the following: + - An instance of `.Normalize` or one of its subclasses + (see :ref:`colormapnorms`). + - A scale name, i.e. one of "linear", "log", "symlog", "logit", etc. For a + list of available scales, call `matplotlib.scale.get_scale_names()`. + In this case, a suitable `.Normalize` subclass is dynamically generated + and instantiated. + - A list of scale names or `.Normalize` objects matching the number of + variates in the colormap, for use with `~matplotlib.colors.BivarColormap` + or `~matplotlib.colors.MultivarColormap`, i.e. ``["linear", "log"]``.""", vmin_vmax_doc="""\ vmin, vmax : float, optional When using scalar data and no explicit *norm*, *vmin* and *vmax* define @@ -636,6 +679,17 @@ def _get_colorizer(cmap, norm, colorizer): the complete value range of the supplied data. It is an error to use *vmin*/*vmax* when a *norm* instance is given (but using a `str` *norm* name together with *vmin*/*vmax* is acceptable).""", + multi_vmin_vmax_doc="""\ +vmin, vmax : float or list, optional + When using scalar data and no explicit *norm*, *vmin* and *vmax* define + the data range that the colormap covers. By default, the colormap covers + the complete value range of the supplied data. It is an error to use + *vmin*/*vmax* when a *norm* instance is given (but using a `str` *norm* + name together with *vmin*/*vmax* is acceptable). + + A list of values (vmin or vmax) can be used to define independent limits + for each variate when using a `~matplotlib.colors.BivarColormap` or + `~matplotlib.colors.MultivarColormap`.""", ) @@ -701,3 +755,155 @@ def _auto_norm_from_scale(scale_cls): norm = colors.make_norm_from_scale(scale_cls)( colors.Normalize)() return type(norm) + + +def _ensure_norm(norm, n_components=1): + if n_components == 1: + _api.check_isinstance((colors.Norm, str, None), norm=norm) + if norm is None: + norm = colors.Normalize() + elif isinstance(norm, str): + scale_cls = _api.check_getitem(scale._scale_mapping, norm=norm) + return _auto_norm_from_scale(scale_cls)() + return norm + elif n_components > 1: + if not np.iterable(norm): + _api.check_isinstance((colors.MultiNorm, None, tuple), norm=norm) + if norm is None: + norm = colors.MultiNorm(['linear']*n_components) + else: # iterable, i.e. multiple strings or Normalize objects + norm = colors.MultiNorm(norm) + if isinstance(norm, colors.MultiNorm) and norm.n_components == n_components: + return norm + raise ValueError( + f"Invalid norm for multivariate colormap with {n_components} inputs") + else: # n_components == 0 + raise ValueError( + "Invalid cmap. A colorizer object must have a cmap with `n_variates` >= 1") + + +def _ensure_cmap(cmap, accept_multivariate=False): + """ + Ensure that we have a `.Colormap` object. + + For internal use to preserve type stability of errors. + + Parameters + ---------- + cmap : None, str, Colormap + + - if a `~matplotlib.colors.Colormap`, + `~matplotlib.colors.MultivarColormap` or + `~matplotlib.colors.BivarColormap`, + return it + - if a string, look it up in three corresponding databases + when not found: raise an error based on the expected shape + - if None, look up the default color map in mpl.colormaps + accept_multivariate : bool, default False + - if False, accept only Colormap, string in mpl.colormaps or None + + Returns + ------- + Colormap + + """ + if accept_multivariate: + types = (colors.Colormap, colors.BivarColormap, colors.MultivarColormap) + mappings = (mpl.colormaps, mpl.multivar_colormaps, mpl.bivar_colormaps) + else: + types = (colors.Colormap, ) + mappings = (mpl.colormaps, ) + + if isinstance(cmap, types): + return cmap + + cmap_name = mpl._val_or_rc(cmap, "image.cmap") + + for mapping in mappings: + if cmap_name in mapping: + return mapping[cmap_name] + + # this error message is a variant of _api.check_in_list but gives + # additional hints as to how to access multivariate colormaps + + raise ValueError(f"{cmap!r} is not a valid value for cmap" + "; supported values for scalar colormaps are " + f"{', '.join(map(repr, sorted(mpl.colormaps)))}\n" + "See `matplotlib.bivar_colormaps()` and" + " `matplotlib.multivar_colormaps()` for" + " bivariate and multivariate colormaps") + + +def _ensure_multivariate_data(data, n_components): + """ + Ensure that the data has dtype with n_components. + Input data of shape (n_components, n, m) is converted to an array of shape + (n, m) with data type np.dtype(f'{data.dtype}, ' * n_components) + Complex data is returned as a view with dtype np.dtype('float64, float64') + or np.dtype('float32, float32') + If n_components is 1 and data is not of type np.ndarray (i.e. PIL.Image), + the data is returned unchanged. + If data is None, the function returns None + + Parameters + ---------- + n_components : int + Number of variates in the data. + data : np.ndarray, PIL.Image or None + + Returns + ------- + np.ndarray, PIL.Image or None + """ + + if isinstance(data, np.ndarray): + if len(data.dtype.descr) == n_components: + # pass scalar data + # and already formatted data + return data + elif data.dtype in [np.complex64, np.complex128]: + if n_components != 2: + raise ValueError("Invalid data entry for multivariate data. " + "Complex numbers are incompatible with " + f"{n_components} variates.") + + # pass complex data + if data.dtype == np.complex128: + dt = np.dtype('float64, float64') + else: + dt = np.dtype('float32, float32') + + reconstructed = np.ma.array(np.ma.getdata(data).view(dt)) + if np.ma.is_masked(data): + for descriptor in dt.descr: + reconstructed[descriptor[0]][data.mask] = np.ma.masked + return reconstructed + + if n_components > 1 and len(data) == n_components: + # convert data from shape (n_components, n, m) + # to (n, m) with a new dtype + data = [np.ma.array(part, copy=False) for part in data] + dt = np.dtype(', '.join([f'{part.dtype}' for part in data])) + fields = [descriptor[0] for descriptor in dt.descr] + reconstructed = np.ma.empty(data[0].shape, dtype=dt) + for i, f in enumerate(fields): + if data[i].shape != reconstructed.shape: + raise ValueError("For multivariate data all variates must have same " + f"shape, not {data[0].shape} and {data[i].shape}") + reconstructed[f] = data[i] + if np.ma.is_masked(data[i]): + reconstructed[f][data[i].mask] = np.ma.masked + return reconstructed + + if n_components == 1: + # PIL.Image gets passed here + return data + + elif n_components == 2: + raise ValueError("Invalid data entry for multivariate data. The data" + " must contain complex numbers, or have a first dimension 2," + " or be of a dtype with 2 fields") + else: + raise ValueError("Invalid data entry for multivariate data. The shape" + f" of the data must have a first dimension {n_components}" + f" or be of a dtype with {n_components} fields") diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index f60c8eb48134..628d9f0acf77 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -805,8 +805,7 @@ def _get_rgba_and_mask(self, X, alpha=None, bytes=False): mask : np.ndarray Boolean array with True where the input is ``np.nan`` or masked. """ - if not self._isinit: - self._init() + self._ensure_inited() xa = np.array(X, copy=True) if not xa.dtype.isnative: @@ -863,59 +862,60 @@ def __eq__(self, other): self.colorbar_extend != other.colorbar_extend): return False # To compare lookup tables the Colormaps have to be initialized - if not self._isinit: - self._init() - if not other._isinit: - other._init() + self._ensure_inited() + other._ensure_inited() return np.array_equal(self._lut, other._lut) def get_bad(self): """Get the color for masked values.""" - if not self._isinit: - self._init() + self._ensure_inited() return np.array(self._lut[self._i_bad]) + @_api.deprecated( + "3.11", + pending=True, + alternative="cmap.with_extremes(bad=...) or Colormap(bad=...)") def set_bad(self, color='k', alpha=None): """Set the color for masked values.""" - self._rgba_bad = to_rgba(color, alpha) - if self._isinit: - self._set_extremes() + self._set_extremes(bad=(color, alpha)) def get_under(self): """Get the color for low out-of-range values.""" - if not self._isinit: - self._init() + self._ensure_inited() return np.array(self._lut[self._i_under]) + @_api.deprecated( + "3.11", + pending=True, + alternative="cmap.with_extremes(under=...) or Colormap(under=...)") def set_under(self, color='k', alpha=None): """Set the color for low out-of-range values.""" - self._rgba_under = to_rgba(color, alpha) - if self._isinit: - self._set_extremes() + self._set_extremes(under=(color, alpha)) def get_over(self): """Get the color for high out-of-range values.""" - if not self._isinit: - self._init() + self._ensure_inited() return np.array(self._lut[self._i_over]) + @_api.deprecated( + "3.11", + pending=True, + alternative="cmap.with_extremes(over=...) or Colormap(over=...)") def set_over(self, color='k', alpha=None): """Set the color for high out-of-range values.""" - self._rgba_over = to_rgba(color, alpha) - if self._isinit: - self._set_extremes() + self._set_extremes(over=(color, alpha)) + @_api.deprecated( + "3.11", + pending=True, + alternative="cmap.with_extremes(bad=..., under=..., over=...) or " + "Colormap(bad=..., under=..., over=...)") def set_extremes(self, *, bad=None, under=None, over=None): """ Set the colors for masked (*bad*) values and, when ``norm.clip = False``, low (*under*) and high (*over*) out-of-range values. """ - if bad is not None: - self.set_bad(bad) - if under is not None: - self.set_under(under) - if over is not None: - self.set_over(over) + self._set_extremes(bad=bad, under=under, over=over) def with_extremes(self, *, bad=None, under=None, over=None): """ @@ -924,10 +924,26 @@ def with_extremes(self, *, bad=None, under=None, over=None): out-of-range values, have been set accordingly. """ new_cm = self.copy() - new_cm.set_extremes(bad=bad, under=under, over=over) + new_cm._set_extremes(bad=bad, under=under, over=over) return new_cm - def _set_extremes(self): + def _set_extremes(self, bad=None, under=None, over=None): + """ + Set the colors for masked (*bad*) and out-of-range (*under* and *over*) values. + + Parameters that are None are left unchanged. + """ + if bad is not None: + self._rgba_bad = to_rgba(bad) + if under is not None: + self._rgba_under = to_rgba(under) + if over is not None: + self._rgba_over = to_rgba(over) + if self._isinit: + self._update_lut_extremes() + + def _update_lut_extremes(self): + """Ensure than an existing lookup table has the correct extreme values.""" if self._rgba_under: self._lut[self._i_under] = self._rgba_under else: @@ -952,8 +968,7 @@ def with_alpha(self, alpha): if not 0 <= alpha <= 1: raise ValueError("'alpha' must be between 0 and 1, inclusive") new_cm = self.copy() - if not new_cm._isinit: - new_cm._init() + new_cm._ensure_inited() new_cm._lut[:, 3] = alpha return new_cm @@ -961,10 +976,13 @@ def _init(self): """Generate the lookup table, ``self._lut``.""" raise NotImplementedError("Abstract class only") - def is_gray(self): - """Return whether the colormap is grayscale.""" + def _ensure_inited(self): if not self._isinit: self._init() + + def is_gray(self): + """Return whether the colormap is grayscale.""" + self._ensure_inited() return (np.all(self._lut[:, 0] == self._lut[:, 1]) and np.all(self._lut[:, 0] == self._lut[:, 2])) @@ -1154,7 +1172,7 @@ def _init(self): self._lut[:-3, 3] = _create_lookup_table( self.N, self._segmentdata['alpha'], 1) self._isinit = True - self._set_extremes() + self._update_lut_extremes() def set_gamma(self, gamma): """Set a new gamma value and regenerate colormap.""" @@ -1322,7 +1340,7 @@ class ListedColormap(Colormap): "and will be removed in %(removal)s. Please ensure the list " "of passed colors is the required length instead." ) - def __init__(self, colors, name='from_list', N=None, *, + def __init__(self, colors, name='unnamed', N=None, *, bad=None, under=None, over=None): if N is None: self.colors = colors @@ -1346,7 +1364,7 @@ def _init(self): self._lut = np.zeros((self.N + 3, 4), float) self._lut[:-3] = to_rgba_array(self.colors) self._isinit = True - self._set_extremes() + self._update_lut_extremes() @property def monochrome(self): @@ -1358,9 +1376,7 @@ def monochrome(self): # TODO: It's a separate discussion whether we need this property on # colormaps at all (at least as public API). It's a very special edge # case and we only use it for contours internally. - if not self._isinit: - self._init() - + self._ensure_inited() return self.N <= 1 or np.all(self._lut[0] == self._lut[1:self.N]) def resampled(self, lutsize): @@ -1615,14 +1631,16 @@ def with_extremes(self, *, bad=None, under=None, over=None): f" i.e. be of length {len(new_cm)}.") else: for c, b in zip(new_cm, under): - c.set_under(b) + # in-place change is ok, since we've just created c as a copy + c._set_extremes(under=b) if over is not None: if not np.iterable(over) or len(over) != len(new_cm): raise ValueError("*over* must contain a color for each scalar colormap" f" i.e. be of length {len(new_cm)}.") else: for c, b in zip(new_cm, over): - c.set_over(b) + # in-place change is ok, since we've just created c as a copy + c._set_extremes(over=b) return new_cm @property @@ -2071,26 +2089,27 @@ def __getitem__(self, item): """Creates and returns a colorbar along the selected axis""" if not self._isinit: self._init() + extremes = ( + dict(bad=self._rgba_bad, over=self._rgba_outside, under=self._rgba_outside) + if self.shape in ['ignore', 'circleignore'] + else dict(bad=self._rgba_bad) + ) if item == 0: origin_1_as_int = int(self._origin[1]*self.M) if origin_1_as_int > self.M-1: origin_1_as_int = self.M-1 one_d_lut = self._lut[:, origin_1_as_int] - new_cmap = ListedColormap(one_d_lut, name=f'{self.name}_0') + new_cmap = ListedColormap(one_d_lut, name=f'{self.name}_0', **extremes) elif item == 1: origin_0_as_int = int(self._origin[0]*self.N) if origin_0_as_int > self.N-1: origin_0_as_int = self.N-1 one_d_lut = self._lut[origin_0_as_int, :] - new_cmap = ListedColormap(one_d_lut, name=f'{self.name}_1') + new_cmap = ListedColormap(one_d_lut, name=f'{self.name}_1', **extremes) else: raise KeyError(f"only 0 or 1 are" f" valid keys for BivarColormap, not {item!r}") - new_cmap._rgba_bad = self._rgba_bad - if self.shape in ['ignore', 'circleignore']: - new_cmap.set_over(self._rgba_outside) - new_cmap.set_under(self._rgba_outside) return new_cmap def _repr_png_(self): @@ -3612,11 +3631,10 @@ def rgb_to_hsv(arr): f"shape {arr.shape} was found.") in_shape = arr.shape - arr = np.array( - arr, copy=False, - dtype=np.promote_types(arr.dtype, np.float32), # Don't work on ints. - ndmin=2, # In case input was 1D. - ) + # ensure numerics are done at least on float32; ints are cast as well + arr = np.asarray(arr, dtype=np.promote_types(arr.dtype, np.float32)) + if arr.ndim == 1: + arr = np.expand_dims(arr, axis=0) # ensure arr is 2D out = np.zeros_like(arr) arr_max = arr.max(-1) diff --git a/lib/matplotlib/container.py b/lib/matplotlib/container.py index fcf2e6016db9..96b14cfd26f7 100644 --- a/lib/matplotlib/container.py +++ b/lib/matplotlib/container.py @@ -148,6 +148,78 @@ def __init__(self, lines, has_xerr=False, has_yerr=False, **kwargs): super().__init__(lines, **kwargs) +class PieContainer: + """ + Container for the artists of pie charts (e.g. created by `.Axes.pie`). + + .. versionadded:: 3.11 + + .. warning:: + The class name ``PieContainer`` name is provisional and may change in future + to reflect development of its functionality. + + You can access the wedge patches and further parameters by the attributes. + + Attributes + ---------- + wedges : list of `~matplotlib.patches.Wedge` + The artists of the pie wedges. + + values : `numpy.ndarray` + The data that the pie is based on. + + fracs : `numpy.ndarray` + The fraction of the pie that each wedge represents. + + texts : list of list of `~matplotlib.text.Text` + The artists of any labels on the pie wedges. Each inner list has one + text label per wedge. + + """ + def __init__(self, wedges, values, normalize): + self.wedges = wedges + self._texts = [] + self._values = values + self._normalize = normalize + + @property + def texts(self): + # Only return non-empty sublists. An empty sublist may have been added + # for backwards compatibility of the Axes.pie return value (see __getitem__). + return [t_list for t_list in self._texts if t_list] + + @property + def values(self): + result = self._values.copy() + result.flags.writeable = False + return result + + @property + def fracs(self): + if self._normalize: + result = self._values / self._values.sum() + else: + result = self._values + + result.flags.writeable = False + return result + + def add_texts(self, texts): + """Add a list of `~matplotlib.text.Text` objects to the container.""" + self._texts.append(texts) + + def remove(self): + """Remove all wedges and texts from the axes""" + for artist_list in self.wedges, self._texts: + for artist in cbook.flatten(artist_list): + artist.remove() + + def __getitem__(self, key): + # needed to support unpacking into a tuple for backward compatibility of the + # Axes.pie return value + return (self.wedges, *self._texts)[key] + + class StemContainer(Container): """ Container for the artists created in a :meth:`.Axes.stem` plot. diff --git a/lib/matplotlib/container.pyi b/lib/matplotlib/container.pyi index ff11830c544c..772801b16d6d 100644 --- a/lib/matplotlib/container.pyi +++ b/lib/matplotlib/container.pyi @@ -1,11 +1,13 @@ from matplotlib.artist import Artist from matplotlib.lines import Line2D from matplotlib.collections import LineCollection -from matplotlib.patches import Rectangle +from matplotlib.patches import Rectangle, Wedge +from matplotlib.text import Text from collections.abc import Callable from typing import Any, Literal from numpy.typing import ArrayLike +from numpy import ndarray class Container(tuple): def __new__(cls, *args, **kwargs): ... @@ -51,6 +53,24 @@ class ErrorbarContainer(Container): **kwargs ) -> None: ... +class PieContainer(Container): + wedges: list[Wedge] + def __init__( + self, + wedges: list[Wedge], + values: ndarray, + normalize: bool, + ) -> None: ... + @property + def texts(self) -> list[list[Text]]: ... + @property + def values(self) -> ndarray: ... + @property + def fracs(self) -> ndarray: ... + def add_texts(self, + texts: list[Text], + ) -> None: ... + class StemContainer(Container): markerline: Line2D stemlines: LineCollection diff --git a/lib/matplotlib/contour.py b/lib/matplotlib/contour.py index 643bfce4273a..dfc39ed664f9 100644 --- a/lib/matplotlib/contour.py +++ b/lib/matplotlib/contour.py @@ -735,13 +735,12 @@ def __init__(self, ax, *args, i0 = 1 cmap = mcolors.ListedColormap( - cbook._resize_sequence(color_sequence[i0:], ncolors)) - - if use_set_under_over: - if self._extend_min: - cmap.set_under(color_sequence[0]) - if self._extend_max: - cmap.set_over(color_sequence[-1]) + cbook._resize_sequence(color_sequence[i0:], ncolors), + under=(color_sequence[0] + if use_set_under_over and self._extend_min else None), + over=(color_sequence[-1] + if use_set_under_over and self._extend_max else None), + ) # label lists must be initialized here self.labelTexts = [] @@ -1269,7 +1268,8 @@ def draw(self, renderer): if edgecolors.size == 0: edgecolors = ("none",) for idx in range(n_paths): - with cbook._setattr_cm(self, _paths=[paths[idx]]), self._cm_set( + with self._cm_set( + paths=[paths[idx]], hatch=self.hatches[idx % len(self.hatches)], array=[self.get_array()[idx]], linewidths=[self.get_linewidths()[idx % len(self.get_linewidths())]], @@ -1333,7 +1333,7 @@ def _process_args(self, *args, corner_mask=None, algorithm=None, **kwargs): # if the transform is not trans data, and some part of it # contains transData, transform the xs and ys to data coordinates if (t != self.axes.transData and - any(t.contains_branch_seperately(self.axes.transData))): + any(t.contains_branch_separately(self.axes.transData))): trans_to_data = t - self.axes.transData pts = np.vstack([x.flat, y.flat]).T transformed_pts = trans_to_data.transform(pts) diff --git a/lib/matplotlib/dviread.py b/lib/matplotlib/dviread.py index 9e8b6a5facf5..f07157a63524 100644 --- a/lib/matplotlib/dviread.py +++ b/lib/matplotlib/dviread.py @@ -26,12 +26,15 @@ import subprocess import sys from collections import namedtuple -from functools import cache, lru_cache, partial, wraps +from functools import cache, cached_property, lru_cache, partial, wraps from pathlib import Path +import fontTools.agl import numpy as np +import matplotlib as mpl from matplotlib import _api, cbook, font_manager +from matplotlib.ft2font import LoadFlags _log = logging.getLogger(__name__) @@ -67,42 +70,15 @@ class Text(namedtuple('Text', 'x y font glyph width')): """ A glyph in the dvi file. - The *x* and *y* attributes directly position the glyph. The *font*, - *glyph*, and *width* attributes are kept public for back-compatibility, - but users wanting to draw the glyph themselves are encouraged to instead - load the font specified by `font_path` at `font_size`, warp it with the - effects specified by `font_effects`, and load the glyph at the FreeType - glyph `index`. - """ - - def _get_pdftexmap_entry(self): - return PsfontsMap(find_tex_file("pdftex.map"))[self.font.texname] - - @property - def font_path(self): - """The `~pathlib.Path` to the font for this glyph.""" - psfont = self._get_pdftexmap_entry() - if psfont.filename is None: - raise ValueError("No usable font file found for {} ({}); " - "the font may lack a Type-1 version" - .format(psfont.psname.decode("ascii"), - psfont.texname.decode("ascii"))) - return Path(psfont.filename) - - @property - def font_size(self): - """The font size.""" - return self.font.size + In order to render the glyph, load the glyph at index ``text.index`` + from the font at ``text.font.resolve_path()`` with size ``text.font.size``, + warped with ``text.font.effects``, then draw it at position + ``(text.x, text.y)``. - @property - def font_effects(self): - """ - The "font effects" dict for this glyph. - - This dict contains the values for this glyph of SlantFont and - ExtendFont (if any), read off :file:`pdftex.map`. - """ - return self._get_pdftexmap_entry().effects + ``text.glyph`` is the glyph number actually stored in the dvi file (whose + interpretation depends on the font). ``text.width`` is the glyph width in + dvi units. + """ @property def index(self): @@ -112,25 +88,57 @@ def index(self): # See DviFont._index_dvi_to_freetype for details on the index mapping. return self.font._index_dvi_to_freetype(self.glyph) - @property # To be deprecated together with font_size, font_effects. + font_path = property(lambda self: self.font.resolve_path()) + font_size = property(lambda self: self.font.size) + font_effects = property(lambda self: self.font.effects) + + @property # To be deprecated together with font_path, font_size, font_effects. def glyph_name_or_index(self): """ - Either the glyph name or the native charmap glyph index. - - If :file:`pdftex.map` specifies an encoding for this glyph's font, that - is a mapping of glyph indices to Adobe glyph names; use it to convert - dvi indices to glyph names. Callers can then convert glyph names to - glyph indices (with FT_Get_Name_Index/get_name_index), and load the - glyph using FT_Load_Glyph/load_glyph. - - If :file:`pdftex.map` specifies no encoding, the indices directly map - to the font's "native" charmap; glyphs should directly load using - FT_Load_Char/load_char after selecting the native charmap. + The glyph name, the native charmap glyph index, or the raw glyph index. + + If the font is a TrueType file (which can currently only happen for + DVI files generated by xetex or luatex), then this number is the raw + index of the glyph, which can be passed to FT_Load_Glyph/load_glyph. + + Otherwise, the font is a PostScript font. For such fonts, if + :file:`pdftex.map` specifies an encoding for this glyph's font, + that is a mapping of glyph indices to Adobe glyph names; which + is used by this property to convert dvi numbers to glyph names. + Callers can then convert glyph names to glyph indices (with + FT_Get_Name_Index/get_name_index), and load the glyph using + FT_Load_Glyph/load_glyph. + + If :file:`pdftex.map` specifies no encoding for a PostScript font, + this number is an index to the font's "native" charmap; glyphs should + directly load using FT_Load_Char/load_char after selecting the native + charmap. """ + # The last section is only true on luatex since luaotfload 3.23; this + # must be checked by the code generated by texmanager. (luaotfload's + # docs states "No one should rely on the mapping between DVI character + # codes and font glyphs [prior to v3.15] unless they tightly + # control all involved versions and are deeply familiar with the + # implementation", but a further mapping bug was fixed in luaotfload + # commit 8f2dca4, first included in v3.23). entry = self._get_pdftexmap_entry() return (_parse_enc(entry.encoding)[self.glyph] if entry.encoding is not None else self.glyph) + def _as_unicode_or_name(self): + if self.font.subfont: + raise NotImplementedError("Indexing TTC fonts is not supported yet") + path = self.font.resolve_path() + if path.name.lower().endswith("pk"): + # PK fonts have no encoding information; report glyphs as ASCII but + # with a "?" to indicate that this is just a guess. + return (f"{chr(self.glyph)}?" if chr(self.glyph).isprintable() else + f"pk{self.glyph:#02x}") + face = font_manager.get_font(path) + glyph_name = face.get_glyph_name(self.index) + glyph_str = fontTools.agl.toUnicode(glyph_name) + return glyph_str or glyph_name + # Opcode argument parsing # @@ -408,7 +416,7 @@ def _put_char_real(self, char): scale = font._scale for x, y, f, g, w in font._vf[char].text: newf = DviFont(scale=_mul1220(scale, f._scale), - tfm=f._tfm, texname=f.texname, vf=f._vf) + metrics=f._metrics, texname=f.texname, vf=f._vf) self.text.append(Text(self.h + _mul1220(x, scale), self.v + _mul1220(y, scale), newf, g, newf._width_of(g))) @@ -504,10 +512,21 @@ def _fnt_def(self, k, c, s, d, a, l): def _fnt_def_real(self, k, c, s, d, a, l): n = self.file.read(a + l) - fontname = n[-l:].decode('ascii') + fontname = n[-l:] + if fontname.startswith(b"[") and c == 0x4c756146: # c == "LuaF" + # See https://chat.stackexchange.com/rooms/106428 (and also + # https://tug.org/pipermail/dvipdfmx/2021-January/000168.html). + # AFAICT luatex's dvi drops info re: OpenType variation-axis values. + self.fonts[k] = DviFont.from_luatex(s, n) + return + fontname = fontname.decode("ascii") try: tfm = _tfmfile(fontname) except FileNotFoundError as exc: + if fontname.startswith("[") and fontname.endswith(";") and c == 0: + exc.add_note( + "This dvi file was likely generated with a too-old " + "version of luaotfload; luaotfload 3.23 is required.") # Explicitly allow defining missing fonts for Vf support; we only # register an error when trying to load a glyph from a missing font # and throw that error in Dvi._read. For Vf, _finalize_packet @@ -521,12 +540,12 @@ def _fnt_def_real(self, k, c, s, d, a, l): vf = _vffile(fontname) except FileNotFoundError: vf = None - self.fonts[k] = DviFont(scale=s, tfm=tfm, texname=n, vf=vf) + self.fonts[k] = DviFont(scale=s, metrics=tfm, texname=n, vf=vf) @_dispatch(247, state=_dvistate.pre, args=('u1', 'u4', 'u4', 'u4', 'u1')) def _pre(self, i, num, den, mag, k): self.file.read(k) # comment in the dvi file - if i != 2: + if i not in [2, 7]: # 2: pdftex, luatex; 7: xetex raise ValueError(f"Unknown dvi format {i}") if num != 25400000 or den != 7227 * 2**16: raise ValueError("Nonstandard units in dvi file") @@ -547,13 +566,66 @@ def _post(self, _): # TODO: actually read the postamble and finale? # currently post_post just triggers closing the file - @_dispatch(249) - def _post_post(self, _): + @_dispatch(249, args=()) + def _post_post(self): raise NotImplementedError - @_dispatch(min=250, max=255) - def _malformed(self, offset): - raise ValueError(f"unknown command: byte {250 + offset}") + @_dispatch(250, args=()) + def _begin_reflect(self): + raise NotImplementedError + + @_dispatch(251, args=()) + def _end_reflect(self): + raise NotImplementedError + + @_dispatch(252, args=()) + def _define_native_font(self): + k = self._read_arg(4, signed=False) + s = self._read_arg(4, signed=False) + flags = self._read_arg(2, signed=False) + l = self._read_arg(1, signed=False) + n = self.file.read(l) + i = self._read_arg(4, signed=False) + effects = {} + if flags & 0x0200: + effects["rgba"] = [self._read_arg(1, signed=False) for _ in range(4)] + if flags & 0x1000: + effects["extend"] = self._read_arg(4, signed=True) / 65536 + if flags & 0x2000: + effects["slant"] = self._read_arg(4, signed=True) / 65536 + if flags & 0x4000: + effects["embolden"] = self._read_arg(4, signed=True) / 65536 + self.fonts[k] = DviFont.from_xetex(s, n, i, effects) + + @_dispatch(253, args=()) + def _set_glyphs(self): + w = self._read_arg(4, signed=False) + k = self._read_arg(2, signed=False) + xy = [self._read_arg(4, signed=True) for _ in range(2 * k)] + g = [self._read_arg(2, signed=False) for _ in range(k)] + font = self.fonts[self.f] + for i in range(k): + self.text.append(Text(self.h + xy[2 * i], self.v + xy[2 * i + 1], + font, g[i], font._width_of(g[i]))) + self.h += w + + @_dispatch(254, args=()) + def _set_text_and_glyphs(self): + l = self._read_arg(2, signed=False) + t = self.file.read(2 * l) # utf16 + w = self._read_arg(4, signed=False) + k = self._read_arg(2, signed=False) + xy = [self._read_arg(4, signed=True) for _ in range(2 * k)] + g = [self._read_arg(2, signed=False) for _ in range(k)] + font = self.fonts[self.f] + for i in range(k): + self.text.append(Text(self.h + xy[2 * i], self.v + xy[2 * i + 1], + font, g[i], font._width_of(g[i]))) + self.h += w + + @_dispatch(255) + def _malformed(self, raw): + raise ValueError("unknown command: byte 255") class DviFont: @@ -571,10 +643,10 @@ class DviFont: ---------- scale : float Factor by which the font is scaled from its natural size. - tfm : Tfm + metrics : Tfm | TtfMetrics TeX font metrics for this font texname : bytes - Name of the font as used internally by TeX and friends, as an ASCII + Name of the font as used internally in the DVI file, as an ASCII bytestring. This is usually very different from any external font names; `PsfontsMap` can be used to find the external name of the font. vf : Vf @@ -590,17 +662,54 @@ class DviFont: Size of the font in Adobe points, converted from the slightly smaller TeX points. """ - __slots__ = ('texname', 'size', '_scale', '_vf', '_tfm', '_encoding') - def __init__(self, scale, tfm, texname, vf): + def __init__(self, scale, metrics, texname, vf): _api.check_isinstance(bytes, texname=texname) self._scale = scale - self._tfm = tfm + self._metrics = metrics self.texname = texname self._vf = vf - self.size = scale * (72.0 / (72.27 * 2**16)) + self._path = None self._encoding = None + @classmethod + def from_luatex(cls, scale, texname): + path_b, sep, rest = texname[1:].rpartition(b"]") + if not (texname.startswith(b"[") and sep and rest[:1] in [b"", b":"]): + raise ValueError(f"Invalid modern font name: {texname}") + # utf8 on Windows, not utf16! + path = path_b.decode("utf8") if os.name == "nt" else os.fsdecode(path_b) + subfont = 0 + effects = {} + if rest[1:]: + for kv in rest[1:].decode("ascii").split(";"): + key, val = kv.split("=", 1) + if key == "index": + subfont = val + elif key in ["embolden", "slant", "extend"]: + effects[key] = int(val) / 65536 + else: + _log.warning("Ignoring invalid key-value pair: %r", kv) + metrics = TtfMetrics(path) + font = cls(scale, metrics, texname, vf=None) + font._path = Path(path) + font.subfont = subfont + font.effects = effects + return font + + @classmethod + def from_xetex(cls, scale, texname, subfont, effects): + # utf8 on Windows, not utf16! + path = texname.decode("utf8") if os.name == "nt" else os.fsdecode(texname) + metrics = TtfMetrics(path) + font = cls(scale, metrics, b"[" + texname + b"]", vf=None) + font._path = Path(path) + font.subfont = subfont + font.effects = effects + return font + + size = property(lambda self: self._scale * (72.0 / (72.27 * 2**16))) + widths = _api.deprecated("3.11")(property(lambda self: [ (1000 * self._tfm.width.get(char, 0)) >> 20 for char in range(max(self._tfm.width, default=-1) + 1)])) @@ -629,7 +738,7 @@ def __repr__(self): def _width_of(self, char): """Width of char in dvi units.""" - metrics = self._tfm.get_metrics(char) + metrics = self._metrics.get_metrics(char) if metrics is None: _log.debug('No width for char %d in font %s.', char, self.texname) return 0 @@ -637,7 +746,7 @@ def _width_of(self, char): def _height_depth_of(self, char): """Height and depth of char in dvi units.""" - metrics = self._tfm.get_metrics(char) + metrics = self._metrics.get_metrics(char) if metrics is None: _log.debug('No metrics for char %d in font %s', char, self.texname) return [0, 0] @@ -654,26 +763,55 @@ def _height_depth_of(self, char): hd[-1] = 0 return hd + def resolve_path(self): + if self._path is None: + fontmap = PsfontsMap(find_tex_file("pdftex.map")) + try: + psfont = fontmap[self.texname] + except LookupError as exc: + try: + find_tex_file(f"{self.texname.decode('ascii')}.mf") + except FileNotFoundError: + raise exc from None + else: + self._path = Path(find_tex_file( + f"{self.texname.decode('ascii')}.600pk")) + else: + if psfont.filename is None: + raise ValueError("No usable font file found for {} ({}); " + "the font may lack a Type-1 version" + .format(psfont.psname.decode("ascii"), + psfont.texname.decode("ascii"))) + self._path = Path(psfont.filename) + return self._path + + @cached_property + def subfont(self): + return 0 + + @cached_property + def effects(self): + if self.resolve_path().match("*.600pk"): + return {} + return PsfontsMap(find_tex_file("pdftex.map"))[self.texname].effects + def _index_dvi_to_freetype(self, idx): """Convert dvi glyph indices to FreeType ones.""" # Glyphs indices stored in the dvi file map to FreeType glyph indices # (i.e., which can be passed to FT_Load_Glyph) in various ways: + # - for xetex & luatex "native fonts", dvi indices are directly equal + # to FreeType indices. # - if pdftex.map specifies an ".enc" file for the font, that file maps # dvi indices to Adobe glyph names, which can then be converted to # FreeType glyph indices with FT_Get_Name_Index. # - if no ".enc" file is specified, then the font must be a Type 1 # font, and dvi indices directly index into the font's CharStrings # vector. - # - (xetex & luatex, currently unsupported, can also declare "native - # fonts", for which dvi indices are equal to FreeType indices.) + if self.texname.startswith(b"["): + return idx if self._encoding is None: + face = font_manager.get_font(self.resolve_path()) psfont = PsfontsMap(find_tex_file("pdftex.map"))[self.texname] - if psfont.filename is None: - raise ValueError("No usable font file found for {} ({}); " - "the font may lack a Type-1 version" - .format(psfont.psname.decode("ascii"), - psfont.texname.decode("ascii"))) - face = font_manager.get_font(psfont.filename) if psfont.encoding: self._encoding = [face.get_name_index(name) for name in _parse_enc(psfont.encoding)] @@ -882,6 +1020,27 @@ def get_metrics(self, idx): property(lambda self: {c: m.tex_depth for c, m in self._glyph_metrics})) +class TtfMetrics: + def __init__(self, filename): + self._face = font_manager.get_font(filename, hinting_factor=1) + + def get_metrics(self, idx): + # _mul1220 uses a truncating bitshift for compatibility with dvitype. + # When upem is 2048 the conversion to 12.20 is exact, but when + # upem is 1000 (e.g. lmroman10-regular.otf) the metrics themselves + # are not exactly representable as 12.20 fp. Manual testing via + # \sbox0{x}\count0=\wd0\typeout{\the\count0} suggests that metrics + # are rounded (not truncated) after conversion to 12.20 and before + # multiplication by the scale. + upem = self._face.units_per_EM # Usually 2048 or 1000. + g = self._face.load_glyph(idx, LoadFlags.NO_SCALE) + return TexMetrics( + tex_width=round(g.horiAdvance / upem * 2**20), + tex_height=round(g.horiBearingY / upem * 2**20), + tex_depth=round((g.height - g.horiBearingY) / upem * 2**20), + ) + + PsFont = namedtuple('PsFont', 'texname psname effects encoding filename') @@ -1094,9 +1253,12 @@ def __new__(cls): def _new_proc(self): return subprocess.Popen( - ["luatex", "--luaonly", - str(cbook._get_data_path("kpsewhich.lua"))], - stdin=subprocess.PIPE, stdout=subprocess.PIPE) + ["luatex", "--luaonly", str(cbook._get_data_path("kpsewhich.lua"))], + # mktexpk logs to stderr; suppress that. + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + # Store generated pk fonts in our own cache. + env={"MT_VARTEXFONTS": str(Path(mpl.get_cachedir(), "vartexfonts")), + **os.environ}) def search(self, filename): if self._proc.poll() is not None: # Dead, restart it. @@ -1148,13 +1310,16 @@ def find_tex_file(filename): kwargs = {'env': {**os.environ, 'command_line_encoding': 'utf-8'}, 'encoding': 'utf-8'} else: # On POSIX, run through the equivalent of os.fsdecode(). - kwargs = {'encoding': sys.getfilesystemencoding(), + kwargs = {'env': {**os.environ}, + 'encoding': sys.getfilesystemencoding(), 'errors': 'surrogateescape'} + kwargs['env'].update( + MT_VARTEXFONTS=str(Path(mpl.get_cachedir(), "vartexfonts"))) try: - path = (cbook._check_and_log_subprocess(['kpsewhich', filename], - _log, **kwargs) - .rstrip('\n')) + path = cbook._check_and_log_subprocess( + ['kpsewhich', '-mktex=pk', filename], _log, **kwargs, + ).rstrip('\n') except (FileNotFoundError, RuntimeError): path = None @@ -1179,35 +1344,35 @@ def _fontfile(cls, suffix, texname): import itertools from argparse import ArgumentParser - import fontTools.agl - - from matplotlib.ft2font import FT2Font - parser = ArgumentParser() parser.add_argument("filename") parser.add_argument("dpi", nargs="?", type=float, default=None) + parser.add_argument("-d", "--debug", action="store_true") args = parser.parse_args() + if args.debug: + logging.basicConfig(level=logging.DEBUG) + def _print_fields(*args): print(" ".join(map("{:>11}".format, args))) with Dvi(args.filename, args.dpi) as dvi: - fontmap = PsfontsMap(find_tex_file('pdftex.map')) for page in dvi: print(f"=== NEW PAGE === " f"(w: {page.width}, h: {page.height}, d: {page.descent})") print("--- GLYPHS ---") - for font, group in itertools.groupby( - page.text, lambda text: text.font): - psfont = fontmap[font.texname] - fontpath = psfont.filename - print(f"font: {font.texname.decode('latin-1')} " - f"(scale: {font._scale / 2 ** 20}) at {fontpath}") - face = FT2Font(fontpath) + for font, group in itertools.groupby(page.text, lambda text: text.font): + font_name = (font.texname.decode("utf8") if os.name == "nt" + else os.fsdecode(font.texname)) + if isinstance(font._metrics, Tfm): + print(f"font: {font_name} at {font.resolve_path()}") + else: + print(f"font: {font_name}") + print(f"scale: {font._scale / 2 ** 20}") _print_fields("x", "y", "glyph", "chr", "w") for text in group: - glyph_str = fontTools.agl.toUnicode(face.get_glyph_name(text.index)) - _print_fields(text.x, text.y, text.glyph, glyph_str, text.width) + _print_fields(text.x, text.y, text.glyph, + text._as_unicode_or_name(), text.width) if page.boxes: print("--- BOXES ---") _print_fields("x", "y", "h", "w") diff --git a/lib/matplotlib/dviread.pyi b/lib/matplotlib/dviread.pyi index 82c0238d39d1..1c24ff1c28a9 100644 --- a/lib/matplotlib/dviread.pyi +++ b/lib/matplotlib/dviread.pyi @@ -58,16 +58,28 @@ class Dvi: class DviFont: texname: bytes - size: float def __init__( - self, scale: float, tfm: Tfm, texname: bytes, vf: Vf | None + self, scale: float, metrics: Tfm | TtfMetrics, texname: bytes, vf: Vf | None ) -> None: ... + @classmethod + def from_luatex(cls, scale: float, texname: bytes) -> DviFont: ... + @classmethod + def from_xetex( + cls, scale: float, texname: bytes, subfont: int, effects: dict[str, float] + ) -> DviFont: ... def __eq__(self, other: object) -> bool: ... def __ne__(self, other: object) -> bool: ... @property + def size(self) -> float: ... + @property def widths(self) -> list[int]: ... @property def fname(self) -> str: ... + def resolve_path(self) -> Path: ... + @property + def subfont(self) -> int: ... + @property + def effects(self) -> dict[str, float]: ... class Vf(Dvi): def __init__(self, filename: str | os.PathLike) -> None: ... @@ -93,6 +105,10 @@ class Tfm: @property def depth(self) -> dict[int, int]: ... +class TtfMetrics: + def __init__(self, filename: str | os.PathLike) -> None: ... + def get_metrics(self, idx: int) -> TexMetrics: ... + class PsFont(NamedTuple): texname: bytes psname: bytes diff --git a/lib/matplotlib/figure.py b/lib/matplotlib/figure.py index 03549dd53bc1..4cd7fd01a995 100644 --- a/lib/matplotlib/figure.py +++ b/lib/matplotlib/figure.py @@ -2642,7 +2642,7 @@ def __init__(self, self._set_artist_props(self.patch) self.patch.set_antialiased(False) - FigureCanvasBase(self) # Set self.canvas. + self._set_base_canvas() if subplotpars is None: subplotpars = SubplotParams() @@ -2996,6 +2996,18 @@ def get_constrained_layout_pads(self, relative=False): return w_pad, h_pad, wspace, hspace + def _set_base_canvas(self): + """ + Initialize self.canvas with a FigureCanvasBase instance. + + This is used upon initialization of the Figure, but also + to reset the canvas when decoupling from pyplot. + """ + FigureCanvasBase(self) # Set self.canvas as a side-effect + # undo any high-dpi scaling + if self._dpi != self._original_dpi: + self.dpi = self._original_dpi + def set_canvas(self, canvas): """ Set the canvas that contains the figure @@ -3309,8 +3321,9 @@ def __setstate__(self, state): self.__dict__ = state # re-initialise some of the unstored state information - FigureCanvasBase(self) # Set self.canvas. - + self._set_base_canvas() + # force the bounding boxes to respect current dpi + self.dpi_scale_trans.clear().scale(self._dpi) if restore_to_pylab: # lazy import to avoid circularity import matplotlib.pyplot as plt diff --git a/lib/matplotlib/legend.py b/lib/matplotlib/legend.py index 119a27181c80..8f43f89de5f9 100644 --- a/lib/matplotlib/legend.py +++ b/lib/matplotlib/legend.py @@ -37,7 +37,7 @@ from matplotlib.patches import (Patch, Rectangle, Shadow, FancyBboxPatch, StepPatch) from matplotlib.collections import ( - Collection, CircleCollection, LineCollection, PathCollection, + Collection, CircleCollection, LineCollection, PatchCollection, PathCollection, PolyCollection, RegularPolyCollection) from matplotlib.text import Text from matplotlib.transforms import Bbox, BboxBase, TransformedBbox @@ -196,6 +196,12 @@ def _update_bbox_to_anchor(self, loc_in_canvas): The legend's background patch edge color. If ``"inherit"``, use :rc:`axes.edgecolor`. +linewidth : float or None, default: :rc:`legend.linewidth` + The legend's background patch edge linewidth. + If ``None``, use :rc:`patch.linewidth`. + + .. versionadded:: 3.11 + mode : {"expand", None} If *mode* is set to ``"expand"`` the legend will be horizontally expanded to fill the Axes area (or *bbox_to_anchor* if defines @@ -385,6 +391,7 @@ def __init__( framealpha=None, # set frame alpha edgecolor=None, # frame patch edgecolor facecolor=None, # frame patch facecolor + linewidth=None, # frame patch linewidth bbox_to_anchor=None, # bbox to which the legend will be anchored bbox_transform=None, # transform for the bbox @@ -526,9 +533,12 @@ def __init__( fancybox = mpl._val_or_rc(fancybox, "legend.fancybox") + linewidth = mpl._val_or_rc(linewidth, "legend.linewidth") + self.legendPatch = FancyBboxPatch( xy=(0, 0), width=1, height=1, facecolor=facecolor, edgecolor=edgecolor, + linewidth=linewidth, # If shadow is used, default to alpha=1 (#8943). alpha=(framealpha if framealpha is not None else 1 if shadow @@ -787,6 +797,7 @@ def draw(self, renderer): BarContainer: legend_handler.HandlerPatch( update_func=legend_handler.update_from_first_child), tuple: legend_handler.HandlerTuple(), + PatchCollection: legend_handler.HandlerPolyCollection(), PathCollection: legend_handler.HandlerPathCollection(), PolyCollection: legend_handler.HandlerPolyCollection() } diff --git a/lib/matplotlib/legend.pyi b/lib/matplotlib/legend.pyi index c03471fc54d1..e17738c76161 100644 --- a/lib/matplotlib/legend.pyi +++ b/lib/matplotlib/legend.pyi @@ -85,6 +85,7 @@ class Legend(Artist): framealpha: float | None = ..., edgecolor: Literal["inherit"] | ColorType | None = ..., facecolor: Literal["inherit"] | ColorType | None = ..., + linewidth: float | None = ..., bbox_to_anchor: BboxBase | tuple[float, float] | tuple[float, float, float, float] diff --git a/lib/matplotlib/meson.build b/lib/matplotlib/meson.build index c4746f332bcb..c0bfdb227e2e 100644 --- a/lib/matplotlib/meson.build +++ b/lib/matplotlib/meson.build @@ -17,6 +17,7 @@ python_sources = [ '_mathtext.py', '_mathtext_data.py', '_pylab_helpers.py', + '_style_helpers.py', '_text_helpers.py', '_tight_bbox.py', '_tight_layout.py', diff --git a/lib/matplotlib/mlab.py b/lib/matplotlib/mlab.py index c28774125df0..a694308384c1 100644 --- a/lib/matplotlib/mlab.py +++ b/lib/matplotlib/mlab.py @@ -353,7 +353,7 @@ def _spectral_helper(x, y=None, NFFT=None, Fs=None, detrend_func=None, # the sampling frequency, if desired. Scale everything, except the DC # component and the NFFT/2 component: - # if we have a even number of frequencies, don't scale NFFT/2 + # if we have an even number of frequencies, don't scale NFFT/2 if not NFFT % 2: slc = slice(1, -1, None) # if we have an odd number, just don't scale DC diff --git a/lib/matplotlib/mpl-data/kpsewhich.lua b/lib/matplotlib/mpl-data/kpsewhich.lua index 8e9172a45082..dc526effeebe 100644 --- a/lib/matplotlib/mpl-data/kpsewhich.lua +++ b/lib/matplotlib/mpl-data/kpsewhich.lua @@ -1,3 +1,4 @@ -- see dviread._LuatexKpsewhich kpse.set_program_name("latex") -while true do print(kpse.lookup(io.read():gsub("\r", ""))); io.flush(); end +kpse.init_prog("", 600, "ljfour") +while true do print(kpse.lookup(io.read():gsub("\r", ""), {mktexpk=true})); io.flush(); end diff --git a/lib/matplotlib/mpl-data/matplotlibrc b/lib/matplotlib/mpl-data/matplotlibrc index 83e567a414c9..17705fe60347 100644 --- a/lib/matplotlib/mpl-data/matplotlibrc +++ b/lib/matplotlib/mpl-data/matplotlibrc @@ -562,6 +562,7 @@ #legend.framealpha: 0.8 # legend patch transparency #legend.facecolor: inherit # inherit from axes.facecolor; or color spec #legend.edgecolor: 0.8 # background patch boundary color +#legend.linewidth: None # line width of the legend frame, None means inherit from patch.linewidth #legend.fancybox: True # if True, use a rounded box for the # legend background, else a rectangle #legend.shadow: False # if True, give background a shadow effect @@ -599,6 +600,9 @@ # the pyplot interface before emitting a warning. # If less than one this feature is disabled. #figure.raise_window : True # Raise the GUI window to front when show() is called. + # If set to False, we currently do not take any further + # actions and whether the window appears on the front + # may depend on the GUI framework and window manager. ## The figure subplot parameters. All dimensions are a fraction of the figure width and height. #figure.subplot.left: 0.125 # the left side of the subplots of the figure diff --git a/lib/matplotlib/patches.py b/lib/matplotlib/patches.py index d750e86e401f..ec8f55d20ac8 100644 --- a/lib/matplotlib/patches.py +++ b/lib/matplotlib/patches.py @@ -2382,10 +2382,9 @@ def pprint_styles(cls): @classmethod @_api.deprecated( - '3.10.0', + '3.10', message="This method is never used internally.", - alternative="No replacement. Please open an issue if you use this." - ) + alternative="No replacement. Please open an issue if you use this.") def register(cls, name, style): """Register a new style.""" if not issubclass(style, cls._Base): diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py index 4531cbd3b261..225684d068f4 100644 --- a/lib/matplotlib/pyplot.py +++ b/lib/matplotlib/pyplot.py @@ -1,5 +1,9 @@ -# Note: The first part of this file can be modified in place, but the latter -# part is autogenerated by the boilerplate.py script. +# Note: The first part of this file is hand-written and must be edited +# in-place. The second part, starting with +# ### REMAINING CONTENT GENERATED BY boilerplate.py ### +# is generated by the script boilerplate.py. It must not be edited here +# because all changes will be overwritten by the next run of the script. +# For more information see the description in boilerplate.py. """ `matplotlib.pyplot` is a state-based interface to matplotlib. It provides @@ -54,7 +58,6 @@ from cycler import cycler # noqa: F401 import matplotlib -import matplotlib.colorbar import matplotlib.image from matplotlib import _api # Re-exported (import x as x) for typing. @@ -123,13 +126,14 @@ from matplotlib.container import ( BarContainer, ErrorbarContainer, + PieContainer, StemContainer, ) from matplotlib.figure import SubFigure from matplotlib.legend import Legend from matplotlib.mlab import GaussianKDE from matplotlib.image import AxesImage, FigureImage - from matplotlib.patches import FancyArrow, StepPatch, Wedge + from matplotlib.patches import FancyArrow, StepPatch from matplotlib.quiver import Barbs, Quiver, QuiverKey from matplotlib.scale import ScaleBase from matplotlib.typing import ( @@ -143,6 +147,8 @@ MarkerType, MouseEventType, PickEventType, + RcGroupKeyType, + RcKeyType, ResizeEventType, LogLevel ) @@ -787,13 +793,13 @@ def pause(interval: float) -> None: @_copy_docstring_and_deprecators(matplotlib.rc) -def rc(group: str, **kwargs) -> None: +def rc(group: RcGroupKeyType, **kwargs) -> None: matplotlib.rc(group, **kwargs) @_copy_docstring_and_deprecators(matplotlib.rc_context) def rc_context( - rc: dict[str, Any] | None = None, + rc: dict[RcKeyType, Any] | None = None, fname: str | pathlib.Path | os.PathLike | None = None, ) -> AbstractContextManager[None]: return matplotlib.rc_context(rc, fname) @@ -933,6 +939,10 @@ def figure( window title is set to this value. If num is a ``SubFigure``, its parent ``Figure`` is activated. + If *num* is a Figure instance that is already tracked in pyplot, it is + activated. If *num* is a Figure instance that is not tracked in pyplot, + it is added to the tracked figures and activated. + figsize : (float, float) or (float, float, str), default: :rc:`figure.figsize` The figure dimensions. This can be @@ -1019,21 +1029,32 @@ def figure( in the matplotlibrc file. """ allnums = get_fignums() + next_num = max(allnums) + 1 if allnums else 1 if isinstance(num, FigureBase): # type narrowed to `Figure | SubFigure` by combination of input and isinstance + has_figure_property_parameters = ( + any(param is not None for param in [figsize, dpi, facecolor, edgecolor]) + or not frameon or kwargs + ) + root_fig = num.get_figure(root=True) if root_fig.canvas.manager is None: - raise ValueError("The passed figure is not managed by pyplot") - elif (any(param is not None for param in [figsize, dpi, facecolor, edgecolor]) - or not frameon or kwargs) and root_fig.canvas.manager.num in allnums: + if has_figure_property_parameters: + raise ValueError( + "You cannot pass figure properties when calling figure() with " + "an existing Figure instance") + backend = _get_backend_mod() + manager_ = backend.new_figure_manager_given_figure(next_num, root_fig) + _pylab_helpers.Gcf._set_new_active_manager(manager_) + return manager_.canvas.figure + elif has_figure_property_parameters and root_fig.canvas.manager.num in allnums: _api.warn_external( "Ignoring specified arguments in this call because figure " f"with num: {root_fig.canvas.manager.num} already exists") _pylab_helpers.Gcf.set_active(root_fig.canvas.manager) return root_fig - next_num = max(allnums) + 1 if allnums else 1 fig_label = '' if num is None: num = next_num @@ -1242,7 +1263,7 @@ def close(fig: None | int | str | Figure | Literal["all"] = None) -> None: ----- pyplot maintains a reference to figures created with `figure()`. When work on the figure is completed, it should be closed, i.e. deregistered - from pyplot, to free its memory (see also :rc:figure.max_open_warning). + from pyplot, to free its memory (see also :rc:`figure.max_open_warning`). Closing a figure window created by `show()` automatically deregisters the figure. For all other use cases, most prominently `savefig()` without `show()`, the figure must be deregistered explicitly using `close()`. @@ -3250,8 +3271,9 @@ def cohere( NFFT: int = 256, Fs: float = 2, Fc: int = 0, - detrend: Literal["none", "mean", "linear"] - | Callable[[ArrayLike], ArrayLike] = mlab.detrend_none, + detrend: ( + Literal["none", "mean", "linear"] | Callable[[ArrayLike], ArrayLike] + ) = mlab.detrend_none, window: Callable[[ArrayLike], ArrayLike] | ArrayLike = mlab.window_hanning, noverlap: int = 0, pad_to: int | None = None, @@ -3308,9 +3330,9 @@ def csd( NFFT: int | None = None, Fs: float | None = None, Fc: int | None = None, - detrend: Literal["none", "mean", "linear"] - | Callable[[ArrayLike], ArrayLike] - | None = None, + detrend: ( + Literal["none", "mean", "linear"] | Callable[[ArrayLike], ArrayLike] | None + ) = None, window: Callable[[ArrayLike], ArrayLike] | ArrayLike | None = None, noverlap: int | None = None, pad_to: int | None = None, @@ -3936,7 +3958,7 @@ def pie( normalize: bool = True, hatch: str | Sequence[str] | None = None, data=None, -) -> tuple[list[Wedge], list[Text]] | tuple[list[Wedge], list[Text], list[Text]]: +) -> PieContainer: return gca().pie( x, explode=explode, @@ -3960,6 +3982,28 @@ def pie( ) +# Autogenerated by boilerplate.py. Do not edit as changes will be lost. +@_copy_docstring_and_deprecators(Axes.pie_label) +def pie_label( + container: PieContainer, + /, + labels: str | Sequence[str], + *, + distance: float = 0.6, + textprops: dict | None = None, + rotate: bool = False, + alignment: str = "auto", +) -> list[Text]: + return gca().pie_label( + container, + labels, + distance=distance, + textprops=textprops, + rotate=rotate, + alignment=alignment, + ) + + # Autogenerated by boilerplate.py. Do not edit as changes will be lost. @_copy_docstring_and_deprecators(Axes.plot) def plot( @@ -3985,9 +4029,9 @@ def psd( NFFT: int | None = None, Fs: float | None = None, Fc: int | None = None, - detrend: Literal["none", "mean", "linear"] - | Callable[[ArrayLike], ArrayLike] - | None = None, + detrend: ( + Literal["none", "mean", "linear"] | Callable[[ArrayLike], ArrayLike] | None + ) = None, window: Callable[[ArrayLike], ArrayLike] | ArrayLike | None = None, noverlap: int | None = None, pad_to: int | None = None, @@ -4095,9 +4139,9 @@ def specgram( NFFT: int | None = None, Fs: float | None = None, Fc: int | None = None, - detrend: Literal["none", "mean", "linear"] - | Callable[[ArrayLike], ArrayLike] - | None = None, + detrend: ( + Literal["none", "mean", "linear"] | Callable[[ArrayLike], ArrayLike] | None + ) = None, window: Callable[[ArrayLike], ArrayLike] | ArrayLike | None = None, noverlap: int | None = None, cmap: str | Colormap | None = None, @@ -4164,15 +4208,12 @@ def spy( # Autogenerated by boilerplate.py. Do not edit as changes will be lost. @_copy_docstring_and_deprecators(Axes.stackplot) -def stackplot( - x, *args, labels=(), colors=None, hatch=None, baseline="zero", data=None, **kwargs -): +def stackplot(x, *args, labels=(), colors=None, baseline="zero", data=None, **kwargs): return gca().stackplot( x, *args, labels=labels, colors=colors, - hatch=hatch, baseline=baseline, **({"data": data} if data is not None else {}), **kwargs, @@ -4414,10 +4455,9 @@ def violinplot( showmedians: bool = False, quantiles: Sequence[float | Sequence[float]] | None = None, points: int = 100, - bw_method: Literal["scott", "silverman"] - | float - | Callable[[GaussianKDE], float] - | None = None, + bw_method: ( + Literal["scott", "silverman"] | float | Callable[[GaussianKDE], float] | None + ) = None, side: Literal["both", "low", "high"] = "both", facecolor: Sequence[ColorType] | ColorType | None = None, linecolor: Sequence[ColorType] | ColorType | None = None, diff --git a/lib/matplotlib/quiver.py b/lib/matplotlib/quiver.py index 91c510ca7060..9ffcec5117d9 100644 --- a/lib/matplotlib/quiver.py +++ b/lib/matplotlib/quiver.py @@ -144,7 +144,7 @@ length in y direction = $\\frac{v}{\\mathrm{scale}} \\mathrm{scale_unit}$ - For example, ``(u, v) = (0.5, 0)`` with ``scale=10, scale_unit="width"`` results + For example, ``(u, v) = (0.5, 0)`` with ``scale=10, scale_units="width"`` results in a horizontal arrow with a length of *0.5 / 10 * "width"*, i.e. 0.05 times the Axes width. @@ -490,11 +490,8 @@ class Quiver(mcollections.PolyCollection): """ Specialized PolyCollection for arrows. - The only API method is set_UVC(), which can be used - to change the size, orientation, and color of the - arrows; their locations are fixed when the class is - instantiated. Possibly this method will be useful - in animations. + Use set_UVC to change the size, orientation, and color of the + arrows; their locations can be set using set_offsets(). Much of the work in this class is done in the draw() method so that as much information as possible is available diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py index 80d25659888e..a088274b3439 100644 --- a/lib/matplotlib/rcsetup.py +++ b/lib/matplotlib/rcsetup.py @@ -1184,6 +1184,8 @@ def _convert_validator_spec(key, conv): "legend.frameon": validate_bool, # alpha value of the legend frame "legend.framealpha": validate_float_or_None, + # linewidth of legend frame + "legend.linewidth": validate_float_or_None, ## the following dimensions are in fraction of the font size "legend.borderpad": validate_float, # units are fontsize diff --git a/lib/matplotlib/sphinxext/plot_directive.py b/lib/matplotlib/sphinxext/plot_directive.py index b5f10d851182..7b46b3145e2b 100644 --- a/lib/matplotlib/sphinxext/plot_directive.py +++ b/lib/matplotlib/sphinxext/plot_directive.py @@ -84,6 +84,11 @@ figure. This overwrites the caption given in the content, when the plot is generated from a file. +``:code-caption:`` : str + If specified, the option's argument will be used as a caption for the + code block (when ``:include-source:`` is used). This is added as the + ``:caption:`` option to the ``.. code-block::`` directive. + Additionally, this directive supports all the options of the `image directive `_, except for ``:target:`` (since plot will add its own target). These include @@ -281,6 +286,7 @@ class PlotDirective(Directive): 'context': _option_context, 'nofigs': directives.flag, 'caption': directives.unchanged, + 'code-caption': directives.unchanged, } def run(self): @@ -952,8 +958,11 @@ def run(arguments, content, options, state_machine, state, lineno): if is_doctest: lines = ['', *code_piece.splitlines()] else: - lines = ['.. code-block:: python', '', - *textwrap.indent(code_piece, ' ').splitlines()] + lines = ['.. code-block:: python'] + if 'code-caption' in options: + code_caption = options['code-caption'].replace('\n', ' ') + lines.append(f' :caption: {code_caption}') + lines.extend(['', *textwrap.indent(code_piece, ' ').splitlines()]) source_code = "\n".join(lines) else: source_code = "" diff --git a/lib/matplotlib/stackplot.py b/lib/matplotlib/stackplot.py index bd11558b0da9..25bb2f45a0c4 100644 --- a/lib/matplotlib/stackplot.py +++ b/lib/matplotlib/stackplot.py @@ -6,17 +6,16 @@ (https://stackoverflow.com/users/66549/doug) """ -import itertools import numpy as np -from matplotlib import _api +from matplotlib import cbook, collections, _api, _style_helpers __all__ = ['stackplot'] def stackplot(axes, x, *args, - labels=(), colors=None, hatch=None, baseline='zero', + labels=(), colors=None, baseline='zero', **kwargs): """ Draw a stacked area plot or a streamgraph. @@ -55,23 +54,26 @@ def stackplot(axes, x, *args, If not specified, the colors from the Axes property cycle will be used. - hatch : list of str, default: None - A sequence of hatching styles. See - :doc:`/gallery/shapes_and_collections/hatch_style_reference`. - The sequence will be cycled through for filling the - stacked areas from bottom to top. - It need not be exactly the same length as the number - of provided *y*, in which case the styles will repeat from the - beginning. - - .. versionadded:: 3.9 - Support for list input - data : indexable object, optional DATA_PARAMETER_PLACEHOLDER **kwargs - All other keyword arguments are passed to `.Axes.fill_between`. + All other keyword arguments are passed to `.Axes.fill_between`. The + following parameters additionally accept a sequence of values + corresponding to the *y* datasets: + + - *hatch* + - *edgecolor* + - *facecolor* + - *linewidth* + - *linestyle* + + .. versionadded:: 3.9 + Allowing a sequence of strings for *hatch*. + + .. versionadded:: 3.11 + Allowing sequences of values in above listed `.Axes.fill_between` + parameters. Returns ------- @@ -83,15 +85,13 @@ def stackplot(axes, x, *args, y = np.vstack(args) labels = iter(labels) - if colors is not None: - colors = itertools.cycle(colors) - else: - colors = (axes._get_lines.get_next_color() for _ in y) + if colors is None: + colors = [axes._get_lines.get_next_color() for _ in y] + + kwargs = cbook.normalize_kwargs(kwargs, collections.PolyCollection) + kwargs.setdefault('facecolor', colors) - if hatch is None or isinstance(hatch, str): - hatch = itertools.cycle([hatch]) - else: - hatch = itertools.cycle(hatch) + kwargs, style_gen = _style_helpers.style_generator(kwargs) # Assume data passed has not been 'stacked', so stack it here. # We'll need a float buffer for the upcoming calculations. @@ -130,18 +130,14 @@ def stackplot(axes, x, *args, # Color between x = 0 and the first array. coll = axes.fill_between(x, first_line, stack[0, :], - facecolor=next(colors), - hatch=next(hatch), label=next(labels, None), - **kwargs) + **next(style_gen), **kwargs) coll.sticky_edges.y[:] = [0] r = [coll] # Color between array i-1 and array i for i in range(len(y) - 1): r.append(axes.fill_between(x, stack[i, :], stack[i + 1, :], - facecolor=next(colors), - hatch=next(hatch), label=next(labels, None), - **kwargs)) + **next(style_gen), **kwargs)) return r diff --git a/lib/matplotlib/streamplot.py b/lib/matplotlib/streamplot.py index ece8bebf8192..725fff7b23fd 100644 --- a/lib/matplotlib/streamplot.py +++ b/lib/matplotlib/streamplot.py @@ -6,7 +6,7 @@ import numpy as np import matplotlib as mpl -from matplotlib import _api, cm, patches +from matplotlib import _api, colorizer, patches import matplotlib.colors as mcolors import matplotlib.collections as mcollections import matplotlib.lines as mlines @@ -228,7 +228,7 @@ def streamplot(axes, x, y, u, v, density=1, linewidth=None, color=None, if use_multicolor_lines: if norm is None: norm = mcolors.Normalize(color.min(), color.max()) - cmap = cm._ensure_cmap(cmap) + cmap = colorizer._ensure_cmap(cmap) streamlines = [] arrows = [] diff --git a/lib/matplotlib/table.py b/lib/matplotlib/table.py index 370ce9fe922f..91dddba6c31f 100644 --- a/lib/matplotlib/table.py +++ b/lib/matplotlib/table.py @@ -240,6 +240,13 @@ class Table(Artist): """ A table of cells. + .. note:: + + ``table()`` has some fundamental design limitations and will not be + developed further. If you need more functionality, consider + `blume `__. + + The table consists of a grid of cells, which are indexed by (row, column). For a simple table, you'll have a full grid of cells with indices from @@ -658,6 +665,12 @@ def table(ax, """ Add a table to an `~.axes.Axes`. + .. note:: + + ``table()`` has some fundamental design limitations and will not be + developed further. If you need more functionality, consider + `blume `__. + At least one of *cellText* or *cellColours* must be specified. These parameters must be 2D lists, in which the outer lists define the rows and the inner list define the column values per row. Each row must have the diff --git a/lib/matplotlib/testing/__init__.py b/lib/matplotlib/testing/__init__.py index 904ee5d73db4..eff079efe887 100644 --- a/lib/matplotlib/testing/__init__.py +++ b/lib/matplotlib/testing/__init__.py @@ -54,7 +54,7 @@ def setup(): def subprocess_run_for_testing(command, env=None, timeout=60, stdout=None, stderr=None, check=False, text=True, - capture_output=False): + capture_output=False, **kwargs): """ Create and run a subprocess. @@ -97,7 +97,7 @@ def subprocess_run_for_testing(command, env=None, timeout=60, stdout=None, command, env=env, timeout=timeout, check=check, stdout=stdout, stderr=stderr, - text=text + text=text, **kwargs ) except BlockingIOError: if sys.platform == "cygwin": diff --git a/lib/matplotlib/testing/__init__.pyi b/lib/matplotlib/testing/__init__.pyi index 7763cb6a9769..accf973615fa 100644 --- a/lib/matplotlib/testing/__init__.pyi +++ b/lib/matplotlib/testing/__init__.pyi @@ -16,6 +16,7 @@ def subprocess_run_for_testing( *, text: Literal[True], capture_output: bool = ..., + **kwargs, ) -> subprocess.CompletedProcess[str]: ... @overload def subprocess_run_for_testing( @@ -27,6 +28,7 @@ def subprocess_run_for_testing( check: bool = ..., text: Literal[False] = ..., capture_output: bool = ..., + **kwargs, ) -> subprocess.CompletedProcess[bytes]: ... @overload def subprocess_run_for_testing( @@ -38,6 +40,7 @@ def subprocess_run_for_testing( check: bool = ..., text: bool = ..., capture_output: bool = ..., + **kwargs, ) -> subprocess.CompletedProcess[bytes] | subprocess.CompletedProcess[str]: ... def subprocess_run_helper( func: Callable[[], None], diff --git a/lib/matplotlib/tests/baseline_images/dviread/lualatex.json b/lib/matplotlib/tests/baseline_images/dviread/lualatex.json new file mode 100644 index 000000000000..8f2d95017ec7 --- /dev/null +++ b/lib/matplotlib/tests/baseline_images/dviread/lualatex.json @@ -0,0 +1 @@ +[{"text": [[5046272, 4128768, "A", "lmroman10-regular.otf", 9.96, {}], [5756027, 4128768, "L", "lmroman10-regular.otf", 9.96, {}], [5929697, 4012179, "A", "lmroman7-regular.otf", 6.97, {}], [6218125, 4128768, "T", "lmroman10-regular.otf", 9.96, {}], [6582045, 4269998, "E", "lmroman10-regular.otf", 9.96, {}], [6946425, 4128768, "X", "lmroman10-regular.otf", 9.96, {}], [7656180, 4128768, "d", "DejaVuSans.ttf", 9.96, {"extend": 1.25, "slant": 0.25, "embolden": 0.25}], [8072180, 4128768, "o", "DejaVuSans.ttf", 9.96, {"extend": 1.25, "slant": 0.25, "embolden": 0.25}], [8473140, 4128768, "c", "DejaVuSans.ttf", 9.96, {"extend": 1.25, "slant": 0.25, "embolden": 0.25}], [8833460, 4128768, ".", "DejaVuSans.ttf", 9.96, {"extend": 1.25, "slant": 0.25, "embolden": 0.25}]], "boxes": []}, {"text": [[13686374, 5056284, "\u03c0", "cmmi5.pfb", 4.98, {}], [13716923, 5390308, "2", "cmr5.pfb", 4.98, {}], [13355110, 5463127, "integraldisplay", "cmex10.pfb", 9.96, {}], [13406537, 7324364, "0", "cmr7.pfb", 6.97, {}], [14010471, 5627696, "parenleftBig", "cmex10.pfb", 9.96, {}], [14937513, 5911796, "x", "cmmi10.pfb", 9.96, {}], [14480510, 6804696, "s", "lmroman10-regular.otf", 9.96, {}], [14738721, 6804696, "i", "lmroman10-regular.otf", 9.96, {}], [14920911, 6804696, "n", "lmroman10-regular.otf", 9.96, {}], [15394516, 6804696, "x", "cmmi10.pfb", 9.96, {}], [15847715, 5627696, "parenrightBig", "cmex10.pfb", 9.96, {}], [16239111, 5763501, "2", "cmr7.pfb", 6.97, {}], [16642338, 6355152, "d", "lmroman10-regular.otf", 9.96, {}], [17006718, 6355152, "x", "cmmi10.pfb", 9.96, {}]], "boxes": [[13686374, 5130818, 26213, 284106], [14480510, 6204418, 26213, 1288562]]}] diff --git a/lib/matplotlib/tests/baseline_images/dviread/pdflatex.json b/lib/matplotlib/tests/baseline_images/dviread/pdflatex.json new file mode 100644 index 000000000000..4754b722aa58 --- /dev/null +++ b/lib/matplotlib/tests/baseline_images/dviread/pdflatex.json @@ -0,0 +1 @@ +[{"text": [[5046272, 4128768, "A", "cmr10.pfb", 9.96, {}], [5756246, 4128768, "L", "cmr10.pfb", 9.96, {}], [5929917, 3994421, "A", "cmr7.pfb", 6.97, {}], [6218464, 4128768, "T", "cmr10.pfb", 9.96, {}], [6582530, 4269852, "E", "cmr10.pfb", 9.96, {}], [6946620, 4128768, "X", "cmr10.pfb", 9.96, {}], [7656594, 4128768, "d", "cmr10.pfb", 9.96, {}], [8020684, 4128768, "o", "cmr10.pfb", 9.96, {}], [8366570, 4128768, "c", "cmr10.pfb", 9.96, {}], [8657841, 4128768, ".", "cmr10.pfb", 9.96, {}]], "boxes": []}, {"text": [[13686591, 5056284, "\u03c0", "cmmi5.pfb", 4.98, {}], [13717140, 5390308, "2", "cmr5.pfb", 4.98, {}], [13355327, 5463127, "integraldisplay", "cmex10.pfb", 9.96, {}], [13406754, 7324364, "0", "cmr7.pfb", 6.97, {}], [14010688, 5627696, "parenleftBig", "cmex10.pfb", 9.96, {}], [14937658, 5911796, "x", "cmmi10.pfb", 9.96, {}], [14480727, 6804696, "s", "cmr10.pfb", 9.96, {}], [14739230, 6804696, "i", "cmr10.pfb", 9.96, {}], [14921275, 6804696, "n", "cmr10.pfb", 9.96, {}], [15394589, 6804696, "x", "cmmi10.pfb", 9.96, {}], [15847788, 5627696, "parenrightBig", "cmex10.pfb", 9.96, {}], [16239184, 5763501, "2", "cmr7.pfb", 6.97, {}], [16642411, 6355152, "d", "cmr10.pfb", 9.96, {}], [17006501, 6355152, "x", "cmmi10.pfb", 9.96, {}]], "boxes": [[13686591, 5130818, 26213, 284106], [14480727, 6204418, 26213, 1288418]]}] diff --git a/lib/matplotlib/tests/baseline_images/dviread/test.dvi b/lib/matplotlib/tests/baseline_images/dviread/test.dvi deleted file mode 100644 index 93751ffdcba0..000000000000 Binary files a/lib/matplotlib/tests/baseline_images/dviread/test.dvi and /dev/null differ diff --git a/lib/matplotlib/tests/baseline_images/dviread/test.json b/lib/matplotlib/tests/baseline_images/dviread/test.json deleted file mode 100644 index 0809cb9531f1..000000000000 --- a/lib/matplotlib/tests/baseline_images/dviread/test.json +++ /dev/null @@ -1,94 +0,0 @@ -[ - { - "text": [ - [5046272, 4128768, "T", "cmr10", 9.96], - [5519588, 4128768, "h", "cmr10", 9.96], - [5883678, 4128768, "i", "cmr10", 9.96], - [6065723, 4128768, "s", "cmr10", 9.96], - [6542679, 4128768, "i", "cmr10", 9.96], - [6724724, 4128768, "s", "cmr10", 9.96], - [7201680, 4128768, "a", "cmr10", 9.96], - [7747814, 4128768, "L", "cmr10", 9.96], - [7921485, 3994421, "A", "cmr7", 6.97], - [8210032, 4128768, "T", "cmr10", 9.96], - [8574098, 4269852, "E", "cmr10", 9.96], - [8938188, 4128768, "X", "cmr10", 9.96], - [9648162, 4128768, "t", "cmr10", 9.96], - [9903025, 4128768, "e", "cmr10", 9.96], - [10194296, 4128768, "s", "cmr10", 9.96], - [10452799, 4128768, "t", "cmr10", 9.96], - [10926115, 4128768, "d", "cmr10", 9.96], - [11290205, 4128768, "o", "cmr10", 9.96], - [11636091, 4128768, "c", "cmr10", 9.96], - [11927362, 4128768, "u", "cmr10", 9.96], - [12291452, 4128768, "m", "cmr10", 9.96], - [12837587, 4128768, "e", "cmr10", 9.96], - [13128858, 4128768, "n", "cmr10", 9.96], - [13474743, 4128768, "t", "cmr10", 9.96], - [4063232, 4915200, "f", "cmr10", 9.96], - [4263482, 4915200, "o", "cmr10", 9.96], - [4591163, 4915200, "r", "cmr10", 9.96], - [5066299, 4915200, "t", "cmr10", 9.96], - [5321162, 4915200, "e", "cmr10", 9.96], - [5612433, 4915200, "s", "cmr10", 9.96], - [5870936, 4915200, "t", "cmr10", 9.96], - [6125799, 4915200, "i", "cmr10", 9.96], - [6307844, 4915200, "n", "cmr10", 9.96], - [6671934, 4915200, "g", "cmr10", 9.96], - [7218068, 4915200, "m", "cmr10", 9.96], - [7764203, 4915200, "a", "cmr10", 9.96], - [8091884, 4915200, "t", "cmr10", 9.96], - [8346747, 4915200, "p", "cmr10", 9.96], - [8710837, 4915200, "l", "cmr10", 9.96], - [8892882, 4915200, "o", "cmr10", 9.96], - [9220563, 4915200, "t", "cmr10", 9.96], - [9475426, 4915200, "l", "cmr10", 9.96], - [9657471, 4915200, "i", "cmr10", 9.96], - [9839516, 4915200, "b", "cmr10", 9.96], - [10203606, 4915200, "'", "cmr10", 9.96], - [10385651, 4915200, "s", "cmr10", 9.96], - [10862607, 4915200, "d", "cmr10", 9.96], - [11226697, 4915200, "v", "cmr10", 9.96], - [11572583, 4915200, "i", "cmr10", 9.96], - [11754628, 4915200, "r", "cmr10", 9.96], - [12011311, 4915200, "e", "cmr10", 9.96], - [12302582, 4915200, "a", "cmr10", 9.96], - [12630263, 4915200, "d", "cmr10", 9.96], - [13686591, 6629148, "\u0019", "cmmi5", 4.98], - [13717140, 6963172, "2", "cmr5", 4.98], - [13355327, 7035991, "Z", "cmex10", 9.96], - [13406754, 8897228, "0", "cmr7", 6.97], - [14010688, 7200560, "\u0010", "cmex10", 9.96], - [14937658, 7484660, "x", "cmmi10", 9.96], - [14480727, 8377560, "s", "cmr10", 9.96], - [14739230, 8377560, "i", "cmr10", 9.96], - [14921275, 8377560, "n", "cmr10", 9.96], - [15394589, 8377560, "x", "cmmi10", 9.96], - [15847788, 7200560, "\u0011", "cmex10", 9.96], - [16239184, 7336365, "2", "cmr7", 6.97], - [16642411, 7928016, "d", "cmr10", 9.96], - [17006501, 7928016, "x", "cmmi10", 9.96] - ], - "boxes": [ - [4063232, 5701632, 65536, 22609920], - [13686591, 6703682, 26213, 284106], - [14480727, 7777282, 26213, 1288418] - ] - }, - { - "text": [ - [5046272, 4128768, "a", "cmr10", 9.96], - [5373953, 4128768, "n", "cmr10", 9.96], - [5738043, 4128768, "o", "cmr10", 9.96], - [6065724, 4128768, "t", "cmr10", 9.96], - [6320587, 4128768, "h", "cmr10", 9.96], - [6684677, 4128768, "e", "cmr10", 9.96], - [6975948, 4128768, "r", "cmr10", 9.96], - [7451084, 4128768, "p", "cmr10", 9.96], - [7815174, 4128768, "a", "cmr10", 9.96], - [8142855, 4128768, "g", "cmr10", 9.96], - [8470536, 4128768, "e", "cmr10", 9.96] - ], - "boxes": [] - } -] diff --git a/lib/matplotlib/tests/baseline_images/dviread/test.tex b/lib/matplotlib/tests/baseline_images/dviread/test.tex index 33220fedae3e..4a2d4720c065 100644 --- a/lib/matplotlib/tests/baseline_images/dviread/test.tex +++ b/lib/matplotlib/tests/baseline_images/dviread/test.tex @@ -1,17 +1,19 @@ -% source file for test.dvi \documentclass{article} +\usepackage{iftex} +\iftutex\usepackage{fontspec}\fi % xetex or luatex \pagestyle{empty} + \begin{document} -This is a \LaTeX\ test document\\ -for testing matplotlib's dviread +A \LaTeX { + \iftutex\fontspec{DejaVuSans.ttf}[ + FakeSlant=0.25, FakeStretch=1.25, FakeBold=2.5, Color=0000FF]\fi + doc. +} -\noindent\rule{\textwidth}{1pt} +\newpage \[ \int\limits_0^{\frac{\pi}{2}} \Bigl(\frac{x}{\sin x}\Bigr)^2\,\mathrm{d}x \] \special{Special!} -\newpage -another page - \end{document} diff --git a/lib/matplotlib/tests/baseline_images/dviread/xelatex.json b/lib/matplotlib/tests/baseline_images/dviread/xelatex.json new file mode 100644 index 000000000000..8fb81ddf0c7e --- /dev/null +++ b/lib/matplotlib/tests/baseline_images/dviread/xelatex.json @@ -0,0 +1 @@ +[{"text": [[5046272, 4128768, "A", "lmroman10-regular.otf", 9.96, {}], [5756027, 4128768, "L", "lmroman10-regular.otf", 9.96, {}], [5929697, 4012179, "A", "lmroman7-regular.otf", 6.97, {}], [6218125, 4128768, "T", "lmroman10-regular.otf", 9.96, {}], [6582045, 4269998, "E", "lmroman10-regular.otf", 9.96, {}], [6946425, 4128768, "X", "lmroman10-regular.otf", 9.96, {}], [7656180, 4128768, "d", "DejaVuSans.ttf", 9.96, {"rgba": [0, 0, 255, 255], "extend": 1.25, "slant": 0.25, "embolden": 0.25}], [8176180, 4128768, "o", "DejaVuSans.ttf", 9.96, {"rgba": [0, 0, 255, 255], "extend": 1.25, "slant": 0.25, "embolden": 0.25}], [8677380, 4128768, "c", "DejaVuSans.ttf", 9.96, {"rgba": [0, 0, 255, 255], "extend": 1.25, "slant": 0.25, "embolden": 0.25}], [9127780, 4128768, ".", "DejaVuSans.ttf", 9.96, {"rgba": [0, 0, 255, 255], "extend": 1.25, "slant": 0.25, "embolden": 0.25}]], "boxes": []}, {"text": [[13686374, 5056284, "\u03c0", "cmmi5.pfb", 4.98, {}], [13716923, 5390308, "2", "cmr5.pfb", 4.98, {}], [13355110, 5463127, "integraldisplay", "cmex10.pfb", 9.96, {}], [13406537, 7324364, "0", "cmr7.pfb", 6.97, {}], [14010471, 5627696, "parenleftBig", "cmex10.pfb", 9.96, {}], [14937513, 5911796, "x", "cmmi10.pfb", 9.96, {}], [14480510, 6804696, "s", "lmroman10-regular.otf", 9.96, {}], [14738722, 6804696, "i", "lmroman10-regular.otf", 9.96, {}], [14920912, 6804696, "n", "lmroman10-regular.otf", 9.96, {}], [15394516, 6804696, "x", "cmmi10.pfb", 9.96, {}], [15847715, 5627696, "parenrightBig", "cmex10.pfb", 9.96, {}], [16239111, 5763501, "2", "cmr7.pfb", 6.97, {}], [16642338, 6355152, "d", "lmroman10-regular.otf", 9.96, {}], [17006718, 6355152, "x", "cmmi10.pfb", 9.96, {}]], "boxes": [[13686374, 5130818, 26213, 284106], [14480510, 6204418, 26213, 1288562]]}] diff --git a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/test_compressed_suptitle_colorbar.png b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/test_compressed_suptitle_colorbar.png new file mode 100644 index 000000000000..582872077ee3 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/test_compressed_suptitle_colorbar.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_constrainedlayout/test_compressed_supylabel_colorbar.png b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/test_compressed_supylabel_colorbar.png new file mode 100644 index 000000000000..9754ac57ad65 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_constrainedlayout/test_compressed_supylabel_colorbar.png differ diff --git a/lib/matplotlib/tests/test__style_helpers.py b/lib/matplotlib/tests/test__style_helpers.py new file mode 100644 index 000000000000..764bd5a0c88e --- /dev/null +++ b/lib/matplotlib/tests/test__style_helpers.py @@ -0,0 +1,83 @@ +import pytest + +import matplotlib.colors as mcolors +from matplotlib.lines import _get_dash_pattern +from matplotlib._style_helpers import style_generator + + +@pytest.mark.parametrize('key, value', [('facecolor', ["b", "g", "r"]), + ('edgecolor', ["b", "g", "r"]), + ('hatch', ["/", "\\", "."]), + ('linestyle', ["-", "--", ":"]), + ('linewidth', [1, 1.5, 2])]) +def test_style_generator_list(key, value): + """Test that style parameter lists are distributed to the generator.""" + kw = {'foo': 12, key: value} + new_kw, gen = style_generator(kw) + + assert new_kw == {'foo': 12} + + for v in value * 2: # Result should repeat + style_dict = next(gen) + assert len(style_dict) == 1 + if key.endswith('color'): + assert mcolors.same_color(v, style_dict[key]) + elif key == 'linestyle': + assert _get_dash_pattern(v) == style_dict[key] + else: + assert v == style_dict[key] + + +@pytest.mark.parametrize('key, value', [('facecolor', "b"), + ('edgecolor', "b"), + ('hatch', "/"), + ('linestyle', "-"), + ('linewidth', 1)]) +def test_style_generator_single(key, value): + """Test that single-value style parameters are distributed to the generator.""" + kw = {'foo': 12, key: value} + new_kw, gen = style_generator(kw) + + assert new_kw == {'foo': 12} + for _ in range(2): # Result should repeat + style_dict = next(gen) + if key.endswith('color'): + assert mcolors.same_color(value, style_dict[key]) + elif key == 'linestyle': + assert _get_dash_pattern(value) == style_dict[key] + else: + assert value == style_dict[key] + + +@pytest.mark.parametrize('key', ['facecolor', 'hatch', 'linestyle']) +def test_style_generator_raises_on_empty_style_parameter_list(key): + kw = {key: []} + with pytest.raises(TypeError, match=f'{key} must not be an empty sequence'): + style_generator(kw) + + +def test_style_generator_sequence_type_styles(): + """ + Test that sequence type style values are detected as single value + and passed to a all elements of the generator. + """ + kw = {'facecolor': ('r', 0.5), + 'edgecolor': [0.5, 0.5, 0.5], + 'linestyle': (0, (1, 1))} + + _, gen = style_generator(kw) + for _ in range(2): # Result should repeat + style_dict = next(gen) + mcolors.same_color(kw['facecolor'], style_dict['facecolor']) + mcolors.same_color(kw['edgecolor'], style_dict['edgecolor']) + kw['linestyle'] == style_dict['linestyle'] + + +def test_style_generator_none(): + kw = {'facecolor': 'none', + 'edgecolor': 'none'} + _, gen = style_generator(kw) + for _ in range(2): # Result should repeat + style_dict = next(gen) + assert style_dict['facecolor'] == 'none' + assert style_dict['edgecolor'] == 'none' diff --git a/lib/matplotlib/tests/test_api.py b/lib/matplotlib/tests/test_api.py index 58e7986bfce6..4d0241264ddb 100644 --- a/lib/matplotlib/tests/test_api.py +++ b/lib/matplotlib/tests/test_api.py @@ -150,3 +150,8 @@ def f() -> None: def test_empty_check_in_list() -> None: with pytest.raises(TypeError, match="No argument to check!"): _api.check_in_list(["a"]) + + +def test_check_in_list_numpy() -> None: + with pytest.raises(ValueError, match=r"array\(5\) is not a valid value"): + _api.check_in_list(['a', 'b'], value=np.array(5)) diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 0c445f86d9aa..6e839ef2f189 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -8,8 +8,10 @@ import io from itertools import product import platform +import re import sys from types import SimpleNamespace +import unittest.mock import dateutil.tz @@ -44,7 +46,6 @@ from matplotlib.testing.decorators import ( image_comparison, check_figures_equal, remove_ticks_and_titles) from matplotlib.testing._markers import needs_usetex - # Note: Some test cases are run twice: once normally and once with labeled data # These two must be defined in the same test function or need to have # different baseline images to prevent race conditions when pytest runs @@ -1382,7 +1383,8 @@ def test_pcolorargs_5205(): plt.pcolor(X, Y, list(Z[:-1, :-1])) -@image_comparison(['pcolormesh'], remove_text=True) +@image_comparison(['pcolormesh'], remove_text=True, + tol=0.11 if platform.machine() == 'aarch64' else 0) def test_pcolormesh(): # Remove this line when this test image is regenerated. plt.rcParams['pcolormesh.snap'] = False @@ -1434,7 +1436,7 @@ def test_pcolormesh_small(): @image_comparison(['pcolormesh_alpha'], extensions=["png", "pdf"], remove_text=True, - tol=0.2 if platform.machine() == "aarch64" else 0) + tol=0.4 if platform.machine() == "aarch64" else 0) def test_pcolormesh_alpha(): # Remove this line when this test image is regenerated. plt.rcParams['pcolormesh.snap'] = False @@ -2266,6 +2268,20 @@ def test_grouped_bar_return_value(): assert bc not in ax.containers +def test_grouped_bar_hatch_sequence(): + """Each dataset should receive its own hatch pattern when a sequence is passed.""" + fig, ax = plt.subplots() + x = np.arange(2) + heights = [np.array([1, 2]), np.array([2, 3]), np.array([3, 4])] + hatches = ['//', 'xx', '..'] + containers = ax.grouped_bar(heights, positions=x, hatch=hatches) + + # Verify each dataset gets the corresponding hatch + for hatch, c in zip(hatches, containers.bar_containers): + for rect in c: + assert rect.get_hatch() == hatch + + def test_boxplot_dates_pandas(pd): # smoke test for boxplot and dates in pandas data = np.random.rand(5, 2) @@ -2836,6 +2852,16 @@ def test_hist2d_density(): obj.hist2d(x, y, density=True) +@mpl.style.context("mpl20") +def test_hist2d_autolimits(): + x, y = np.random.random((2, 100)) + ax = plt.figure().add_subplot() + ax.hist2d(x, y) + assert ax.get_xlim() == (x.min(), x.max()) + assert ax.get_ylim() == (y.min(), y.max()) + assert ax.get_autoscale_on() # Autolimits have not been disabled. + + class TestScatter: @image_comparison(['scatter'], style='mpl20', remove_text=True) def test_scatter_plot(self): @@ -2946,11 +2972,11 @@ def test_scatter_unfillable(self): def test_scatter_size_arg_size(self): x = np.arange(4) - with pytest.raises(ValueError, match='same size as x and y'): + with pytest.raises(ValueError, match='cannot be broadcast to match x and y'): plt.scatter(x, x, x[1:]) - with pytest.raises(ValueError, match='same size as x and y'): + with pytest.raises(ValueError, match='cannot be broadcast to match x and y'): plt.scatter(x[1:], x[1:], x) - with pytest.raises(ValueError, match='float array-like'): + with pytest.raises(ValueError, match='must be float'): plt.scatter(x, x, 'foo') def test_scatter_edgecolor_RGB(self): @@ -2965,8 +2991,7 @@ def test_scatter_edgecolor_RGB(self): @check_figures_equal() def test_scatter_invalid_color(self, fig_test, fig_ref): ax = fig_test.subplots() - cmap = mpl.colormaps["viridis"].resampled(16) - cmap.set_bad("k", 1) + cmap = mpl.colormaps["viridis"].resampled(16).with_extremes(bad="black") # Set a nonuniform size to prevent the last call to `scatter` (plotting # the invalid points separately in fig_ref) from using the marker # stamping fast path, which would result in slightly offset markers. @@ -2982,8 +3007,7 @@ def test_scatter_invalid_color(self, fig_test, fig_ref): def test_scatter_no_invalid_color(self, fig_test, fig_ref): # With plotnonfinite=False we plot only 2 points. ax = fig_test.subplots() - cmap = mpl.colormaps["viridis"].resampled(16) - cmap.set_bad("k", 1) + cmap = mpl.colormaps["viridis"].resampled(16).with_extremes(bad="k") ax.scatter(range(4), range(4), c=[1, np.nan, 2, np.nan], s=[1, 2, 3, 4], cmap=cmap, plotnonfinite=False) @@ -3420,6 +3444,26 @@ def test_stackplot_hatching(fig_ref, fig_test): ax_ref.set_ylim(0, 70) +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 + + facecolors = ['r', 'b'] + + fig, ax = plt.subplots() + + 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) + + def test_stackplot_subfig_legend(): # Smoke test for https://github.com/matplotlib/matplotlib/issues/30158 @@ -4172,6 +4216,10 @@ def color_violins(parts, facecolor=None, linecolor=None): if facecolor is not None: for pc in parts['bodies']: pc.set_facecolor(facecolor) + # disable alpha Artist property to counter the legacy behavior + # that applies an alpha of 0.3 to the bodies if no facecolor + # was set + pc.set_alpha(None) if linecolor is not None: for partname in ('cbars', 'cmins', 'cmaxes', 'cmeans', 'cmedians'): if partname in parts: @@ -4229,6 +4277,33 @@ def assert_colors_equal(colors1, colors2): assert_colors_equal(colors_test, mcolors.to_rgba_array(linecolors)) +def test_violinplot_alpha(): + matplotlib.style.use('default') + data = [(np.random.normal(0, 1, 100))] + + fig, ax = plt.subplots() + parts = ax.violinplot(data, positions=[1]) + + # Case 1: If facecolor is unspecified, it's the first color from the color cycle + # with Artist-level alpha=0.3 + facecolor = ('y' if mpl.rcParams['_internal.classic_mode'] + else plt.rcParams['axes.prop_cycle'].by_key()['color'][0]) + assert mcolors.same_color(parts['bodies'][0].get_facecolor(), (facecolor, 0.3)) + assert parts['bodies'][0].get_alpha() == 0.3 + # setting a new facecolor maintains the alpha + parts['bodies'][0].set_facecolor('red') + assert mcolors.same_color(parts['bodies'][0].get_facecolor(), ('red', 0.3)) + + # Case 2: If facecolor is explicitly given, it's alpha does not become an + # Artist property + parts = ax.violinplot(data, positions=[1], facecolor=('blue', 0.3)) + assert mcolors.same_color(parts['bodies'][0].get_facecolor(), ('blue', 0.3)) + assert parts['bodies'][0].get_alpha() is None + # so setting a new color does not maintain the alpha + parts['bodies'][0].set_facecolor('red') + assert mcolors.same_color(parts['bodies'][0].get_facecolor(), 'red') + + @check_figures_equal() def test_violinplot_single_list_quantiles(fig_test, fig_ref): # Ensures quantile list for 1D can be passed in as single list @@ -5005,27 +5080,6 @@ def test_hist_vectorized_params(fig_test, fig_ref, kwargs): zorder=(len(xs)-i)/2) -def test_hist_sequence_type_styles(): - facecolor = ('r', 0.5) - edgecolor = [0.5, 0.5, 0.5] - linestyle = (0, (1, 1)) - - arr = np.random.uniform(size=50) - _, _, bars = plt.hist(arr, facecolor=facecolor, edgecolor=edgecolor, - linestyle=linestyle) - assert mcolors.same_color(bars[0].get_facecolor(), facecolor) - assert mcolors.same_color(bars[0].get_edgecolor(), edgecolor) - assert bars[0].get_linestyle() == linestyle - - -def test_hist_color_none(): - arr = np.random.uniform(size=50) - # No edgecolor is the default but check that it can be explicitly passed. - _, _, bars = plt.hist(arr, facecolor='none', edgecolor='none') - assert bars[0].get_facecolor(), (0, 0, 0, 0) - assert bars[0].get_edgecolor(), (0, 0, 0, 0) - - @pytest.mark.parametrize('kwargs, patch_face, patch_edge', # 'C0'(blue) stands for the first color of the # default color cycle as well as the patch.facecolor rcParam @@ -6568,6 +6622,57 @@ def test_pie_hatch_multi(fig_test, fig_ref): [w.set_hatch(hp) for w, hp in zip(wedges, hatch)] +def test_pie_label_formatter(): + fig, ax = plt.subplots() + pie = ax.pie([2, 3]) + + texts = ax.pie_label(pie, '{absval:03d}') + assert texts[0].get_text() == '002' + assert texts[1].get_text() == '003' + + texts = ax.pie_label(pie, '{frac:.1%}') + assert texts[0].get_text() == '40.0%' + assert texts[1].get_text() == '60.0%' + + +@pytest.mark.parametrize('distance', [0.6, 1.1]) +@pytest.mark.parametrize('rotate', [False, True]) +def test_pie_label_auto_align(distance, rotate): + fig, ax = plt.subplots() + pie = ax.pie([1, 1], startangle=45) + + texts = ax.pie_label( + pie, ['spam', 'eggs'], distance=distance, rotate=rotate, alignment='auto') + + if distance < 1: + for text in texts: + # labels within the pie should be centered + assert text.get_horizontalalignment() == 'center' + assert text.get_verticalalignment() == 'center' + + else: + # labels outside the pie should be aligned away from it + h_expected = ['right', 'left'] + v_expected = ['bottom', 'top'] + for text, h_align, v_align in zip(texts, h_expected, v_expected): + assert text.get_horizontalalignment() == h_align + if rotate: + assert text.get_verticalalignment() == v_align + else: + assert text.get_verticalalignment() == 'center' + + +def test_pie_label_fail(): + sizes = 15, 30, 45, 10 + labels = 'Frogs', 'Hogs' + fig, ax = plt.subplots() + pie = ax.pie(sizes) + + match = re.escape("The number of labels (2) must match the number of wedges (4)") + with pytest.raises(ValueError, match=match): + ax.pie_label(pie, labels) + + @image_comparison(['set_get_ticklabels.png'], tol=0 if platform.machine() == 'x86_64' else 0.025) def test_set_get_ticklabels(): @@ -8250,10 +8355,9 @@ def color_boxes(fig, ax): """ fig.canvas.draw() - renderer = fig.canvas.get_renderer() bbaxis = [] for nn, axx in enumerate([ax.xaxis, ax.yaxis]): - bb = axx.get_tightbbox(renderer) + bb = axx.get_tightbbox() if bb: axisr = mpatches.Rectangle( (bb.x0, bb.y0), width=bb.width, height=bb.height, @@ -8264,7 +8368,7 @@ def color_boxes(fig, ax): bbspines = [] for nn, a in enumerate(['bottom', 'top', 'left', 'right']): - bb = ax.spines[a].get_window_extent(renderer) + bb = ax.spines[a].get_window_extent() spiner = mpatches.Rectangle( (bb.x0, bb.y0), width=bb.width, height=bb.height, linewidth=0.7, edgecolor="green", facecolor="none", transform=None, @@ -8280,7 +8384,7 @@ def color_boxes(fig, ax): fig.add_artist(rect2) bbax = bb - bb2 = ax.get_tightbbox(renderer) + bb2 = ax.get_tightbbox() rect2 = mpatches.Rectangle( (bb2.x0, bb2.y0), width=bb2.width, height=bb2.height, linewidth=3, edgecolor="red", facecolor="none", transform=None, @@ -9065,7 +9169,7 @@ def test_patch_bounds(): # PR 19078 @mpl.style.context('default') def test_warn_ignored_scatter_kwargs(): with pytest.warns(UserWarning, - match=r"You passed a edgecolor/edgecolors"): + match=r"You passed an edgecolor/edgecolors"): plt.scatter([0], [0], marker="+", s=500, facecolor="r", edgecolor="b") @@ -9927,3 +10031,20 @@ 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"]) + + +def test_animated_artists_not_drawn_by_default(): + fig, (ax1, ax2) = plt.subplots(ncols=2) + + imdata = np.random.random((20, 20)) + lndata = imdata[0] + + im = ax1.imshow(imdata, animated=True) + (ln,) = ax2.plot(lndata, animated=True) + + with (unittest.mock.patch.object(im, "draw", name="im.draw") as mocked_im_draw, + unittest.mock.patch.object(ln, "draw", name="ln.draw") as mocked_ln_draw): + fig.draw_without_rendering() + + mocked_im_draw.assert_not_called() + mocked_ln_draw.assert_not_called() diff --git a/lib/matplotlib/tests/test_axis.py b/lib/matplotlib/tests/test_axis.py index 97884a33208f..67d9ed5bde62 100644 --- a/lib/matplotlib/tests/test_axis.py +++ b/lib/matplotlib/tests/test_axis.py @@ -2,6 +2,7 @@ import matplotlib.pyplot as plt from matplotlib.axis import XTick +from matplotlib.testing.decorators import check_figures_equal def test_tick_labelcolor_array(): @@ -31,6 +32,21 @@ def test_axis_not_in_layout(): assert ax1_right.get_position().bounds == ax2_right.get_position().bounds +@check_figures_equal() +def test_tick_not_in_layout(fig_test, fig_ref): + # Check that the "very long" ticklabel is ignored from layouting after + # set_in_layout(False); i.e. the layout is as if the ticklabel was empty. + # Ticklabels are set to white so that the actual string doesn't matter. + fig_test.set_layout_engine("constrained") + ax = fig_test.add_subplot(xticks=[0, 1], xticklabels=["short", "very long"]) + ax.tick_params(labelcolor="w") + fig_test.draw_without_rendering() # Ensure ticks are correct. + ax.xaxis.majorTicks[-1].label1.set_in_layout(False) + fig_ref.set_layout_engine("constrained") + ax = fig_ref.add_subplot(xticks=[0, 1], xticklabels=["short", ""]) + ax.tick_params(labelcolor="w") + + def test_translate_tick_params_reverse(): fig, ax = plt.subplots() kw = {'label1On': 'a', 'label2On': 'b', 'tick1On': 'c', 'tick2On': 'd'} diff --git a/lib/matplotlib/tests/test_backend_gtk3.py b/lib/matplotlib/tests/test_backend_gtk3.py index b4c6e3d7fca8..a299d21a4b7b 100644 --- a/lib/matplotlib/tests/test_backend_gtk3.py +++ b/lib/matplotlib/tests/test_backend_gtk3.py @@ -5,51 +5,6 @@ from unittest import mock -@pytest.mark.backend("gtk3agg", skip_on_importerror=True) -def test_correct_key(): - pytest.xfail("test_widget_send_event is not triggering key_press_event") - - from gi.repository import Gdk, Gtk # type: ignore[import] - fig = plt.figure() - buf = [] - - def send(event): - for key, mod in [ - (Gdk.KEY_a, Gdk.ModifierType.SHIFT_MASK), - (Gdk.KEY_a, 0), - (Gdk.KEY_a, Gdk.ModifierType.CONTROL_MASK), - (Gdk.KEY_agrave, 0), - (Gdk.KEY_Control_L, Gdk.ModifierType.MOD1_MASK), - (Gdk.KEY_Alt_L, Gdk.ModifierType.CONTROL_MASK), - (Gdk.KEY_agrave, - Gdk.ModifierType.CONTROL_MASK - | Gdk.ModifierType.MOD1_MASK - | Gdk.ModifierType.MOD4_MASK), - (0xfd16, 0), # KEY_3270_Play. - (Gdk.KEY_BackSpace, 0), - (Gdk.KEY_BackSpace, Gdk.ModifierType.CONTROL_MASK), - ]: - # This is not actually really the right API: it depends on the - # actual keymap (e.g. on Azerty, shift+agrave -> 0). - Gtk.test_widget_send_key(fig.canvas, key, mod) - - def receive(event): - buf.append(event.key) - if buf == [ - "A", "a", "ctrl+a", - "\N{LATIN SMALL LETTER A WITH GRAVE}", - "alt+control", "ctrl+alt", - "ctrl+alt+super+\N{LATIN SMALL LETTER A WITH GRAVE}", - # (No entry for KEY_3270_Play.) - "backspace", "ctrl+backspace", - ]: - plt.close(fig) - - fig.canvas.mpl_connect("draw_event", send) - fig.canvas.mpl_connect("key_press_event", receive) - plt.show() - - @pytest.mark.backend("gtk3agg", skip_on_importerror=True) def test_save_figure_return(): from gi.repository import Gtk diff --git a/lib/matplotlib/tests/test_backend_macosx.py b/lib/matplotlib/tests/test_backend_macosx.py index fe4c9a6fba3c..0648e43cde94 100644 --- a/lib/matplotlib/tests/test_backend_macosx.py +++ b/lib/matplotlib/tests/test_backend_macosx.py @@ -1,4 +1,5 @@ import os +import threading from pathlib import Path import pytest @@ -84,3 +85,25 @@ def _test_save_figure_return(): def test_save_figure_return(): subprocess_run_helper(_test_save_figure_return, timeout=_test_timeout, extra_env={"MPLBACKEND": "macosx"}) + + +def _test_create_figure_on_worker_thread_fails(): + def create_figure(): + warn_msg = "Matplotlib GUI outside of the main thread will likely fail." + err_msg = "Cannot create a GUI FigureManager outside the main thread" + with pytest.warns(UserWarning, match=warn_msg): + with pytest.raises(RuntimeError, match=err_msg): + plt.gcf() + + worker = threading.Thread(target=create_figure) + worker.start() + worker.join() + + +@pytest.mark.backend('macosx', skip_on_importerror=True) +def test_create_figure_on_worker_thread_fails(): + subprocess_run_helper( + _test_create_figure_on_worker_thread_fails, + timeout=_test_timeout, + extra_env={"MPLBACKEND": "macosx"} + ) diff --git a/lib/matplotlib/tests/test_backend_qt.py b/lib/matplotlib/tests/test_backend_qt.py index 3f34a58a765d..fda0f978ea02 100644 --- a/lib/matplotlib/tests/test_backend_qt.py +++ b/lib/matplotlib/tests/test_backend_qt.py @@ -194,6 +194,10 @@ def set_device_pixel_ratio(ratio): assert qt_canvas.get_width_height() == (600, 240) assert (fig.get_size_inches() == (5, 2)).all() + # check that closing the figure restores the original dpi + plt.close(fig) + assert fig.dpi == 120 + @pytest.mark.backend('QtAgg', skip_on_importerror=True) def test_subplottool(): @@ -215,14 +219,15 @@ def test_figureoptions(): @pytest.mark.backend('QtAgg', skip_on_importerror=True) -def test_save_figure_return(): +def test_save_figure_return(tmp_path): fig, ax = plt.subplots() ax.imshow([[1]]) + expected = tmp_path / "foobar.png" prop = "matplotlib.backends.qt_compat.QtWidgets.QFileDialog.getSaveFileName" - with mock.patch(prop, return_value=("foobar.png", None)): + with mock.patch(prop, return_value=(str(expected), None)): fname = fig.canvas.manager.toolbar.save_figure() - os.remove("foobar.png") - assert fname == "foobar.png" + assert fname == str(expected) + assert expected.exists() with mock.patch(prop, return_value=(None, None)): fname = fig.canvas.manager.toolbar.save_figure() assert fname is None diff --git a/lib/matplotlib/tests/test_backends_interactive.py b/lib/matplotlib/tests/test_backends_interactive.py index 9725a79397bc..6bc7de433825 100644 --- a/lib/matplotlib/tests/test_backends_interactive.py +++ b/lib/matplotlib/tests/test_backends_interactive.py @@ -107,13 +107,7 @@ def _get_available_interactive_backends(): elif env["MPLBACKEND"].startswith('wx') and sys.platform == 'darwin': # ignore on macosx because that's currently broken (github #16849) marks.append(pytest.mark.xfail(reason='github #16849')) - elif (env['MPLBACKEND'] == 'tkagg' and - ('TF_BUILD' in os.environ or 'GITHUB_ACTION' in os.environ) and - sys.platform == 'darwin' and - sys.version_info[:2] < (3, 11) - ): - marks.append( # https://github.com/actions/setup-python/issues/649 - pytest.mark.xfail(reason='Tk version mismatch on Azure macOS CI')) + envs.append(({**env, 'BACKEND_DEPS': ','.join(deps)}, marks)) return envs @@ -163,7 +157,7 @@ def _test_interactive_impl(): import matplotlib as mpl from matplotlib import pyplot as plt - from matplotlib.backend_bases import KeyEvent + from matplotlib.backend_bases import KeyEvent, FigureCanvasBase mpl.rcParams.update({ "webagg.open_in_browser": False, "webagg.port_retries": 1, @@ -221,19 +215,23 @@ def check_alt_backend(alt_backend): fig.canvas.mpl_connect("close_event", print) result = io.BytesIO() - fig.savefig(result, format='png') + fig.savefig(result, format='png', dpi=100) plt.show() # Ensure that the window is really closed. plt.pause(0.5) - # Test that saving works after interactive window is closed, but the figure - # is not deleted. + # When the figure is closed, its manager is removed and the canvas is reset to + # FigureCanvasBase. Saving should still be possible. + assert type(fig.canvas) == FigureCanvasBase, str(fig.canvas) result_after = io.BytesIO() - fig.savefig(result_after, format='png') + fig.savefig(result_after, format='png', dpi=100) - assert result.getvalue() == result_after.getvalue() + if backend.endswith("agg"): + # agg-based interactive backends should save the same image as a non-interactive + # figure + assert result.getvalue() == result_after.getvalue() @pytest.mark.parametrize("env", _get_testable_interactive_backends()) @@ -286,10 +284,13 @@ def _test_thread_impl(): future = ThreadPoolExecutor().submit(fig.canvas.draw) plt.pause(0.5) # flush_events fails here on at least Tkagg (bpo-41176) future.result() # Joins the thread; rethrows any exception. + # stash the current canvas as closing the figure will reset the canvas on + # the figure + canvas = fig.canvas plt.close() # backend is responsible for flushing any events here if plt.rcParams["backend"].lower().startswith("wx"): # TODO: debug why WX needs this only on py >= 3.8 - fig.canvas.flush_events() + canvas.flush_events() _thread_safe_backends = _get_testable_interactive_backends() diff --git a/lib/matplotlib/tests/test_bbox_tight.py b/lib/matplotlib/tests/test_bbox_tight.py index 431ca70bf7ea..9d430b78d5de 100644 --- a/lib/matplotlib/tests/test_bbox_tight.py +++ b/lib/matplotlib/tests/test_bbox_tight.py @@ -110,7 +110,7 @@ def test_bbox_inches_tight_clipping(): plt.gcf().artists.append(patch) -@image_comparison(['bbox_inches_tight_raster'], +@image_comparison(['bbox_inches_tight_raster'], tol=0.15, # For Ghostscript 10.06+. remove_text=True, savefig_kwarg={'bbox_inches': 'tight'}) def test_bbox_inches_tight_raster(): """Test rasterization with tight_layout""" diff --git a/lib/matplotlib/tests/test_colorbar.py b/lib/matplotlib/tests/test_colorbar.py index ba20f325f4d7..614c7ae5c20c 100644 --- a/lib/matplotlib/tests/test_colorbar.py +++ b/lib/matplotlib/tests/test_colorbar.py @@ -946,9 +946,10 @@ def test_proportional_colorbars(): levels = [-1.25, -0.5, -0.125, 0.125, 0.5, 1.25] cmap = mcolors.ListedColormap( - ['0.3', '0.5', 'white', 'lightblue', 'steelblue']) - cmap.set_under('darkred') - cmap.set_over('crimson') + ['0.3', '0.5', 'white', 'lightblue', 'steelblue'], + under='darkred', + over='crimson', + ) norm = mcolors.BoundaryNorm(levels, cmap.N) extends = ['neither', 'both'] diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py index 42f364848b66..ee6e35f580a4 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -53,13 +53,9 @@ def test_resampled(): colorlist[:, 1] = 0.2 colorlist[:, 2] = np.linspace(1, 0, n) colorlist[:, 3] = 0.7 - lsc = mcolors.LinearSegmentedColormap.from_list('lsc', colorlist) - lc = mcolors.ListedColormap(colorlist) - # Set some bad values for testing too - for cmap in [lsc, lc]: - cmap.set_under('r') - cmap.set_over('g') - cmap.set_bad('b') + lsc = mcolors.LinearSegmentedColormap.from_list( + 'lsc', colorlist, under='red', over='green', bad='blue') + lc = mcolors.ListedColormap(colorlist, under='red', over='green', bad='blue') lsc3 = lsc.resampled(3) lc3 = lc.resampled(3) expected = np.array([[0.0, 0.2, 1.0, 0.7], @@ -115,7 +111,8 @@ def test_colormap_copy(): with np.errstate(invalid='ignore'): ret1 = copied_cmap([-1, 0, .5, 1, np.nan, np.inf]) cmap2 = copy.copy(copied_cmap) - cmap2.set_bad('g') + with pytest.warns(PendingDeprecationWarning): + cmap2.set_bad('g') with np.errstate(invalid='ignore'): ret2 = copied_cmap([-1, 0, .5, 1, np.nan, np.inf]) assert_array_equal(ret1, ret2) @@ -125,7 +122,8 @@ def test_colormap_copy(): with np.errstate(invalid='ignore'): ret1 = copied_cmap([-1, 0, .5, 1, np.nan, np.inf]) cmap2 = copy.copy(copied_cmap) - cmap2.set_bad('g') + with pytest.warns(PendingDeprecationWarning): + cmap2.set_bad('g') with np.errstate(invalid='ignore'): ret2 = copied_cmap([-1, 0, .5, 1, np.nan, np.inf]) assert_array_equal(ret1, ret2) @@ -139,7 +137,8 @@ def test_colormap_equals(): # But the same data should be equal assert cm_copy == cmap # Change the copy - cm_copy.set_bad('y') + with pytest.warns(PendingDeprecationWarning): + cm_copy.set_bad('y') assert cm_copy != cmap # Make sure we can compare different sizes without failure cm_copy._lut = cm_copy._lut[:10, :] @@ -371,9 +370,7 @@ def test_BoundaryNorm(): assert_array_equal(mynorm(x), ref) # Without interpolation - cmref = mcolors.ListedColormap(['blue', 'red']) - cmref.set_over('black') - cmref.set_under('white') + cmref = mcolors.ListedColormap(['blue', 'red'], under='white', over='black') cmshould = mcolors.ListedColormap(['white', 'blue', 'red', 'black']) assert mcolors.same_color(cmref.get_over(), 'black') @@ -395,8 +392,7 @@ def test_BoundaryNorm(): assert_array_equal(cmshould(mynorm(x)), cmref(refnorm(x))) # Just min - cmref = mcolors.ListedColormap(['blue', 'red']) - cmref.set_under('white') + cmref = mcolors.ListedColormap(['blue', 'red'], under='white') cmshould = mcolors.ListedColormap(['white', 'blue', 'red']) assert mcolors.same_color(cmref.get_under(), 'white') @@ -413,8 +409,7 @@ def test_BoundaryNorm(): assert_array_equal(cmshould(mynorm(x)), cmref(refnorm(x))) # Just max - cmref = mcolors.ListedColormap(['blue', 'red']) - cmref.set_over('black') + cmref = mcolors.ListedColormap(['blue', 'red'], over='black') cmshould = mcolors.ListedColormap(['blue', 'red', 'black']) assert mcolors.same_color(cmref.get_over(), 'black') @@ -928,7 +923,7 @@ def test_cmap_and_norm_from_levels_and_colors2(): for extend, i1, cases in tests: cmap, norm = mcolors.from_levels_and_colors(levels, colors[0:i1], extend=extend) - cmap.set_bad(bad) + cmap = cmap.with_extremes(bad=bad) for d_val, expected_color in cases.items(): if d_val == masked_value: d_val = np.ma.array([1], mask=True) @@ -952,6 +947,11 @@ def test_rgb_hsv_round_trip(): tt, mcolors.rgb_to_hsv(mcolors.hsv_to_rgb(tt))) +def test_rgb_to_hsv_int(): + # Test that int rgb values (still range 0-1) are processed correctly. + assert_array_equal(mcolors.rgb_to_hsv((0, 1, 0)), (1/3, 1, 1)) # green + + def test_autoscale_masked(): # Test for #2336. Previously fully masked data would trigger a ValueError. data = np.ma.masked_all((12, 20)) @@ -1430,7 +1430,7 @@ def test_scalarmappable_nan_to_rgba(bytes): # Out-of-range fail x[1, 0, 0] = 42 - with pytest.raises(ValueError, match='0..1 range'): + with pytest.raises(ValueError, match=r'\[0,1\] range'): sm.to_rgba(x[..., :3], bytes=bytes) @@ -1543,7 +1543,8 @@ def test_get_under_over_bad(): def test_non_mutable_get_values(kind): cmap = copy.copy(mpl.colormaps['viridis']) init_value = getattr(cmap, f'get_{kind}')() - getattr(cmap, f'set_{kind}')('k') + with pytest.warns(PendingDeprecationWarning): + getattr(cmap, f'set_{kind}')('k') black_value = getattr(cmap, f'get_{kind}')() assert np.all(black_value == [0, 0, 0, 1]) assert not np.all(init_value == black_value) @@ -1868,6 +1869,7 @@ def autoscale_None(self, A): def scaled(self): return True + @property def n_components(self): return 1 @@ -2020,7 +2022,7 @@ def test_mult_norm_call_types(): [[0., 0.5, 1.], [1, 3, 5]]) assert no_norm_out[0].dtype == np.dtype('float64') - assert no_norm_out[1].dtype == np.dtype('int64') + assert no_norm_out[1].dtype == vals.dtype # test with NoNorm, structured array as input mn_no_norm = mpl.colors.MultiNorm(['linear', mcolors.NoNorm()]) @@ -2060,3 +2062,189 @@ def test_mult_norm_call_types(): with pytest.raises(ValueError, match="but got 5, data) + mdata = mcolorizer._ensure_multivariate_data(data, 2) + assert np.all(mdata["f0"].mask[:2] == 0) + assert np.all(mdata["f0"].mask[2:] == 1) + assert np.all(mdata["f1"].mask[:2] == 0) + assert np.all(mdata["f1"].mask[2:] == 1) + + # test tuple of data + data = [0, 1] + mdata = mcolorizer._ensure_multivariate_data(data, 2) + assert mdata.shape == () + + # test wrong input size + data = [[0, 1]] + with pytest.raises(ValueError, match="must contain complex numbers"): + mcolorizer._ensure_multivariate_data(data, 2) + data = [[0, 1]] + with pytest.raises(ValueError, match="have a first dimension 3"): + mcolorizer._ensure_multivariate_data(data, 3) + + # test input of ints as list of lists + data = [[0, 0, 0], [1, 1, 1]] + mdata = mcolorizer._ensure_multivariate_data(data, 2) + assert mdata.shape == (3,) + assert mdata.dtype.fields['f0'][0] == np.int64 + assert mdata.dtype.fields['f1'][0] == np.int64 + + # test input of floats, ints as tuple of lists + data = ([0.0, 0.0], [1, 1]) + mdata = mcolorizer._ensure_multivariate_data(data, 2) + assert mdata.shape == (2,) + assert mdata.dtype.fields['f0'][0] == np.float64 + assert mdata.dtype.fields['f1'][0] == np.int64 + + # test input of array of floats + data = np.array([[0.0, 0, 0], [1, 1, 1]]) + mdata = mcolorizer._ensure_multivariate_data(data, 2) + assert mdata.shape == (3,) + assert mdata.dtype.fields['f0'][0] == np.float64 + assert mdata.dtype.fields['f1'][0] == np.float64 + + # test more input dims + data = np.zeros((3, 4, 5, 6)) + mdata = mcolorizer._ensure_multivariate_data(data, 3) + assert mdata.shape == (4, 5, 6) + + +def test_colorizer_multinorm_implicit(): + ca = mcolorizer.Colorizer('BiOrangeBlue') + ca.vmin = (0, 0) + ca.vmax = (1, 1) + + # test call with two single values + data = [0.1, 0.2] + res = (0.10009765625, 0.1510859375, 0.20166015625, 1.0) + assert_array_almost_equal(ca.to_rgba(data), res) + + # test call with two 1d arrays + data = [[0.1, 0.2], [0.3, 0.4]] + res = [[0.10009766, 0.19998877, 0.29931641, 1.], + [0.20166016, 0.30098633, 0.40087891, 1.]] + assert_array_almost_equal(ca.to_rgba(data), res) + + # test call with two 2d arrays + data = [np.linspace(0, 1, 12).reshape(3, 4), + np.linspace(1, 0, 12).reshape(3, 4)] + res = np.array([[[0.00244141, 0.50048437, 0.99853516, 1.], + [0.09228516, 0.50048437, 0.90869141, 1.], + [0.18212891, 0.50048437, 0.81884766, 1.], + [0.27197266, 0.50048437, 0.72900391, 1.]], + [[0.36572266, 0.50048437, 0.63525391, 1.], + [0.45556641, 0.50048438, 0.54541016, 1.], + [0.54541016, 0.50048438, 0.45556641, 1.], + [0.63525391, 0.50048437, 0.36572266, 1.]], + [[0.72900391, 0.50048437, 0.27197266, 1.], + [0.81884766, 0.50048437, 0.18212891, 1.], + [0.90869141, 0.50048437, 0.09228516, 1.], + [0.99853516, 0.50048437, 0.00244141, 1.]]]) + assert_array_almost_equal(ca.to_rgba(data), res) + + with pytest.raises(ValueError, match=("This MultiNorm has 2 components, " + "but got a sequence with 3 elements")): + ca.to_rgba([0.1, 0.2, 0.3]) + with pytest.raises(ValueError, match=("This MultiNorm has 2 components, " + "but got a sequence with 1 elements")): + ca.to_rgba([[0.1]]) + + # test multivariate + ca = mcolorizer.Colorizer('3VarAddA') + ca.vmin = (-0.1, -0.2, -0.3) + ca.vmax = (0.1, 0.2, 0.3) + + data = [0.1, 0.1, 0.1] + res = (0.712612, 0.896847, 0.954494, 1.0) + assert_array_almost_equal(ca.to_rgba(data), res) + + +def test_colorizer_multinorm_explicit(): + + with pytest.raises(ValueError, match="MultiNorm must be assigned"): + ca = mcolorizer.Colorizer('BiOrangeBlue', 'linear') + + with pytest.raises(TypeError, + match=("'norm' must be an instance of matplotlib.colors.Norm" + ", str or None, not a list")): + ca = mcolorizer.Colorizer('viridis', ['linear', 'linear']) + + with pytest.raises(ValueError, + match=("Invalid norm for multivariate colormap with 2 inputs")): + ca = mcolorizer.Colorizer('BiOrangeBlue', ['linear', 'linear', 'log']) + + # valid explicit construction + ca = mcolorizer.Colorizer('BiOrangeBlue', [mcolors.Normalize(), 'log']) + ca.vmin = (0, 0.01) + ca.vmax = (1, 1) + + # test call with two single values + data = [0.1, 0.2] + res = (0.100098, 0.375492, 0.650879, 1.) + assert_array_almost_equal(ca.to_rgba(data), res) + + +def test_invalid_cmap_n_components_zero(): + class CustomColormap(mcolors.Colormap): + def __init__(self): + super().__init__("custom") + self.n_variates = 0 + + with pytest.raises(ValueError, match='`n_variates` >= 1'): + ca = mcolorizer.Colorizer(CustomColormap()) + + +def test_colorizer_bivar_cmap(): + ca = mcolorizer.Colorizer('BiOrangeBlue', [mcolors.Normalize(), 'log']) + + with pytest.raises(ValueError, match='The colormap viridis'): + ca.cmap = 'viridis' + + cartist = mcolorizer.ColorizingArtist(ca) + cartist.set_array(np.zeros((2, 4, 4))) + + with pytest.raises(ValueError, match='Invalid data entry for multivariate'): + cartist.set_array(np.zeros((3, 4, 4))) + + dt = np.dtype([('x', 'f4'), ('', 'object')]) + with pytest.raises(TypeError, match='converted to a sequence of floats'): + cartist.set_array(np.zeros((2, 4, 4), dtype=dt)) + + with pytest.raises(ValueError, match='all variates must have same shape'): + cartist.set_array((np.zeros(3), np.zeros(4))) + + # ensure masked value is propagated from input + a = np.arange(3) + cartist.set_array((a, np.ma.masked_where(a > 1, a))) + assert np.all(cartist.get_array()['f0'].mask == np.array([0, 0, 0], dtype=bool)) + assert np.all(cartist.get_array()['f1'].mask == np.array([0, 0, 1], dtype=bool)) + + # test clearing data + cartist.set_array(None) + cartist.get_array() is None + + +def test_colorizer_multivar_cmap(): + ca = mcolorizer.Colorizer('3VarAddA', [mcolors.Normalize(), + mcolors.Normalize(), + 'log']) + cartist = mcolorizer.ColorizingArtist(ca) + cartist.set_array(np.zeros((3, 5, 5))) + with pytest.raises(ValueError, match='Complex numbers are incompatible with'): + cartist.set_array(np.zeros((5, 5), dtype='complex128')) diff --git a/lib/matplotlib/tests/test_constrainedlayout.py b/lib/matplotlib/tests/test_constrainedlayout.py index a2fa5efe780f..91aaa2fd9172 100644 --- a/lib/matplotlib/tests/test_constrainedlayout.py +++ b/lib/matplotlib/tests/test_constrainedlayout.py @@ -688,6 +688,77 @@ def test_compressed_suptitle(): assert title.get_position()[1] == 0.98 +@image_comparison(['test_compressed_suptitle_colorbar.png'], style='mpl20') +def test_compressed_suptitle_colorbar(): + """Test that colorbars align with axes in compressed layout with suptitle.""" + arr = np.arange(100).reshape((10, 10)) + fig, axs = plt.subplots(ncols=2, figsize=(4, 2), layout='compressed') + + im0 = axs[0].imshow(arr) + im1 = axs[1].imshow(arr) + + cb0 = plt.colorbar(im0, ax=axs[0]) + cb1 = plt.colorbar(im1, ax=axs[1]) + + fig.suptitle('Title') + + # Verify colorbar heights match axes heights + # After layout, colorbar should have same height as parent axes + fig.canvas.draw() + + for ax, cb in zip(axs, [cb0, cb1]): + ax_pos = ax.get_position() + cb_pos = cb.ax.get_position() + + # Check that colorbar height matches axes height (within tolerance) + # Note: We check the actual rendered positions, not the bbox + assert abs(cb_pos.height - ax_pos.height) < 0.01, \ + f"Colorbar height {cb_pos.height} doesn't match axes height {ax_pos.height}" + + # Also verify vertical alignment (y0 and y1 should match) + assert abs(cb_pos.y0 - ax_pos.y0) < 0.01, \ + f"Colorbar y0 {cb_pos.y0} doesn't match axes y0 {ax_pos.y0}" + assert abs(cb_pos.y1 - ax_pos.y1) < 0.01, \ + f"Colorbar y1 {cb_pos.y1} doesn't match axes y1 {ax_pos.y1}" + + +@image_comparison(['test_compressed_supylabel_colorbar.png'], style='mpl20') +def test_compressed_supylabel_colorbar(): + """ + Test that horizontal colorbars align with axes + in compressed layout with supylabel. + """ + arr = np.arange(100).reshape((10, 10)) + fig, axs = plt.subplots(nrows=2, figsize=(3, 4), layout='compressed') + + im0 = axs[0].imshow(arr) + im1 = axs[1].imshow(arr) + + cb0 = plt.colorbar(im0, ax=axs[0], orientation='horizontal') + cb1 = plt.colorbar(im1, ax=axs[1], orientation='horizontal') + + fig.supylabel('Title') + + # Verify colorbar widths match axes widths + # After layout, colorbar should have same width as parent axes + fig.canvas.draw() + + for ax, cb in zip(axs, [cb0, cb1]): + ax_pos = ax.get_position() + cb_pos = cb.ax.get_position() + + # Check that colorbar width matches axes width (within tolerance) + # Note: We check the actual rendered positions, not the bbox + assert abs(cb_pos.width - ax_pos.width) < 0.01, \ + f"Colorbar width {cb_pos.width} doesn't match axes width {ax_pos.width}" + + # Also verify horizontal alignment (x0 and x1 should match) + assert abs(cb_pos.x0 - ax_pos.x0) < 0.01, \ + f"Colorbar x0 {cb_pos.x0} doesn't match axes x0 {ax_pos.x0}" + assert abs(cb_pos.x1 - ax_pos.x1) < 0.01, \ + f"Colorbar x1 {cb_pos.x1} doesn't match axes x1 {ax_pos.x1}" + + @pytest.mark.parametrize('arg, state', [ (True, True), (False, False), diff --git a/lib/matplotlib/tests/test_container.py b/lib/matplotlib/tests/test_container.py index 6998101dd755..b7dfe1196685 100644 --- a/lib/matplotlib/tests/test_container.py +++ b/lib/matplotlib/tests/test_container.py @@ -53,3 +53,26 @@ def test_barcontainer_position_centers__bottoms__tops(): assert_array_equal(container.position_centers, pos) assert_array_equal(container.bottoms, bottoms) assert_array_equal(container.tops, bottoms + heights) + + +def test_piecontainer_remove(): + fig, ax = plt.subplots() + pie = ax.pie([2, 3], labels=['foo', 'bar'], autopct="%1.0f%%") + ax.pie_label(pie, ['baz', 'qux']) + assert len(ax.patches) == 2 + assert len(ax.texts) == 6 + + pie.remove() + assert not ax.patches + assert not ax.texts + + +def test_piecontainer_unpack_backcompat(): + fig, ax = plt.subplots() + wedges, texts, autotexts = ax.pie( + [2, 3], labels=['foo', 'bar'], autopct="%1.0f%%", labeldistance=None) + + assert len(wedges) == 2 + assert isinstance(texts, list) + assert not texts + assert len(autotexts) == 2 diff --git a/lib/matplotlib/tests/test_contour.py b/lib/matplotlib/tests/test_contour.py index 89c1c0a6cc96..87e2d0ce182e 100644 --- a/lib/matplotlib/tests/test_contour.py +++ b/lib/matplotlib/tests/test_contour.py @@ -616,8 +616,7 @@ def test_contourf_legend_elements(): cs = plt.contourf(h, levels=[10, 30, 50], colors=['#FFFF00', '#FF00FF', '#00FFFF'], extend='both') - cs.cmap.set_over('red') - cs.cmap.set_under('blue') + cs.cmap = cs.cmap.with_extremes(over='red', under='blue') cs.changed() artists, labels = cs.legend_elements() assert labels == ['$x \\leq -1e+250s$', diff --git a/lib/matplotlib/tests/test_dates.py b/lib/matplotlib/tests/test_dates.py index 8ee12131fdbe..23b35e78c1f5 100644 --- a/lib/matplotlib/tests/test_dates.py +++ b/lib/matplotlib/tests/test_dates.py @@ -275,7 +275,7 @@ def test_DateFormatter(): import matplotlib.testing.jpl_units as units units.register() - # Lets make sure that DateFormatter will allow us to have tick marks + # Let's make sure that DateFormatter will allow us to have tick marks # at intervals of fractional seconds. t0 = datetime.datetime(2001, 1, 1, 0, 0, 0) diff --git a/lib/matplotlib/tests/test_doc.py b/lib/matplotlib/tests/test_doc.py index 3e28fd1b8eb7..6e8ce9fd630c 100644 --- a/lib/matplotlib/tests/test_doc.py +++ b/lib/matplotlib/tests/test_doc.py @@ -9,7 +9,7 @@ def test_sphinx_gallery_example_header(): EXAMPLE_HEADER, this test will start to fail. In that case, please update the monkey-patching of EXAMPLE_HEADER in conf.py. """ - pytest.importorskip('sphinx_gallery', minversion='0.16.0') + pytest.importorskip('sphinx_gallery', minversion='0.20.0') from sphinx_gallery import gen_rst EXAMPLE_HEADER = """ @@ -25,7 +25,7 @@ def test_sphinx_gallery_example_header(): :class: sphx-glr-download-link-note :ref:`Go to the end ` - to download the full example code.{2} + to download the full example code{2} .. rst-class:: sphx-glr-example-title diff --git a/lib/matplotlib/tests/test_dviread.py b/lib/matplotlib/tests/test_dviread.py index 7b7ff151be18..33fe9bb150d2 100644 --- a/lib/matplotlib/tests/test_dviread.py +++ b/lib/matplotlib/tests/test_dviread.py @@ -2,7 +2,8 @@ from pathlib import Path import shutil -import matplotlib.dviread as dr +from matplotlib import cbook, dviread as dr +from matplotlib.testing import subprocess_run_for_testing, _has_tex_package import pytest @@ -62,16 +63,85 @@ def test_PsfontsMap(monkeypatch): @pytest.mark.skipif(shutil.which("kpsewhich") is None, reason="kpsewhich is not available") -def test_dviread(): - dirpath = Path(__file__).parent / 'baseline_images/dviread' - with (dirpath / 'test.json').open() as f: - correct = json.load(f) - with dr.Dvi(str(dirpath / 'test.dvi'), None) as dvi: - data = [{'text': [[t.x, t.y, - chr(t.glyph), - t.font.texname.decode('ascii'), - round(t.font.size, 2)] - for t in page.text], - 'boxes': [[b.x, b.y, b.height, b.width] for b in page.boxes]} - for page in dvi] +@pytest.mark.parametrize("engine", ["pdflatex", "xelatex", "lualatex"]) +def test_dviread(tmp_path, engine, monkeypatch): + dirpath = Path(__file__).parent / "baseline_images/dviread" + shutil.copy(dirpath / "test.tex", tmp_path) + shutil.copy(cbook._get_data_path("fonts/ttf/DejaVuSans.ttf"), tmp_path) + cmd, fmt = { + "pdflatex": (["latex"], "dvi"), + "xelatex": (["xelatex", "-no-pdf"], "xdv"), + "lualatex": (["lualatex", "-output-format=dvi"], "dvi"), + }[engine] + if shutil.which(cmd[0]) is None: + pytest.skip(f"{cmd[0]} is not available") + subprocess_run_for_testing( + [*cmd, "test.tex"], cwd=tmp_path, check=True, capture_output=True) + # dviread must be run from the tmppath directory because {xe,lua}tex output + # records the path to DejaVuSans.ttf as it is written in the tex source, + # i.e. as a relative path. + monkeypatch.chdir(tmp_path) + with dr.Dvi(tmp_path / f"test.{fmt}", None) as dvi: + try: + pages = [*dvi] + except FileNotFoundError as exc: + for note in getattr(exc, "__notes__", []): + if "too-old version of luaotfload" in note: + pytest.skip(note) + raise + data = [ + { + "text": [ + [ + t.x, t.y, + t._as_unicode_or_name(), + t.font.resolve_path().name, + round(t.font.size, 2), + t.font.effects, + ] for t in page.text + ], + "boxes": [[b.x, b.y, b.height, b.width] for b in page.boxes] + } for page in pages + ] + correct = json.loads((dirpath / f"{engine}.json").read_text()) + assert data == correct + + +@pytest.mark.skipif(shutil.which("latex") is None, reason="latex is not available") +@pytest.mark.skipif(not _has_tex_package("concmath"), reason="needs concmath.sty") +def test_dviread_pk(tmp_path): + (tmp_path / "test.tex").write_text(r""" + \documentclass{article} + \usepackage{concmath} + \pagestyle{empty} + \begin{document} + Hi! + \end{document} + """) + subprocess_run_for_testing( + ["latex", "test.tex"], cwd=tmp_path, check=True, capture_output=True) + with dr.Dvi(tmp_path / "test.dvi", None) as dvi: + pages = [*dvi] + data = [ + { + "text": [ + [ + t.x, t.y, + t._as_unicode_or_name(), + t.font.resolve_path().name, + round(t.font.size, 2), + t.font.effects, + ] for t in page.text + ], + "boxes": [[b.x, b.y, b.height, b.width] for b in page.boxes] + } for page in pages + ] + correct = [{ + 'boxes': [], + 'text': [ + [5046272, 4128768, 'H?', 'ccr10.600pk', 9.96, {}], + [5530510, 4128768, 'i?', 'ccr10.600pk', 9.96, {}], + [5716195, 4128768, '!?', 'ccr10.600pk', 9.96, {}], + ], + }] assert data == correct diff --git a/lib/matplotlib/tests/test_figure.py b/lib/matplotlib/tests/test_figure.py index c5890a2963b3..e666a3b99f7f 100644 --- a/lib/matplotlib/tests/test_figure.py +++ b/lib/matplotlib/tests/test_figure.py @@ -147,8 +147,6 @@ def test_figure_label(): assert plt.get_figlabels() == ['', 'today'] plt.figure(fig_today) assert plt.gcf() == fig_today - with pytest.raises(ValueError): - plt.figure(Figure()) def test_figure_label_replaced(): @@ -1690,6 +1688,9 @@ def test_unpickle_with_device_pixel_ratio(): assert fig.dpi == 42*7 fig2 = pickle.loads(pickle.dumps(fig)) assert fig2.dpi == 42 + assert all( + [orig / 7 == restore for orig, restore in zip(fig.bbox.max, fig2.bbox.max)] + ) def test_gridspec_no_mutate_input(): diff --git a/lib/matplotlib/tests/test_getattr.py b/lib/matplotlib/tests/test_getattr.py index f0f5823600ca..fe302220067a 100644 --- a/lib/matplotlib/tests/test_getattr.py +++ b/lib/matplotlib/tests/test_getattr.py @@ -1,25 +1,29 @@ from importlib import import_module from pkgutil import walk_packages +import sys +import warnings -import matplotlib import pytest +import matplotlib +from matplotlib.testing import is_ci_environment, subprocess_run_helper + # Get the names of all matplotlib submodules, # except for the unit tests and private modules. -module_names = [ - m.name - for m in walk_packages( - path=matplotlib.__path__, prefix=f'{matplotlib.__name__}.' - ) - if not m.name.startswith(__package__) - and not any(x.startswith('_') for x in m.name.split('.')) -] +module_names = [] +backend_module_names = [] +for m in walk_packages(path=matplotlib.__path__, prefix=f'{matplotlib.__name__}.'): + if m.name.startswith(__package__): + continue + if any(x.startswith('_') for x in m.name.split('.')): + continue + if 'backends.backend_' in m.name: + backend_module_names.append(m.name) + else: + module_names.append(m.name) -@pytest.mark.parametrize('module_name', module_names) -@pytest.mark.filterwarnings('ignore::DeprecationWarning') -@pytest.mark.filterwarnings('ignore::ImportWarning') -def test_getattr(module_name): +def _test_getattr(module_name, use_pytest=True): """ Test that __getattr__ methods raise AttributeError for unknown keys. See #20822, #20855. @@ -28,8 +32,35 @@ def test_getattr(module_name): module = import_module(module_name) except (ImportError, RuntimeError, OSError) as e: # Skip modules that cannot be imported due to missing dependencies - pytest.skip(f'Cannot import {module_name} due to {e}') + if use_pytest: + pytest.skip(f'Cannot import {module_name} due to {e}') + else: + print(f'SKIP: Cannot import {module_name} due to {e}') + return key = 'THIS_SYMBOL_SHOULD_NOT_EXIST' if hasattr(module, key): delattr(module, key) + + +@pytest.mark.parametrize('module_name', module_names) +@pytest.mark.filterwarnings('ignore::DeprecationWarning') +@pytest.mark.filterwarnings('ignore::ImportWarning') +def test_getattr(module_name): + _test_getattr(module_name) + + +def _test_module_getattr(): + warnings.filterwarnings('ignore', category=DeprecationWarning) + warnings.filterwarnings('ignore', category=ImportWarning) + module_name = sys.argv[1] + _test_getattr(module_name, use_pytest=False) + + +@pytest.mark.parametrize('module_name', backend_module_names) +def test_backend_getattr(module_name): + proc = subprocess_run_helper(_test_module_getattr, module_name, + timeout=120 if is_ci_environment() else 20) + if 'SKIP: ' in proc.stdout: + pytest.skip(proc.stdout.removeprefix('SKIP: ')) + print(proc.stdout) diff --git a/lib/matplotlib/tests/test_image.py b/lib/matplotlib/tests/test_image.py index 00c223c59362..9b598fbf7193 100644 --- a/lib/matplotlib/tests/test_image.py +++ b/lib/matplotlib/tests/test_image.py @@ -1,5 +1,4 @@ from contextlib import ExitStack -from copy import copy import functools import io import os @@ -37,7 +36,7 @@ def test_alpha_interp(): axr.imshow(img, interpolation="bilinear") -@image_comparison(['interp_nearest_vs_none'], +@image_comparison(['interp_nearest_vs_none'], tol=3.7, # For Ghostscript 10.06+. extensions=['pdf', 'svg'], remove_text=True) def test_interp_nearest_vs_none(): """Test the effect of "nearest" and "none" interpolation""" @@ -453,6 +452,43 @@ def test_format_cursor_data(data, text): assert im.format_cursor_data(im.get_cursor_data(event)) == text +@pytest.mark.parametrize( + "data, text", [ + ([[[10001, 10000]], [[0, 0]]], "[10001.000, 0.000]"), + ([[[.123, .987]], [[0.1, 0]]], "[0.123, 0.100]"), + ([[[np.nan, 1, 2]], [[0, 0, 0]]], "[]"), + ]) +def test_format_cursor_data_multinorm(data, text): + from matplotlib.backend_bases import MouseEvent + fig, ax = plt.subplots() + cmap_bivar = mpl.bivar_colormaps['BiOrangeBlue'] + cmap_multivar = mpl.multivar_colormaps['2VarAddA'] + + # This is a test for ColorizingArtist._format_cursor_data_override() + # with data with multiple channels. + # It includes a workaround so that we can test this functionality + # before the MultiVar/BiVariate colormaps and MultiNorm are exposed + # via the top-level methods (ax.imshow()) + # i.e. we here set the hidden variables _cmap and _norm + # and use set_array() on the ColorizingArtist rather than the _ImageBase + # but this workaround should be replaced by: + # `ax.imshow(data, cmap=cmap_bivar, vmin=(0,0), vmax=(1,1))` + # once the functionality is available. + # see https://github.com/matplotlib/matplotlib/issues/14168 + im = ax.imshow([[0, 1]]) + im.colorizer._cmap = cmap_bivar + im.colorizer._norm = colors.MultiNorm([im.norm, im.norm]) + mpl.colorizer.ColorizingArtist.set_array(im, data) + + xdisp, ydisp = ax.transData.transform([0, 0]) + event = MouseEvent('motion_notify_event', fig.canvas, xdisp, ydisp) + assert im.format_cursor_data(im.get_cursor_data(event)) == text + + im.colorizer._cmap = cmap_multivar + event = MouseEvent('motion_notify_event', fig.canvas, xdisp, ydisp) + assert im.format_cursor_data(im.get_cursor_data(event)) == text + + @image_comparison(['image_clip'], style='mpl20') def test_image_clip(): d = [[1, 2], [3, 4]] @@ -1168,21 +1204,6 @@ def test_respects_bbox(): assert buf_before.getvalue() != buf_after.getvalue() # Not all white. -def test_image_cursor_formatting(): - fig, ax = plt.subplots() - # Create a dummy image to be able to call format_cursor_data - im = ax.imshow(np.zeros((4, 4))) - - data = np.ma.masked_array([0], mask=[True]) - assert im.format_cursor_data(data) == '[]' - - data = np.ma.masked_array([0], mask=[False]) - assert im.format_cursor_data(data) == '[0]' - - data = np.nan - assert im.format_cursor_data(data) == '[nan]' - - @check_figures_equal(extensions=['png', 'pdf', 'svg']) def test_image_array_alpha(fig_test, fig_ref): """Per-pixel alpha channel test.""" @@ -1209,8 +1230,7 @@ def test_image_array_alpha_validation(): @mpl.style.context('mpl20') def test_exact_vmin(): - cmap = copy(mpl.colormaps["autumn_r"]) - cmap.set_under(color="lightgrey") + cmap = mpl.colormaps["autumn_r"].with_extremes(under="lightgrey") # make the image exactly 190 pixels wide fig = plt.figure(figsize=(1.9, 0.1), dpi=100) @@ -1484,9 +1504,7 @@ def test_rgba_antialias(): aa[70:90, 195:215] = 1e6 aa[20:30, 195:215] = -1e6 - cmap = plt.colormaps["RdBu_r"] - cmap.set_over('yellow') - cmap.set_under('cyan') + cmap = plt.colormaps["RdBu_r"].with_extremes(over='yellow', under='cyan') axs = axs.flatten() # zoom in @@ -1741,8 +1759,7 @@ def test_downsampling_speckle(): axs = axs.flatten() img = ((np.arange(1024).reshape(-1, 1) * np.ones(720)) // 50).T - cm = plt.get_cmap("viridis") - cm.set_over("m") + cm = plt.get_cmap("viridis").with_extremes(over="m") norm = colors.LogNorm(vmin=3, vmax=11) # old default cannot be tested because it creates over/under speckles diff --git a/lib/matplotlib/tests/test_legend.py b/lib/matplotlib/tests/test_legend.py index 61fae63a298e..5f83b25b90a5 100644 --- a/lib/matplotlib/tests/test_legend.py +++ b/lib/matplotlib/tests/test_legend.py @@ -1667,3 +1667,86 @@ def test_boxplot_legend_labels(): bp4 = axs[3].boxplot(data, label='box A') assert bp4['medians'][0].get_label() == 'box A' assert all(x.get_label().startswith("_") for x in bp4['medians'][1:]) + + +def test_legend_linewidth(): + """Test legend.linewidth parameter and rcParam.""" + fig, ax = plt.subplots() + ax.plot([1, 2, 3], label='data') + + # Test direct parameter + leg = ax.legend(linewidth=2.5) + assert leg.legendPatch.get_linewidth() == 2.5 + + # Test rcParam + with mpl.rc_context({'legend.linewidth': 3.0}): + fig, ax = plt.subplots() + ax.plot([1, 2, 3], label='data') + leg = ax.legend() + assert leg.legendPatch.get_linewidth() == 3.0 + + # Test None default (should inherit from patch.linewidth) + with mpl.rc_context({'legend.linewidth': None, 'patch.linewidth': 1.5}): + fig, ax = plt.subplots() + ax.plot([1, 2, 3], label='data') + leg = ax.legend() + assert leg.legendPatch.get_linewidth() == 1.5 + + # Test that direct parameter overrides rcParam + with mpl.rc_context({'legend.linewidth': 1.0}): + fig, ax = plt.subplots() + ax.plot([1, 2, 3], label='data') + leg = ax.legend(linewidth=4.0) + assert leg.legendPatch.get_linewidth() == 4.0 + + +def test_patchcollection_legend(): + # Test that PatchCollection labels show up in legend and preserve visual + # properties (issue #23998) + fig, ax = plt.subplots() + + pc = mcollections.PatchCollection( + [mpatches.Circle((0, 0), 1), mpatches.Circle((2, 0), 1)], + label="patch collection", + facecolor='red', + edgecolor='blue', + linewidths=3, + linestyle='--', + ) + ax.add_collection(pc) + ax.autoscale_view() + + leg = ax.legend() + + # Check that the legend contains our label + assert len(leg.get_texts()) == 1 + assert leg.get_texts()[0].get_text() == "patch collection" + + # Check that the legend handle exists and has correct visual properties + assert len(leg.legend_handles) == 1 + legend_patch = leg.legend_handles[0] + assert mpl.colors.same_color(legend_patch.get_facecolor(), + pc.get_facecolor()[0]) + assert mpl.colors.same_color(legend_patch.get_edgecolor(), + pc.get_edgecolor()[0]) + assert legend_patch.get_linewidth() == pc.get_linewidths()[0] + assert legend_patch.get_linestyle() == pc.get_linestyles()[0] + + +def test_patchcollection_legend_empty(): + # Test that empty PatchCollection doesn't crash + fig, ax = plt.subplots() + + # Create an empty PatchCollection + pc = mcollections.PatchCollection([], label="empty collection") + ax.add_collection(pc) + + # This should not crash + leg = ax.legend() + + # Check that the label still appears + assert len(leg.get_texts()) == 1 + assert leg.get_texts()[0].get_text() == "empty collection" + + # The legend handle should exist + assert len(leg.legend_handles) == 1 diff --git a/lib/matplotlib/tests/test_pyplot.py b/lib/matplotlib/tests/test_pyplot.py index 55f7c33cb52e..1cca7332aa0c 100644 --- a/lib/matplotlib/tests/test_pyplot.py +++ b/lib/matplotlib/tests/test_pyplot.py @@ -13,7 +13,7 @@ def test_pyplot_up_to_date(tmp_path): - pytest.importorskip("black") + pytest.importorskip("black", minversion="24.1") gen_script = Path(mpl.__file__).parents[2] / "tools/boilerplate.py" if not gen_script.exists(): @@ -471,6 +471,30 @@ def test_multiple_same_figure_calls(): assert fig is fig3 +def test_register_existing_figure_with_pyplot(): + from matplotlib.figure import Figure + # start with a standalone figure + fig = Figure() + assert fig.canvas.manager is None + with pytest.raises(AttributeError): + # Heads-up: This will change to returning None in the future + # See docstring for the Figure.number property + fig.number + # register the Figure with pyplot + plt.figure(fig) + assert fig.number == 1 + # the figure can now be used in pyplot + plt.suptitle("my title") + assert fig.get_suptitle() == "my title" + # it also has a manager that is properly wired up in the pyplot state + assert plt._pylab_helpers.Gcf.get_fig_manager(fig.number) is fig.canvas.manager + # and we can regularly switch the pyplot state + fig2 = plt.figure() + assert fig2.number == 2 + assert plt.figure(1) is fig + assert plt.gcf() is fig + + def test_close_all_warning(): fig1 = plt.figure() diff --git a/lib/matplotlib/tests/test_sankey.py b/lib/matplotlib/tests/test_sankey.py index 253bfa4fa093..745db5f767b2 100644 --- a/lib/matplotlib/tests/test_sankey.py +++ b/lib/matplotlib/tests/test_sankey.py @@ -6,7 +6,7 @@ def test_sankey(): - # lets just create a sankey instance and check the code runs + # let's just create a sankey instance and check the code runs sankey = Sankey() sankey.add() diff --git a/lib/matplotlib/tests/test_sphinxext.py b/lib/matplotlib/tests/test_sphinxext.py index ede3166a2e1b..c6f4e13c74c2 100644 --- a/lib/matplotlib/tests/test_sphinxext.py +++ b/lib/matplotlib/tests/test_sphinxext.py @@ -205,6 +205,30 @@ def test_plot_html_show_source_link_custom_basename(tmp_path): assert 'custom-name.py' in html_content +def test_plot_html_code_caption(tmp_path): + # Test that :code-caption: option adds caption to code block + shutil.copyfile(tinypages / 'conf.py', tmp_path / 'conf.py') + shutil.copytree(tinypages / '_static', tmp_path / '_static') + doctree_dir = tmp_path / 'doctrees' + (tmp_path / 'index.rst').write_text(""" +.. plot:: + :include-source: + :code-caption: Example plotting code + + import matplotlib.pyplot as plt + plt.plot([1, 2, 3], [1, 4, 9]) +""") + html_dir = tmp_path / '_build' / 'html' + build_sphinx_html(tmp_path, doctree_dir, html_dir) + + # Check that the HTML contains the code caption + html_content = (html_dir / 'index.html').read_text(encoding='utf-8') + assert 'Example plotting code' in html_content + # Verify the caption is associated with the code block + # (appears in a caption element) + assert '

info.misses +def test_metrics_cache2(): + # dig into the signature to get the mutable default used as a cache + renderer_cache = inspect.signature( + mpl.text._get_text_metrics_function + ).parameters['_cache'].default + gc.collect() + renderer_cache.clear() + + def helper(): + fig, ax = plt.subplots() + fig.draw_without_rendering() + # show we hit the outer cache + assert len(renderer_cache) == 1 + func = renderer_cache[fig.canvas.get_renderer()] + cache_info = func.cache_info() + # show we hit the inner cache + assert cache_info.currsize > 0 + assert cache_info.currsize == cache_info.misses + assert cache_info.hits > cache_info.misses + plt.close(fig) + + helper() + gc.collect() + # show the outer cache has a lifetime tied to the renderer (via the figure) + assert len(renderer_cache) == 0 + + def test_annotate_offset_fontsize(): # Test that offset_fontsize parameter works and uses accurate values fig, ax = plt.subplots() diff --git a/lib/matplotlib/tests/test_transforms.py b/lib/matplotlib/tests/test_transforms.py index b4db34db5a91..59a765107d7b 100644 --- a/lib/matplotlib/tests/test_transforms.py +++ b/lib/matplotlib/tests/test_transforms.py @@ -694,9 +694,9 @@ def test_contains_branch(self): assert not self.stack1.contains_branch(self.tn1 + self.ta2) blend = mtransforms.BlendedGenericTransform(self.tn2, self.stack2) - x, y = blend.contains_branch_seperately(self.stack2_subset) + x, y = blend.contains_branch_separately(self.stack2_subset) stack_blend = self.tn3 + blend - sx, sy = stack_blend.contains_branch_seperately(self.stack2_subset) + sx, sy = stack_blend.contains_branch_separately(self.stack2_subset) assert x is sx is False assert y is sy is True @@ -835,6 +835,16 @@ def assert_bbox_eq(bbox1, bbox2): assert_array_equal(bbox1.bounds, bbox2.bounds) +def test_bbox_is_finite(): + assert not Bbox([(1, 1), (1, 1)])._is_finite() + assert not Bbox([(0, 0), (np.inf, 1)])._is_finite() + assert not Bbox([(-np.inf, 0), (2, 2)])._is_finite() + assert not Bbox([(np.nan, 0), (2, 2)])._is_finite() + assert Bbox([(0, 0), (0, 2)])._is_finite() + assert Bbox([(0, 0), (2, 0)])._is_finite() + assert Bbox([(0, 0), (1, 2)])._is_finite() + + def test_bbox_frozen_copies_minpos(): bbox = mtransforms.Bbox.from_extents(0.0, 0.0, 1.0, 1.0, minpos=1.0) frozen = bbox.frozen() diff --git a/lib/matplotlib/tests/test_cm_stubs.py b/lib/matplotlib/tests/test_typing.py similarity index 60% rename from lib/matplotlib/tests/test_cm_stubs.py rename to lib/matplotlib/tests/test_typing.py index 2305c91a5301..c9fc8e5b162f 100644 --- a/lib/matplotlib/tests/test_cm_stubs.py +++ b/lib/matplotlib/tests/test_typing.py @@ -1,8 +1,10 @@ import re +import typing from pathlib import Path import matplotlib.pyplot as plt from matplotlib.colors import Colormap +from matplotlib.typing import RcKeyType, RcGroupKeyType def test_cm_stub_matches_runtime_colormaps(): @@ -30,3 +32,20 @@ def test_cm_stub_matches_runtime_colormaps(): ) assert runtime_cmaps == stubbed_cmaps + + +def test_rcparam_stubs(): + runtime_rc_keys = { + name for name in plt.rcParamsDefault.keys() + if not name.startswith('_') + } + + assert {*typing.get_args(RcKeyType)} == runtime_rc_keys + + runtime_rc_group_keys = set() + for name in runtime_rc_keys: + groups = name.split('.') + for i in range(1, len(groups)): + runtime_rc_group_keys.add('.'.join(groups[:i])) + + assert {*typing.get_args(RcGroupKeyType)} == runtime_rc_group_keys diff --git a/lib/matplotlib/text.py b/lib/matplotlib/text.py index acde4fb179a2..0be32ca86009 100644 --- a/lib/matplotlib/text.py +++ b/lib/matplotlib/text.py @@ -64,17 +64,89 @@ def _get_textbox(text, renderer): def _get_text_metrics_with_cache(renderer, text, fontprop, ismath, dpi): """Call ``renderer.get_text_width_height_descent``, caching the results.""" - # Cached based on a copy of fontprop so that later in-place mutations of - # the passed-in argument do not mess up the cache. - return _get_text_metrics_with_cache_impl( - weakref.ref(renderer), text, fontprop.copy(), ismath, dpi) + # hit the outer cache layer and get the function to compute the metrics + # for this renderer instance + get_text_metrics = _get_text_metrics_function(renderer) + # call the function to compute the metrics and return + # + # We pass a copy of the fontprop because FontProperties is both mutable and + # has a `__hash__` that depends on that mutable state. This is not ideal + # as it means the hash of an object is not stable over time which leads to + # very confusing behavior when used as keys in dictionaries or hashes. + return get_text_metrics(text, fontprop.copy(), ismath, dpi) -@functools.lru_cache(4096) -def _get_text_metrics_with_cache_impl( - renderer_ref, text, fontprop, ismath, dpi): - # dpi is unused, but participates in cache invalidation (via the renderer). - return renderer_ref().get_text_width_height_descent(text, fontprop, ismath) + +def _get_text_metrics_function(input_renderer, _cache=weakref.WeakKeyDictionary()): + """ + Helper function to provide a two-layered cache for font metrics + + + To get the rendered size of a size of string we need to know: + - what renderer we are using + - the current dpi of the renderer + - the string + - the font properties + - is it math text or not + + We do this as a two-layer cache with the outer layer being tied to a + renderer instance and the inner layer handling everything else. + + The outer layer is implemented as `.WeakKeyDictionary` keyed on the + renderer. As long as someone else is holding a hard ref to the renderer + we will keep the cache alive, but it will be automatically dropped when + the renderer is garbage collected. + + The inner layer is provided by an lru_cache with a large maximum size (such + that we expect very few cache misses in actual use cases). As the + dpi is mutable on the renderer, we need to explicitly include it as part of + the cache key on the inner layer even though we do not directly use it (it is + used in the method call on the renderer). + + This function takes a renderer and returns a function that can be used to + get the font metrics. + + Parameters + ---------- + input_renderer : maplotlib.backend_bases.RendererBase + The renderer to set the cache up for. + + _cache : dict, optional + We are using the mutable default value to attach the cache to the function. + + In principle you could pass a different dict-like to this function to inject + a different cache, but please don't. This is an internal function not meant to + be reused outside of the narrow context we need it for. + + There is a possible race condition here between threads, we may need to drop the + mutable default and switch to a threadlocal variable in the future. + + """ + if (_text_metrics := _cache.get(input_renderer, None)) is None: + # We are going to include this in the closure we put as values in the + # cache. Closing over a hard-ref would create an unbreakable reference + # cycle. + renderer_ref = weakref.ref(input_renderer) + + # define the function locally to get a new lru_cache per renderer + @functools.lru_cache(4096) + # dpi is unused, but participates in cache invalidation (via the renderer). + def _text_metrics(text, fontprop, ismath, dpi): + # this should never happen under normal use, but this is a better error to + # raise than an AttributeError on `None` + if (local_renderer := renderer_ref()) is None: + raise RuntimeError( + "Trying to get text metrics for a renderer that no longer exists. " + "This should never happen and is evidence of a bug elsewhere." + ) + # do the actual method call we need and return the result + return local_renderer.get_text_width_height_descent(text, fontprop, ismath) + + # stash the function for later use. + _cache[input_renderer] = _text_metrics + + # return the inner function + return _text_metrics @_docstring.interpd diff --git a/lib/matplotlib/text.pyi b/lib/matplotlib/text.pyi index 41c7b761ae32..7223693945ec 100644 --- a/lib/matplotlib/text.pyi +++ b/lib/matplotlib/text.pyi @@ -2,7 +2,7 @@ from .artist import Artist from .backend_bases import RendererBase from .font_manager import FontProperties from .offsetbox import DraggableAnnotation -from .path import Path +from pathlib import Path from .patches import FancyArrowPatch, FancyBboxPatch from .textpath import ( # noqa: F401, reexported API TextPath as TextPath, diff --git a/lib/matplotlib/textpath.py b/lib/matplotlib/textpath.py index b57597ded363..8deae19c42e7 100644 --- a/lib/matplotlib/textpath.py +++ b/lib/matplotlib/textpath.py @@ -234,7 +234,9 @@ def get_glyphs_tex(self, prop, s, glyph_map=None, # characters into strings. t1_encodings = {} for text in page.text: - font = get_font(text.font_path) + font = get_font(text.font.resolve_path()) + if text.font.subfont: + raise NotImplementedError("Indexing TTC fonts is not supported yet") char_id = self._get_char_id(font, text.glyph) if char_id not in glyph_map: font.clear() diff --git a/lib/matplotlib/transforms.py b/lib/matplotlib/transforms.py index 350113c56170..6ffc82edac3e 100644 --- a/lib/matplotlib/transforms.py +++ b/lib/matplotlib/transforms.py @@ -377,6 +377,29 @@ def extents(self): def get_points(self): raise NotImplementedError + def _is_finite(self): + """ + Return whether the bounding box is finite and not degenerate to a + single point. + + We count the box as finite if neither width nor height are infinite + and at least one direction is non-zero; i.e. a point is not finite, + but a horizontal or vertical line is. + + .. versionadded:: 3.11 + + Notes + ----- + We keep this private for now because concise naming is hard and + because we are not sure how universal the concept is. It is + currently used only for filtering bboxes to be included in + tightbbox calculation, but I'm unsure whether single points + should be included there as well. + """ + width = self.width + height = self.height + return (width > 0 or height > 0) and width < np.inf and height < np.inf + def containsx(self, x): """ Return whether *x* is in the closed (:attr:`x0`, :attr:`x1`) interval. @@ -1418,7 +1441,7 @@ def contains_branch(self, other): return True return False - def contains_branch_seperately(self, other_transform): + def contains_branch_separately(self, other_transform): """ Return whether the given branch is a sub-tree of this transform on each separate dimension. @@ -1426,16 +1449,21 @@ def contains_branch_seperately(self, other_transform): A common use for this method is to identify if a transform is a blended transform containing an Axes' data transform. e.g.:: - x_isdata, y_isdata = trans.contains_branch_seperately(ax.transData) + x_isdata, y_isdata = trans.contains_branch_separately(ax.transData) """ if self.output_dims != 2: - raise ValueError('contains_branch_seperately only supports ' + raise ValueError('contains_branch_separately only supports ' 'transforms with 2 output dimensions') # for a non-blended transform each separate dimension is the same, so # just return the appropriate shape. return (self.contains_branch(other_transform), ) * 2 + # Permanent alias for backwards compatibility (historical typo) + def contains_branch_seperately(self, other_transform): + """:meta private:""" + return self.contains_branch_separately(other_transform) + def __sub__(self, other): """ Compose *self* with the inverse of *other*, cancelling identical terms @@ -2185,7 +2213,7 @@ def __eq__(self, other): else: return NotImplemented - def contains_branch_seperately(self, transform): + def contains_branch_separately(self, transform): return (self._x.contains_branch(transform), self._y.contains_branch(transform)) @@ -2411,14 +2439,14 @@ def _iter_break_from_left_to_right(self): for left, right in self._b._iter_break_from_left_to_right(): yield self._a + left, right - def contains_branch_seperately(self, other_transform): + def contains_branch_separately(self, other_transform): # docstring inherited if self.output_dims != 2: - raise ValueError('contains_branch_seperately only supports ' + raise ValueError('contains_branch_separately only supports ' 'transforms with 2 output dimensions') if self == other_transform: return (True, True) - return self._b.contains_branch_seperately(other_transform) + return self._b.contains_branch_separately(other_transform) depth = property(lambda self: self._a.depth + self._b.depth) is_affine = property(lambda self: self._a.is_affine and self._b.is_affine) diff --git a/lib/matplotlib/transforms.pyi b/lib/matplotlib/transforms.pyi index 07d299be297c..da73a8127cc3 100644 --- a/lib/matplotlib/transforms.pyi +++ b/lib/matplotlib/transforms.pyi @@ -65,6 +65,7 @@ class BboxBase(TransformNode): @property def extents(self) -> tuple[float, float, float, float]: ... def get_points(self) -> np.ndarray: ... + def _is_finite(self) -> bool: ... def containsx(self, x: float) -> bool: ... def containsy(self, y: float) -> bool: ... def contains(self, x: float, y: float) -> bool: ... @@ -189,9 +190,10 @@ class Transform(TransformNode): @property def depth(self) -> int: ... def contains_branch(self, other: Transform) -> bool: ... - def contains_branch_seperately( + def contains_branch_separately( self, other_transform: Transform ) -> Sequence[bool]: ... + contains_branch_seperately = contains_branch_separately # Alias (historical typo) def __sub__(self, other: Transform) -> Transform: ... def __array__(self, *args, **kwargs) -> np.ndarray: ... def transform(self, values: ArrayLike) -> np.ndarray: ... @@ -252,7 +254,7 @@ class IdentityTransform(Affine2DBase): ... class _BlendedMixin: def __eq__(self, other: object) -> bool: ... - def contains_branch_seperately(self, transform: Transform) -> Sequence[bool]: ... + def contains_branch_separately(self, transform: Transform) -> Sequence[bool]: ... class BlendedGenericTransform(_BlendedMixin, Transform): input_dims: Literal[2] diff --git a/lib/matplotlib/typing.py b/lib/matplotlib/typing.py index cedeb1ad5d5e..d2e12c6e08d9 100644 --- a/lib/matplotlib/typing.py +++ b/lib/matplotlib/typing.py @@ -170,3 +170,405 @@ tuple[float, float] | int ) + +RcKeyType: TypeAlias = Literal[ + "agg.path.chunksize", + "animation.bitrate", + "animation.codec", + "animation.convert_args", + "animation.convert_path", + "animation.embed_limit", + "animation.ffmpeg_args", + "animation.ffmpeg_path", + "animation.frame_format", + "animation.html", + "animation.writer", + "axes.autolimit_mode", + "axes.axisbelow", + "axes.edgecolor", + "axes.facecolor", + "axes.formatter.limits", + "axes.formatter.min_exponent", + "axes.formatter.offset_threshold", + "axes.formatter.use_locale", + "axes.formatter.use_mathtext", + "axes.formatter.useoffset", + "axes.grid", + "axes.grid.axis", + "axes.grid.which", + "axes.labelcolor", + "axes.labelpad", + "axes.labelsize", + "axes.labelweight", + "axes.linewidth", + "axes.prop_cycle", + "axes.spines.bottom", + "axes.spines.left", + "axes.spines.right", + "axes.spines.top", + "axes.titlecolor", + "axes.titlelocation", + "axes.titlepad", + "axes.titlesize", + "axes.titleweight", + "axes.titley", + "axes.unicode_minus", + "axes.xmargin", + "axes.ymargin", + "axes.zmargin", + "axes3d.automargin", + "axes3d.depthshade", + "axes3d.depthshade_minalpha", + "axes3d.grid", + "axes3d.mouserotationstyle", + "axes3d.trackballborder", + "axes3d.trackballsize", + "axes3d.xaxis.panecolor", + "axes3d.yaxis.panecolor", + "axes3d.zaxis.panecolor", + "backend", + "backend_fallback", + "boxplot.bootstrap", + "boxplot.boxprops.color", + "boxplot.boxprops.linestyle", + "boxplot.boxprops.linewidth", + "boxplot.capprops.color", + "boxplot.capprops.linestyle", + "boxplot.capprops.linewidth", + "boxplot.flierprops.color", + "boxplot.flierprops.linestyle", + "boxplot.flierprops.linewidth", + "boxplot.flierprops.marker", + "boxplot.flierprops.markeredgecolor", + "boxplot.flierprops.markeredgewidth", + "boxplot.flierprops.markerfacecolor", + "boxplot.flierprops.markersize", + "boxplot.meanline", + "boxplot.meanprops.color", + "boxplot.meanprops.linestyle", + "boxplot.meanprops.linewidth", + "boxplot.meanprops.marker", + "boxplot.meanprops.markeredgecolor", + "boxplot.meanprops.markerfacecolor", + "boxplot.meanprops.markersize", + "boxplot.medianprops.color", + "boxplot.medianprops.linestyle", + "boxplot.medianprops.linewidth", + "boxplot.notch", + "boxplot.patchartist", + "boxplot.showbox", + "boxplot.showcaps", + "boxplot.showfliers", + "boxplot.showmeans", + "boxplot.vertical", + "boxplot.whiskerprops.color", + "boxplot.whiskerprops.linestyle", + "boxplot.whiskerprops.linewidth", + "boxplot.whiskers", + "contour.algorithm", + "contour.corner_mask", + "contour.linewidth", + "contour.negative_linestyle", + "date.autoformatter.day", + "date.autoformatter.hour", + "date.autoformatter.microsecond", + "date.autoformatter.minute", + "date.autoformatter.month", + "date.autoformatter.second", + "date.autoformatter.year", + "date.converter", + "date.epoch", + "date.interval_multiples", + "docstring.hardcopy", + "errorbar.capsize", + "figure.autolayout", + "figure.constrained_layout.h_pad", + "figure.constrained_layout.hspace", + "figure.constrained_layout.use", + "figure.constrained_layout.w_pad", + "figure.constrained_layout.wspace", + "figure.dpi", + "figure.edgecolor", + "figure.facecolor", + "figure.figsize", + "figure.frameon", + "figure.hooks", + "figure.labelsize", + "figure.labelweight", + "figure.max_open_warning", + "figure.raise_window", + "figure.subplot.bottom", + "figure.subplot.hspace", + "figure.subplot.left", + "figure.subplot.right", + "figure.subplot.top", + "figure.subplot.wspace", + "figure.titlesize", + "figure.titleweight", + "font.cursive", + "font.enable_last_resort", + "font.family", + "font.fantasy", + "font.monospace", + "font.sans-serif", + "font.serif", + "font.size", + "font.stretch", + "font.style", + "font.variant", + "font.weight", + "grid.alpha", + "grid.color", + "grid.linestyle", + "grid.linewidth", + "grid.major.alpha", + "grid.major.color", + "grid.major.linestyle", + "grid.major.linewidth", + "grid.minor.alpha", + "grid.minor.color", + "grid.minor.linestyle", + "grid.minor.linewidth", + "hatch.color", + "hatch.linewidth", + "hist.bins", + "image.aspect", + "image.cmap", + "image.composite_image", + "image.interpolation", + "image.interpolation_stage", + "image.lut", + "image.origin", + "image.resample", + "interactive", + "keymap.back", + "keymap.copy", + "keymap.forward", + "keymap.fullscreen", + "keymap.grid", + "keymap.grid_minor", + "keymap.help", + "keymap.home", + "keymap.pan", + "keymap.quit", + "keymap.quit_all", + "keymap.save", + "keymap.xscale", + "keymap.yscale", + "keymap.zoom", + "legend.borderaxespad", + "legend.borderpad", + "legend.columnspacing", + "legend.edgecolor", + "legend.facecolor", + "legend.fancybox", + "legend.fontsize", + "legend.framealpha", + "legend.frameon", + "legend.handleheight", + "legend.handlelength", + "legend.handletextpad", + "legend.labelcolor", + "legend.labelspacing", + "legend.linewidth", + "legend.loc", + "legend.markerscale", + "legend.numpoints", + "legend.scatterpoints", + "legend.shadow", + "legend.title_fontsize", + "lines.antialiased", + "lines.color", + "lines.dash_capstyle", + "lines.dash_joinstyle", + "lines.dashdot_pattern", + "lines.dashed_pattern", + "lines.dotted_pattern", + "lines.linestyle", + "lines.linewidth", + "lines.marker", + "lines.markeredgecolor", + "lines.markeredgewidth", + "lines.markerfacecolor", + "lines.markersize", + "lines.scale_dashes", + "lines.solid_capstyle", + "lines.solid_joinstyle", + "macosx.window_mode", + "markers.fillstyle", + "mathtext.bf", + "mathtext.bfit", + "mathtext.cal", + "mathtext.default", + "mathtext.fallback", + "mathtext.fontset", + "mathtext.it", + "mathtext.rm", + "mathtext.sf", + "mathtext.tt", + "patch.antialiased", + "patch.edgecolor", + "patch.facecolor", + "patch.force_edgecolor", + "patch.linewidth", + "path.effects", + "path.simplify", + "path.simplify_threshold", + "path.sketch", + "path.snap", + "pcolor.shading", + "pcolormesh.snap", + "pdf.compression", + "pdf.fonttype", + "pdf.inheritcolor", + "pdf.use14corefonts", + "pgf.preamble", + "pgf.rcfonts", + "pgf.texsystem", + "polaraxes.grid", + "ps.distiller.res", + "ps.fonttype", + "ps.papersize", + "ps.useafm", + "ps.usedistiller", + "savefig.bbox", + "savefig.directory", + "savefig.dpi", + "savefig.edgecolor", + "savefig.facecolor", + "savefig.format", + "savefig.orientation", + "savefig.pad_inches", + "savefig.transparent", + "scatter.edgecolors", + "scatter.marker", + "svg.fonttype", + "svg.hashsalt", + "svg.id", + "svg.image_inline", + "text.antialiased", + "text.color", + "text.hinting", + "text.hinting_factor", + "text.kerning_factor", + "text.latex.preamble", + "text.parse_math", + "text.usetex", + "timezone", + "tk.window_focus", + "toolbar", + "webagg.address", + "webagg.open_in_browser", + "webagg.port", + "webagg.port_retries", + "xaxis.labellocation", + "xtick.alignment", + "xtick.bottom", + "xtick.color", + "xtick.direction", + "xtick.labelbottom", + "xtick.labelcolor", + "xtick.labelsize", + "xtick.labeltop", + "xtick.major.bottom", + "xtick.major.pad", + "xtick.major.size", + "xtick.major.top", + "xtick.major.width", + "xtick.minor.bottom", + "xtick.minor.ndivs", + "xtick.minor.pad", + "xtick.minor.size", + "xtick.minor.top", + "xtick.minor.visible", + "xtick.minor.width", + "xtick.top", + "yaxis.labellocation", + "ytick.alignment", + "ytick.color", + "ytick.direction", + "ytick.labelcolor", + "ytick.labelleft", + "ytick.labelright", + "ytick.labelsize", + "ytick.left", + "ytick.major.left", + "ytick.major.pad", + "ytick.major.right", + "ytick.major.size", + "ytick.major.width", + "ytick.minor.left", + "ytick.minor.ndivs", + "ytick.minor.pad", + "ytick.minor.right", + "ytick.minor.size", + "ytick.minor.visible", + "ytick.minor.width", + "ytick.right", +] + +RcGroupKeyType: TypeAlias = Literal[ + "agg", + "agg.path", + "animation", + "axes", + "axes.formatter", + "axes.grid", + "axes.spines", + "axes3d", + "axes3d.xaxis", + "axes3d.yaxis", + "axes3d.zaxis", + "boxplot", + "boxplot.boxprops", + "boxplot.capprops", + "boxplot.flierprops", + "boxplot.meanprops", + "boxplot.medianprops", + "boxplot.whiskerprops", + "contour", + "date", + "date.autoformatter", + "docstring", + "errorbar", + "figure", + "figure.constrained_layout", + "figure.subplot", + "font", + "grid", + "grid.major", + "grid.minor", + "hatch", + "hist", + "image", + "keymap", + "legend", + "lines", + "macosx", + "markers", + "mathtext", + "patch", + "path", + "pcolor", + "pcolormesh", + "pdf", + "pgf", + "polaraxes", + "ps", + "ps.distiller", + "savefig", + "scatter", + "svg", + "text", + "text.latex", + "tk", + "webagg", + "xaxis", + "xtick", + "xtick.major", + "xtick.minor", + "yaxis", + "ytick", + "ytick.major", + "ytick.minor", +] diff --git a/lib/matplotlib/widgets.py b/lib/matplotlib/widgets.py index 034a9b4db7a0..0410c4f03092 100644 --- a/lib/matplotlib/widgets.py +++ b/lib/matplotlib/widgets.py @@ -11,6 +11,7 @@ from contextlib import ExitStack import copy +import enum import itertools from numbers import Integral, Number @@ -150,6 +151,10 @@ def ignore(self, event): # docstring inherited return super().ignore(event) or self.canvas is None + def _set_cursor(self, cursor): + """Update the canvas cursor.""" + self.ax.get_figure(root=True).canvas.set_cursor(cursor) + class Button(AxesWidget): """ @@ -2172,6 +2177,8 @@ def update_background(self, event): # `release` can call a draw event even when `ignore` is True. if not self.useblit: return + if self.canvas.is_saving(): + return # saving does not use blitting # Make sure that widget artists don't get accidentally included in the # background, by re-rendering the background if needed (and then # re-re-rendering the canvas with the visible widget artists). @@ -2643,7 +2650,7 @@ def _handles_artists(self): else: return () - def _set_cursor(self, enabled): + def _set_span_cursor(self, *, enabled): """Update the canvas cursor based on direction of the selector.""" if enabled: cursor = (backend_tools.Cursors.RESIZE_HORIZONTAL @@ -2652,7 +2659,7 @@ def _set_cursor(self, enabled): else: cursor = backend_tools.Cursors.POINTER - self.ax.get_figure(root=True).canvas.set_cursor(cursor) + self._set_cursor(cursor) def connect_default_events(self): # docstring inherited @@ -2662,7 +2669,7 @@ def connect_default_events(self): def _press(self, event): """Button press event handler.""" - self._set_cursor(True) + self._set_span_cursor(enabled=True) if self._interactive and self._selection_artist.get_visible(): self._set_active_handle(event) else: @@ -2712,7 +2719,7 @@ def direction(self, direction): def _release(self, event): """Button release event handler.""" - self._set_cursor(False) + self._set_span_cursor(enabled=False) if not self._interactive: self._selection_artist.set_visible(False) @@ -2754,7 +2761,7 @@ def _hover(self, event): return _, e_dist = self._edge_handles.closest(event.x, event.y) - self._set_cursor(e_dist <= self.grab_range) + self._set_span_cursor(enabled=e_dist <= self.grab_range) def _onmove(self, event): """Motion notify event handler.""" @@ -3145,6 +3152,13 @@ def onselect(eclick: MouseEvent, erelease: MouseEvent) """ +class _RectangleSelectorAction(enum.Enum): + ROTATE = enum.auto() + MOVE = enum.auto() + RESIZE = enum.auto() + CREATE = enum.auto() + + @_docstring.Substitution(_RECTANGLESELECTOR_PARAMETERS_DOCSTRING.replace( '__ARTIST_NAME__', 'rectangle')) class RectangleSelector(_SelectorWidget): @@ -3278,10 +3292,23 @@ def _press(self, event): self._rotation_on_press = self._rotation self._set_aspect_ratio_correction() + match self._get_action(): + case _RectangleSelectorAction.ROTATE: + # TODO: set to a rotate cursor if possible? + pass + case _RectangleSelectorAction.MOVE: + self._set_cursor(backend_tools.cursors.MOVE) + case _RectangleSelectorAction.RESIZE: + # TODO: set to a resize cursor if possible? + pass + case _RectangleSelectorAction.CREATE: + self._set_cursor(backend_tools.cursors.SELECT_REGION) + return False def _release(self, event): """Button release event handler.""" + self._set_cursor(backend_tools.Cursors.POINTER) if not self._interactive: self._selection_artist.set_visible(False) @@ -3325,9 +3352,20 @@ def _release(self, event): self.update() self._active_handle = None self._extents_on_press = None - return False + def _get_action(self): + state = self._state + if 'rotate' in state and self._active_handle in self._corner_order: + return _RectangleSelectorAction.ROTATE + elif self._active_handle == 'C': + return _RectangleSelectorAction.MOVE + elif self._active_handle: + return _RectangleSelectorAction.RESIZE + + return _RectangleSelectorAction.CREATE + + def _onmove(self, event): """ Motion notify event handler. @@ -3342,12 +3380,10 @@ def _onmove(self, event): # The calculations are done for rotation at zero: we apply inverse # transformation to events except when we rotate and move state = self._state - rotate = 'rotate' in state and self._active_handle in self._corner_order - move = self._active_handle == 'C' - resize = self._active_handle and not move + action = self._get_action() xdata, ydata = self._get_data_coords(event) - if resize: + if action == _RectangleSelectorAction.RESIZE: inv_tr = self._get_rotation_transform().inverted() xdata, ydata = inv_tr.transform([xdata, ydata]) eventpress.xdata, eventpress.ydata = inv_tr.transform( @@ -3367,7 +3403,7 @@ def _onmove(self, event): x0, x1, y0, y1 = self._extents_on_press # rotate an existing shape - if rotate: + if action == _RectangleSelectorAction.ROTATE: # calculate angle abc a = (eventpress.xdata, eventpress.ydata) b = self.center @@ -3376,7 +3412,7 @@ def _onmove(self, event): np.arctan2(a[1]-b[1], a[0]-b[0])) self.rotation = np.rad2deg(self._rotation_on_press + angle) - elif resize: + elif action == _RectangleSelectorAction.RESIZE: size_on_press = [x1 - x0, y1 - y0] center = (x0 + size_on_press[0] / 2, y0 + size_on_press[1] / 2) @@ -3427,7 +3463,7 @@ def _onmove(self, event): sign = np.sign(xdata - x0) x1 = x0 + sign * abs(y1 - y0) * self._aspect_ratio_correction - elif move: + elif action == _RectangleSelectorAction.MOVE: x0, x1, y0, y1 = self._extents_on_press dx = xdata - eventpress.xdata dy = ydata - eventpress.ydata diff --git a/lib/matplotlib/widgets.pyi b/lib/matplotlib/widgets.pyi index e143d0b2c96e..f74b9c7f32bf 100644 --- a/lib/matplotlib/widgets.pyi +++ b/lib/matplotlib/widgets.pyi @@ -6,6 +6,7 @@ from .figure import Figure from .lines import Line2D from .patches import Polygon, Rectangle from .text import Text +from .backend_tools import Cursors import PIL.Image @@ -38,6 +39,7 @@ class AxesWidget(Widget): def canvas(self) -> FigureCanvasBase | None: ... def connect_event(self, event: Event, callback: Callable) -> None: ... def disconnect_events(self) -> None: ... + def _set_cursor(self, cursor: Cursors) -> None: ... class Button(AxesWidget): label: Text @@ -335,6 +337,7 @@ class SpanSelector(_SelectorWidget): _props: dict[str, Any] | None = ..., _init: bool = ..., ) -> None: ... + def _set_span_cursor(self, *, enabled: bool) -> None: ... def connect_default_events(self) -> None: ... @property def direction(self) -> Literal["horizontal", "vertical"]: ... @@ -398,6 +401,7 @@ class RectangleSelector(_SelectorWidget): minspany: float spancoords: Literal["data", "pixels"] grab_range: float + _active_handle: None | Literal["C", "N", "NE", "E", "SE", "S", "SW", "W", "NW"] def __init__( self, ax: Axes, diff --git a/lib/mpl_toolkits/axes_grid1/axes_grid.py b/lib/mpl_toolkits/axes_grid1/axes_grid.py index f7d2968f1990..b26c87edce1c 100644 --- a/lib/mpl_toolkits/axes_grid1/axes_grid.py +++ b/lib/mpl_toolkits/axes_grid1/axes_grid.py @@ -190,7 +190,7 @@ def _get_col_row(self, n): return col, row n_axes = property(lambda self: len(self.axes_all)) - ngrids = _api.deprecated(property(lambda self: len(self.axes_all))) + ngrids = _api.deprecated('3.11')(property(lambda self: len(self.axes_all))) # Good to propagate __len__ if we have __getitem__ def __len__(self): diff --git a/lib/mpl_toolkits/axes_grid1/tests/test_axes_grid1.py b/lib/mpl_toolkits/axes_grid1/tests/test_axes_grid1.py index b6d72e408a52..f550dc9f531e 100644 --- a/lib/mpl_toolkits/axes_grid1/tests/test_axes_grid1.py +++ b/lib/mpl_toolkits/axes_grid1/tests/test_axes_grid1.py @@ -806,3 +806,5 @@ def test_grid_n_axes(): fig = plt.figure() grid = Grid(fig, 111, (3, 3), n_axes=5) assert len(fig.axes) == grid.n_axes == 5 + with pytest.warns(mpl.MatplotlibDeprecationWarning, match="ngrids attribute"): + assert grid.ngrids == 5 diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index c56e4c6b7039..32da8dfde7aa 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -244,6 +244,8 @@ def _transformed_cube(self, vals): (minx, maxy, maxz)] return proj3d._proj_points(xyzs, self.M) + @_api.delete_parameter("3.11", "share") + @_api.delete_parameter("3.11", "anchor") def set_aspect(self, aspect, adjustable=None, anchor=None, share=False): """ Set the aspect ratios. @@ -263,39 +265,31 @@ def set_aspect(self, aspect, adjustable=None, anchor=None, share=False): 'equalyz' adapt the y and z axes to have equal aspect ratios. ========= ================================================== - adjustable : None or {'box', 'datalim'}, optional - If not *None*, this defines which parameter will be adjusted to - meet the required aspect. See `.set_adjustable` for further - details. + adjustable : {'box', 'datalim'}, default: 'box' + Defines which parameter to adjust to meet the aspect ratio. + + - 'box': Change the physical dimensions of the axes bounding box. + - 'datalim': Change the x, y, or z data limits. anchor : None or str or 2-tuple of float, optional - If not *None*, this defines where the Axes will be drawn if there - is extra space due to aspect constraints. The most common way to - specify the anchor are abbreviations of cardinal directions: - - ===== ===================== - value description - ===== ===================== - 'C' centered - 'SW' lower left corner - 'S' middle of bottom edge - 'SE' lower right corner - etc. - ===== ===================== - - See `~.Axes.set_anchor` for further details. + .. deprecated:: 3.11 + This parameter has no effect. share : bool, default: False - If ``True``, apply the settings to all shared Axes. + .. deprecated:: 3.11 + This parameter has no effect. See Also -------- mpl_toolkits.mplot3d.axes3d.Axes3D.set_box_aspect """ + if adjustable is None: + adjustable = 'box' + _api.check_in_list(['box', 'datalim'], adjustable=adjustable) _api.check_in_list(('auto', 'equal', 'equalxy', 'equalyz', 'equalxz'), aspect=aspect) - super().set_aspect( - aspect='auto', adjustable=adjustable, anchor=anchor, share=share) + + self.set_adjustable(adjustable) self._aspect = aspect if aspect in ('equal', 'equalxy', 'equalxz', 'equalyz'): @@ -2890,8 +2884,10 @@ def add_collection3d(self, col, zs=0, zdir='z', autolim=True, *, if autolim: if isinstance(col, art3d.Line3DCollection): - self.auto_scale_xyz(*np.array(col._segments3d).transpose(), - had_data=had_data) + # Handle ragged arrays by extracting coordinates separately + all_points = np.concatenate(col._segments3d) + self.auto_scale_xyz(all_points[:, 0], all_points[:, 1], + all_points[:, 2], had_data=had_data) elif isinstance(col, art3d.Poly3DCollection): self.auto_scale_xyz(col._faces[..., 0], col._faces[..., 1], diff --git a/lib/mpl_toolkits/mplot3d/axis3d.py b/lib/mpl_toolkits/mplot3d/axis3d.py index 4da5031b990c..fdd22b717f67 100644 --- a/lib/mpl_toolkits/mplot3d/axis3d.py +++ b/lib/mpl_toolkits/mplot3d/axis3d.py @@ -708,6 +708,8 @@ def get_tightbbox(self, renderer=None, *, for_layout_only=False): bb_1, bb_2 = self._get_ticklabel_bboxes(ticks, renderer) other = [] + if self.offsetText.get_visible() and self.offsetText.get_text(): + other.append(self.offsetText.get_window_extent(renderer)) if self.line.get_visible(): other.append(self.line.get_window_extent(renderer)) if (self.label.get_visible() and not for_layout_only and diff --git a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py index e6d11f793b46..546659d05177 100644 --- a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py +++ b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py @@ -17,6 +17,7 @@ from matplotlib.patches import Circle, PathPatch from matplotlib.path import Path from matplotlib.text import Text +from matplotlib import _api import matplotlib.pyplot as plt import numpy as np @@ -284,18 +285,11 @@ def test_contourf3d_extend(fig_test, fig_ref, extend, levels): # Z is in the range [0, 8] Z = X**2 + Y**2 - # Manually set the over/under colors to be the end of the colormap - cmap = mpl.colormaps['viridis'].copy() - cmap.set_under(cmap(0)) - cmap.set_over(cmap(255)) - # Set vmin/max to be the min/max values plotted on the reference image - kwargs = {'vmin': 1, 'vmax': 7, 'cmap': cmap} - ax_ref = fig_ref.add_subplot(projection='3d') - ax_ref.contourf(X, Y, Z, levels=[0, 2, 4, 6, 8], **kwargs) + ax_ref.contourf(X, Y, Z, levels=[0, 2, 4, 6, 8], vmin=1, vmax=7) ax_test = fig_test.add_subplot(projection='3d') - ax_test.contourf(X, Y, Z, levels, extend=extend, **kwargs) + ax_test.contourf(X, Y, Z, levels, extend=extend, vmin=1, vmax=7) for ax in [ax_ref, ax_test]: ax.set_xlim(-2, 2) @@ -2689,3 +2683,100 @@ def test_ndarray_color_kwargs_value_error(): ax = fig.add_subplot(111, projection='3d') ax.scatter(1, 0, 0, color=np.array([0, 0, 0, 1])) fig.canvas.draw() + + +def test_line3dcollection_autolim_ragged(): + """Test Line3DCollection with autolim=True and lines of different lengths.""" + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + + # Create lines with different numbers of points (ragged arrays) + edges = [ + [(0, 0, 0), (1, 1, 1), (2, 2, 2)], # 3 points + [(0, 1, 0), (1, 2, 1)], # 2 points + [(1, 0, 1), (2, 1, 2), (3, 2, 3), (4, 3, 4)] # 4 points + ] + + # This should not raise an exception. + collections = ax.add_collection3d(art3d.Line3DCollection(edges), autolim=True) + + # Check that limits were computed correctly with margins + # The limits should include all points with default margins + assert np.allclose(ax.get_xlim3d(), (-0.08333333333333333, 4.083333333333333)) + assert np.allclose(ax.get_ylim3d(), (-0.0625, 3.0625)) + assert np.allclose(ax.get_zlim3d(), (-0.08333333333333333, 4.083333333333333)) + + +def test_axes3d_set_aspect_deperecated_params(): + """ + Test that using the deprecated 'anchor' and 'share' kwargs in + set_aspect raises the correct warning. + """ + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + + # Test that providing the `anchor` parameter raises a deprecation warning. + with pytest.warns(_api.MatplotlibDeprecationWarning, match="'anchor' parameter"): + ax.set_aspect('equal', anchor='C') + + # Test that using the 'share' parameter is now deprecated. + with pytest.warns(_api.MatplotlibDeprecationWarning, match="'share' parameter"): + ax.set_aspect('equal', share=True) + + # Test that the `adjustable` parameter is correctly processed to satisfy + # code coverage. + ax.set_aspect('equal', adjustable='box') + assert ax.get_adjustable() == 'box' + + ax.set_aspect('equal', adjustable='datalim') + assert ax.get_adjustable() == 'datalim' + + with pytest.raises(ValueError, match="adjustable"): + ax.set_aspect('equal', adjustable='invalid_value') + + +def test_axis_get_tightbbox_includes_offset_text(): + # Test that axis.get_tightbbox includes the offset_text + # Regression test for issue #30744 + fig = plt.figure() + ax = fig.add_subplot(111, projection='3d') + + # Create data with high precision values that trigger offset text + Z = np.array([[0.1, 0.100000001], [0.100000000001, 0.100000000]]) + ny, nx = Z.shape + x = np.arange(nx) + y = np.arange(ny) + X, Y = np.meshgrid(x, y) + + ax.plot_surface(X, Y, Z) + + # Force a draw to ensure offset text is created and positioned + fig.canvas.draw() + renderer = fig.canvas.get_renderer() + + # Get the z-axis (which should have the offset text) + zaxis = ax.zaxis + + # Check that offset text is visible and has content + # The offset text may not be visible on all backends/configurations, + # so we only test the inclusion when it's actually present + if (zaxis.offsetText.get_visible() and + zaxis.offsetText.get_text()): + offset_bbox = zaxis.offsetText.get_window_extent(renderer) + + # Get the tight bbox - this should include the offset text + bbox = zaxis.get_tightbbox(renderer) + assert bbox is not None + assert offset_bbox is not None + + # The tight bbox should fully contain the offset text bbox + # Check that offset_bbox is within bbox bounds (with small tolerance for + # floating point errors) + assert bbox.x0 <= offset_bbox.x0 + 1e-6, \ + f"bbox.x0 ({bbox.x0}) should be <= offset_bbox.x0 ({offset_bbox.x0})" + assert bbox.y0 <= offset_bbox.y0 + 1e-6, \ + f"bbox.y0 ({bbox.y0}) should be <= offset_bbox.y0 ({offset_bbox.y0})" + assert bbox.x1 >= offset_bbox.x1 - 1e-6, \ + f"bbox.x1 ({bbox.x1}) should be >= offset_bbox.x1 ({offset_bbox.x1})" + assert bbox.y1 >= offset_bbox.y1 - 1e-6, \ + f"bbox.y1 ({bbox.y1}) should be >= offset_bbox.y1 ({offset_bbox.y1})" diff --git a/lib/mpl_toolkits/mplot3d/tests/test_legend3d.py b/lib/mpl_toolkits/mplot3d/tests/test_legend3d.py index 091ae2c3e12f..9ca048e18ba9 100644 --- a/lib/mpl_toolkits/mplot3d/tests/test_legend3d.py +++ b/lib/mpl_toolkits/mplot3d/tests/test_legend3d.py @@ -90,8 +90,7 @@ def test_contourf_legend_elements(): cs = ax.contourf(x, y, h, levels=[10, 30, 50], colors=['#FFFF00', '#FF00FF', '#00FFFF'], extend='both') - cs.cmap.set_over('red') - cs.cmap.set_under('blue') + cs.cmap = cs.cmap.with_extremes(over='red', under='blue') cs.changed() artists, labels = cs.legend_elements() assert labels == ['$x \\leq -1e+250s$', diff --git a/pyproject.toml b/pyproject.toml index 2bace246c5f1..b2e5451818f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ classifiers=[ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Scientific/Engineering :: Visualization", ] diff --git a/requirements/doc/doc-requirements.txt b/requirements/doc/doc-requirements.txt index 77cb606130b0..1a352eaae975 100644 --- a/requirements/doc/doc-requirements.txt +++ b/requirements/doc/doc-requirements.txt @@ -14,8 +14,7 @@ ipywidgets ipykernel numpydoc>=1.0 packaging>=20 -pydata-sphinx-theme~=0.15.0 -mpl-sphinx-theme~=3.9.0 +mpl-sphinx-theme~=3.10.0 pyyaml PyStemmer sphinxcontrib-svg2pdfconverter>=1.1.0 diff --git a/requirements/testing/all.txt b/requirements/testing/all.txt index e386924a9b67..dd1dbf3f29fd 100644 --- a/requirements/testing/all.txt +++ b/requirements/testing/all.txt @@ -1,12 +1,12 @@ # pip requirements for all the CI builds -black<24 +black<26 certifi coverage!=6.3 psutil pytest!=4.6.0,!=5.4.0,!=8.1.0 pytest-cov -pytest-rerunfailures +pytest-rerunfailures!=16.0 pytest-timeout pytest-xdist pytest-xvfb diff --git a/src/_c_internal_utils.cpp b/src/_c_internal_utils.cpp index 0dddefaf32e3..31eb92444862 100644 --- a/src/_c_internal_utils.cpp +++ b/src/_c_internal_utils.cpp @@ -41,11 +41,11 @@ mpl_xdisplay_is_valid(void) // than dlopen(). if (getenv("DISPLAY") && (libX11 = dlopen("libX11.so.6", RTLD_LAZY))) { - typedef struct Display* (*XOpenDisplay_t)(char const*); - typedef int (*XCloseDisplay_t)(struct Display*); struct Display* display = nullptr; - XOpenDisplay_t XOpenDisplay = (XOpenDisplay_t)dlsym(libX11, "XOpenDisplay"); - XCloseDisplay_t XCloseDisplay = (XCloseDisplay_t)dlsym(libX11, "XCloseDisplay"); + auto XOpenDisplay = (struct Display* (*)(char const*)) + dlsym(libX11, "XOpenDisplay"); + auto XCloseDisplay = (int (*)(struct Display*)) + dlsym(libX11, "XCloseDisplay"); if (XOpenDisplay && XCloseDisplay && (display = XOpenDisplay(nullptr))) { XCloseDisplay(display); @@ -73,13 +73,11 @@ mpl_display_is_valid(void) void* libwayland_client; if (getenv("WAYLAND_DISPLAY") && (libwayland_client = dlopen("libwayland-client.so.0", RTLD_LAZY))) { - typedef struct wl_display* (*wl_display_connect_t)(char const*); - typedef void (*wl_display_disconnect_t)(struct wl_display*); struct wl_display* display = nullptr; - wl_display_connect_t wl_display_connect = - (wl_display_connect_t)dlsym(libwayland_client, "wl_display_connect"); - wl_display_disconnect_t wl_display_disconnect = - (wl_display_disconnect_t)dlsym(libwayland_client, "wl_display_disconnect"); + auto wl_display_connect = (struct wl_display* (*)(char const*)) + dlsym(libwayland_client, "wl_display_connect"); + auto wl_display_disconnect = (void (*)(struct wl_display*)) + dlsym(libwayland_client, "wl_display_disconnect"); if (wl_display_connect && wl_display_disconnect && (display = wl_display_connect(nullptr))) { wl_display_disconnect(display); @@ -162,25 +160,19 @@ mpl_SetProcessDpiAwareness_max(void) #ifdef _DPI_AWARENESS_CONTEXTS_ // These functions and options were added in later Windows 10 updates, so // must be loaded dynamically. - typedef BOOL (WINAPI *IsValidDpiAwarenessContext_t)(DPI_AWARENESS_CONTEXT); - typedef BOOL (WINAPI *SetProcessDpiAwarenessContext_t)(DPI_AWARENESS_CONTEXT); - HMODULE user32 = LoadLibrary("user32.dll"); - IsValidDpiAwarenessContext_t IsValidDpiAwarenessContextPtr = - (IsValidDpiAwarenessContext_t)GetProcAddress( - user32, "IsValidDpiAwarenessContext"); - SetProcessDpiAwarenessContext_t SetProcessDpiAwarenessContextPtr = - (SetProcessDpiAwarenessContext_t)GetProcAddress( - user32, "SetProcessDpiAwarenessContext"); + auto IsValidDpiAwarenessContext = (BOOL (WINAPI *)(DPI_AWARENESS_CONTEXT)) + GetProcAddress(user32, "IsValidDpiAwarenessContext"); + auto SetProcessDpiAwarenessContext = (BOOL (WINAPI *)(DPI_AWARENESS_CONTEXT)) + GetProcAddress(user32, "SetProcessDpiAwarenessContext"); DPI_AWARENESS_CONTEXT ctxs[3] = { DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2, // Win10 Creators Update DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE, // Win10 DPI_AWARENESS_CONTEXT_SYSTEM_AWARE}; // Win10 - if (IsValidDpiAwarenessContextPtr != NULL - && SetProcessDpiAwarenessContextPtr != NULL) { + if (IsValidDpiAwarenessContext && SetProcessDpiAwarenessContext) { for (size_t i = 0; i < sizeof(ctxs) / sizeof(DPI_AWARENESS_CONTEXT); ++i) { - if (IsValidDpiAwarenessContextPtr(ctxs[i])) { - SetProcessDpiAwarenessContextPtr(ctxs[i]); + if (IsValidDpiAwarenessContext(ctxs[i])) { + SetProcessDpiAwarenessContext(ctxs[i]); break; } } diff --git a/src/_image_resample.h b/src/_image_resample.h index 7e6c32c6bf64..6b325c8aa14b 100644 --- a/src/_image_resample.h +++ b/src/_image_resample.h @@ -496,7 +496,7 @@ typedef enum { } interpolation_e; -// T is rgba if and only if it has an T::r field. +// T is rgba if and only if it has a T::r field. template struct is_grayscale : std::true_type {}; template struct is_grayscale> : std::false_type {}; template constexpr bool is_grayscale_v = is_grayscale::value; diff --git a/src/_macosx.m b/src/_macosx.m index 1372157bc80d..76d7e94de60f 100755 --- a/src/_macosx.m +++ b/src/_macosx.m @@ -572,6 +572,8 @@ bool mpl_check_modifier(bool present, PyObject* list, char const* name) }, }; +static PyTypeObject FigureManagerType; // forward declaration, needed in destroy() + typedef struct { PyObject_HEAD Window* window; @@ -580,6 +582,16 @@ bool mpl_check_modifier(bool present, PyObject* list, char const* name) static PyObject* FigureManager_new(PyTypeObject *type, PyObject *args, PyObject *kwds) { + if (![NSThread isMainThread]) { + PyErr_SetString( + PyExc_RuntimeError, + "Cannot create a GUI FigureManager outside the main thread " + "using the MacOS backend. Use a non-interactive " + "backend like 'agg' to make plots on worker threads." + ); + return NULL; + } + lazy_init(); Window* window = [Window alloc]; if (!window) { return NULL; } @@ -686,6 +698,25 @@ bool mpl_check_modifier(bool present, PyObject* list, char const* name) { [self->window close]; self->window = NULL; + + // call super(self, FigureManager).destroy() - it seems we need the + // explicit arguments, and just super() doesn't work in the C API. + PyObject *super_obj = PyObject_CallFunctionObjArgs( + (PyObject *)&PySuper_Type, + (PyObject *)&FigureManagerType, + self, + NULL + ); + if (super_obj == NULL) { + return NULL; // error + } + PyObject *result = PyObject_CallMethod(super_obj, "destroy", NULL); + Py_DECREF(super_obj); + if (result == NULL) { + return NULL; // error + } + Py_DECREF(result); + Py_RETURN_NONE; } diff --git a/src/_path.h b/src/_path.h index c03703776760..226d60231682 100644 --- a/src/_path.h +++ b/src/_path.h @@ -3,12 +3,12 @@ #ifndef MPL_PATH_H #define MPL_PATH_H -#include -#include -#include -#include #include +#include +#include +#include #include +#include #include "agg_conv_contour.h" #include "agg_conv_curve.h" @@ -26,6 +26,8 @@ struct XY double x; double y; + XY() : x(0), y(0) {} + XY(double x_, double y_) : x(x_), y(y_) { } @@ -43,7 +45,8 @@ struct XY typedef std::vector Polygon; -void _finalize_polygon(std::vector &result, int closed_only) +inline void +_finalize_polygon(std::vector &result, bool closed_only) { if (result.size() == 0) { return; @@ -311,43 +314,39 @@ inline bool point_on_path( struct extent_limits { - double x0; - double y0; - double x1; - double y1; - double xm; - double ym; -}; + XY start; + XY end; + /* minpos is the minimum positive values in the data; used by log scaling. */ + XY minpos; -void reset_limits(extent_limits &e) -{ - e.x0 = std::numeric_limits::infinity(); - e.y0 = std::numeric_limits::infinity(); - e.x1 = -std::numeric_limits::infinity(); - e.y1 = -std::numeric_limits::infinity(); - /* xm and ym are the minimum positive values in the data, used - by log scaling */ - e.xm = std::numeric_limits::infinity(); - e.ym = std::numeric_limits::infinity(); -} + extent_limits() : start{0,0}, end{0,0}, minpos{0,0} { + reset(); + } -inline void update_limits(double x, double y, extent_limits &e) -{ - if (x < e.x0) - e.x0 = x; - if (y < e.y0) - e.y0 = y; - if (x > e.x1) - e.x1 = x; - if (y > e.y1) - e.y1 = y; - /* xm and ym are the minimum positive values in the data, used - by log scaling */ - if (x > 0.0 && x < e.xm) - e.xm = x; - if (y > 0.0 && y < e.ym) - e.ym = y; -} + void reset() + { + start.x = std::numeric_limits::infinity(); + start.y = std::numeric_limits::infinity(); + end.x = -std::numeric_limits::infinity(); + end.y = -std::numeric_limits::infinity(); + minpos.x = std::numeric_limits::infinity(); + minpos.y = std::numeric_limits::infinity(); + } + + void update(double x, double y) + { + start.x = std::min(start.x, x); + start.y = std::min(start.y, y); + end.x = std::max(end.x, x); + end.y = std::max(end.y, y); + if (x > 0.0) { + minpos.x = std::min(minpos.x, x); + } + if (y > 0.0) { + minpos.y = std::min(minpos.y, y); + } + } +}; template void update_path_extents(PathIterator &path, agg::trans_affine &trans, extent_limits &extents) @@ -366,7 +365,7 @@ void update_path_extents(PathIterator &path, agg::trans_affine &trans, extent_li if ((code & agg::path_cmd_end_poly) == agg::path_cmd_end_poly) { continue; } - update_limits(x, y, extents); + extents.update(x, y); } } @@ -389,7 +388,7 @@ void get_path_collection_extents(agg::trans_affine &master_transform, agg::trans_affine trans; - reset_limits(extent); + extent.reset(); for (auto i = 0; i < N; ++i) { typename PathGenerator::path_iterator path(paths(i % Npaths)); @@ -524,12 +523,14 @@ struct bisectx { } - inline void bisect(double sx, double sy, double px, double py, double *bx, double *by) const + inline XY bisect(const XY s, const XY p) const { - *bx = m_x; - double dx = px - sx; - double dy = py - sy; - *by = sy + dy * ((m_x - sx) / dx); + double dx = p.x - s.x; + double dy = p.y - s.y; + return { + m_x, + s.y + dy * ((m_x - s.x) / dx), + }; } }; @@ -539,9 +540,9 @@ struct xlt : public bisectx { } - inline bool is_inside(double x, double y) const + inline bool is_inside(const XY point) const { - return x <= m_x; + return point.x <= m_x; } }; @@ -551,9 +552,9 @@ struct xgt : public bisectx { } - inline bool is_inside(double x, double y) const + inline bool is_inside(const XY point) const { - return x >= m_x; + return point.x >= m_x; } }; @@ -565,12 +566,14 @@ struct bisecty { } - inline void bisect(double sx, double sy, double px, double py, double *bx, double *by) const + inline XY bisect(const XY s, const XY p) const { - *by = m_y; - double dx = px - sx; - double dy = py - sy; - *bx = sx + dx * ((m_y - sy) / dy); + double dx = p.x - s.x; + double dy = p.y - s.y; + return { + s.x + dx * ((m_y - s.y) / dy), + m_y, + }; } }; @@ -580,9 +583,9 @@ struct ylt : public bisecty { } - inline bool is_inside(double x, double y) const + inline bool is_inside(const XY point) const { - return y <= m_y; + return point.y <= m_y; } }; @@ -592,9 +595,9 @@ struct ygt : public bisecty { } - inline bool is_inside(double x, double y) const + inline bool is_inside(const XY point) const { - return y >= m_y; + return point.y >= m_y; } }; } @@ -609,46 +612,30 @@ inline void clip_to_rect_one_step(const Polygon &polygon, Polygon &result, const return; } - auto [sx, sy] = polygon.back(); - for (auto [px, py] : polygon) { - sinside = filter.is_inside(sx, sy); - pinside = filter.is_inside(px, py); + auto s = polygon.back(); + for (auto p : polygon) { + sinside = filter.is_inside(s); + pinside = filter.is_inside(p); if (sinside ^ pinside) { - double bx, by; - filter.bisect(sx, sy, px, py, &bx, &by); - result.emplace_back(bx, by); + result.emplace_back(filter.bisect(s, p)); } if (pinside) { - result.emplace_back(px, py); + result.emplace_back(p); } - sx = px; - sy = py; + s = p; } } template -void -clip_path_to_rect(PathIterator &path, agg::rect_d &rect, bool inside, std::vector &results) +auto +clip_path_to_rect(PathIterator &path, agg::rect_d &rect, bool inside) { - double xmin, ymin, xmax, ymax; - if (rect.x1 < rect.x2) { - xmin = rect.x1; - xmax = rect.x2; - } else { - xmin = rect.x2; - xmax = rect.x1; - } - - if (rect.y1 < rect.y2) { - ymin = rect.y1; - ymax = rect.y2; - } else { - ymin = rect.y2; - ymax = rect.y1; - } + rect.normalize(); + auto xmin = rect.x1, xmax = rect.x2; + auto ymin = rect.y1, ymax = rect.y2; if (!inside) { std::swap(xmin, xmax); @@ -659,26 +646,27 @@ clip_path_to_rect(PathIterator &path, agg::rect_d &rect, bool inside, std::vecto curve_t curve(path); Polygon polygon1, polygon2; - double x = 0, y = 0; + XY point; unsigned code = 0; curve.rewind(0); + std::vector results; do { // Grab the next subpath and store it in polygon1 polygon1.clear(); do { if (code == agg::path_cmd_move_to) { - polygon1.emplace_back(x, y); + polygon1.emplace_back(point); } - code = curve.vertex(&x, &y); + code = curve.vertex(&point.x, &point.y); if (code == agg::path_cmd_stop) { break; } if (code != agg::path_cmd_move_to) { - polygon1.emplace_back(x, y); + polygon1.emplace_back(point); } } while ((code & agg::path_cmd_end_poly) != agg::path_cmd_end_poly); @@ -691,12 +679,14 @@ clip_path_to_rect(PathIterator &path, agg::rect_d &rect, bool inside, std::vecto // Empty polygons aren't very useful, so skip them if (polygon1.size()) { - _finalize_polygon(results, 1); + _finalize_polygon(results, true); results.push_back(polygon1); } } while (code != agg::path_cmd_stop); - _finalize_polygon(results, 1); + _finalize_polygon(results, true); + + return results; } template @@ -956,7 +946,7 @@ void convert_path_to_polygons(PathIterator &path, agg::trans_affine &trans, double width, double height, - int closed_only, + bool closed_only, std::vector &result) { typedef agg::conv_transform transformed_path_t; @@ -980,7 +970,7 @@ void convert_path_to_polygons(PathIterator &path, while ((code = curve.vertex(&x, &y)) != agg::path_cmd_stop) { if ((code & agg::path_cmd_end_poly) == agg::path_cmd_end_poly) { - _finalize_polygon(result, 1); + _finalize_polygon(result, true); polygon = &result.emplace_back(); } else { if (code == agg::path_cmd_move_to) { @@ -1051,15 +1041,14 @@ void cleanup_path(PathIterator &path, void quad2cubic(double x0, double y0, double x1, double y1, double x2, double y2, - double *outx, double *outy) + std::array &outx, std::array &outy) { - - outx[0] = x0 + 2./3. * (x1 - x0); - outy[0] = y0 + 2./3. * (y1 - y0); - outx[1] = outx[0] + 1./3. * (x2 - x0); - outy[1] = outy[0] + 1./3. * (y2 - y0); - outx[2] = x2; - outy[2] = y2; + std::get<0>(outx) = x0 + 2./3. * (x1 - x0); + std::get<0>(outy) = y0 + 2./3. * (y1 - y0); + std::get<1>(outx) = std::get<0>(outx) + 1./3. * (x2 - x0); + std::get<1>(outy) = std::get<0>(outy) + 1./3. * (y2 - y0); + std::get<2>(outx) = x2; + std::get<2>(outy) = y2; } @@ -1104,27 +1093,27 @@ void __add_number(double val, char format_code, int precision, template bool __convert_to_string(PathIterator &path, int precision, - char **codes, + const std::array &codes, bool postfix, std::string& buffer) { const char format_code = 'f'; - double x[3]; - double y[3]; + std::array x; + std::array y; double last_x = 0.0; double last_y = 0.0; unsigned code; - while ((code = path.vertex(&x[0], &y[0])) != agg::path_cmd_stop) { + while ((code = path.vertex(&std::get<0>(x), &std::get<0>(y))) != agg::path_cmd_stop) { if (code == CLOSEPOLY) { - buffer += codes[4]; + buffer += std::get<4>(codes); } else if (code < 5) { size_t size = NUM_VERTICES[code]; for (size_t i = 1; i < size; ++i) { - unsigned subcode = path.vertex(&x[i], &y[i]); + unsigned subcode = path.vertex(&x.at(i), &y.at(i)); if (subcode != code) { return false; } @@ -1133,29 +1122,29 @@ bool __convert_to_string(PathIterator &path, /* For formats that don't support quad curves, convert to cubic curves */ if (code == CURVE3 && codes[code - 1][0] == '\0') { - quad2cubic(last_x, last_y, x[0], y[0], x[1], y[1], x, y); + quad2cubic(last_x, last_y, x.at(0), y.at(0), x.at(1), y.at(1), x, y); code++; size = 3; } if (!postfix) { - buffer += codes[code - 1]; + buffer += codes.at(code - 1); buffer += ' '; } for (size_t i = 0; i < size; ++i) { - __add_number(x[i], format_code, precision, buffer); + __add_number(x.at(i), format_code, precision, buffer); buffer += ' '; - __add_number(y[i], format_code, precision, buffer); + __add_number(y.at(i), format_code, precision, buffer); buffer += ' '; } if (postfix) { - buffer += codes[code - 1]; + buffer += codes.at(code - 1); } - last_x = x[size - 1]; - last_y = y[size - 1]; + last_x = x.at(size - 1); + last_y = y.at(size - 1); } else { // Unknown code value return false; @@ -1174,7 +1163,7 @@ bool convert_to_string(PathIterator &path, bool simplify, SketchParams sketch_params, int precision, - char **codes, + const std::array &codes, bool postfix, std::string& buffer) { @@ -1211,7 +1200,6 @@ bool convert_to_string(PathIterator &path, sketch_t sketch(curve, sketch_params.scale, sketch_params.length, sketch_params.randomness); return __convert_to_string(sketch, precision, codes, postfix, buffer); } - } template diff --git a/src/_path_wrapper.cpp b/src/_path_wrapper.cpp index 2a297e49ac92..802189c428d3 100644 --- a/src/_path_wrapper.cpp +++ b/src/_path_wrapper.cpp @@ -68,15 +68,15 @@ Py_get_path_collection_extents(agg::trans_affine master_transform, py::ssize_t dims[] = { 2, 2 }; py::array_t extents(dims); - *extents.mutable_data(0, 0) = e.x0; - *extents.mutable_data(0, 1) = e.y0; - *extents.mutable_data(1, 0) = e.x1; - *extents.mutable_data(1, 1) = e.y1; + *extents.mutable_data(0, 0) = e.start.x; + *extents.mutable_data(0, 1) = e.start.y; + *extents.mutable_data(1, 0) = e.end.x; + *extents.mutable_data(1, 1) = e.end.y; py::ssize_t minposdims[] = { 2 }; py::array_t minpos(minposdims); - *minpos.mutable_data(0) = e.xm; - *minpos.mutable_data(1) = e.ym; + *minpos.mutable_data(0) = e.minpos.x; + *minpos.mutable_data(1) = e.minpos.y; return py::make_tuple(extents, minpos); } @@ -109,9 +109,7 @@ Py_path_in_path(mpl::PathIterator a, agg::trans_affine atrans, static py::list Py_clip_path_to_rect(mpl::PathIterator path, agg::rect_d rect, bool inside) { - std::vector result; - - clip_path_to_rect(path, rect, inside, result); + auto result = clip_path_to_rect(path, rect, inside); return convert_polygon_vector(result); } @@ -252,16 +250,11 @@ static py::object Py_convert_to_string(mpl::PathIterator path, agg::trans_affine trans, agg::rect_d cliprect, std::optional simplify, SketchParams sketch, int precision, - std::array codes_obj, bool postfix) + const std::array &codes, bool postfix) { - char *codes[5]; std::string buffer; bool status; - for (auto i = 0; i < 5; ++i) { - codes[i] = const_cast(codes_obj[i].c_str()); - } - if (!simplify.has_value()) { simplify = path.should_simplify(); } diff --git a/src/tri/_tri.h b/src/tri/_tri.h index 2319650b367b..994b1f43c556 100644 --- a/src/tri/_tri.h +++ b/src/tri/_tri.h @@ -75,7 +75,7 @@ namespace py = pybind11; -/* An edge of a triangle consisting of an triangle index in the range 0 to +/* An edge of a triangle consisting of a triangle index in the range 0 to * ntri-1 and an edge index in the range 0 to 2. Edge i goes from the * triangle's point i to point (i+1)%3. */ struct TriEdge final diff --git a/tools/boilerplate.py b/tools/boilerplate.py index 11ec15ac1c44..0a1a26c7cb76 100644 --- a/tools/boilerplate.py +++ b/tools/boilerplate.py @@ -1,12 +1,19 @@ """ Script to autogenerate pyplot wrappers. -When this script is run, the current contents of pyplot are -split into generatable and non-generatable content (via the magic header -:attr:`PYPLOT_MAGIC_HEADER`) and the generatable content is overwritten. -Hence, the non-generatable content should be edited in the pyplot.py file -itself, whereas the generatable content must be edited via templates in -this file. +pyplot.py consists of two parts: a hand-written part at the top, and an +automatically generated part at the bottom, starting with the comment + + ### REMAINING CONTENT GENERATED BY boilerplate.py ### + +This script generates the automatically generated part of pyplot.py. It +consists of colormap setter functions and wrapper functions for methods +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 +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. """ # Although it is possible to dynamically generate the pyplot functions at @@ -256,6 +263,7 @@ def boilerplate_gen(): 'pcolormesh', 'phase_spectrum', 'pie', + 'pie_label', 'plot', 'psd', 'quiver', diff --git a/tools/cache_zenodo_svg.py b/tools/cache_zenodo_svg.py index 59d6fce55162..07b67a3e04ee 100644 --- a/tools/cache_zenodo_svg.py +++ b/tools/cache_zenodo_svg.py @@ -63,6 +63,8 @@ def _get_xdg_cache_dir(): if __name__ == "__main__": data = { + "v3.10.7": "17298696", + "v3.10.6": "16999430", "v3.10.5": "16644850", "v3.10.3": "15375714", "v3.10.1": "14940554", diff --git a/tools/stubtest.py b/tools/stubtest.py index b79ab2f40dd0..d73d966de19e 100644 --- a/tools/stubtest.py +++ b/tools/stubtest.py @@ -108,6 +108,7 @@ def visit_ClassDef(self, node): [ "stubtest", "--mypy-config-file=pyproject.toml", + "--ignore-disjoint-bases", "--allowlist=ci/mypy-stubtest-allowlist.txt", f"--allowlist={p}", "matplotlib",