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 44b2beec38b9..46a7c68f062e 100644 --- a/.github/workflows/cibuildwheel.yml +++ b/.github/workflows/cibuildwheel.yml @@ -192,32 +192,3 @@ jobs: 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@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.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@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0 - with: - subject-path: dist/matplotlib-* - - - name: Publish package distributions to PyPI - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 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/tests.yml b/.github/workflows/tests.yml index f25b5e4a03dc..048f11be14d2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -115,6 +115,7 @@ jobs: fonts-wqy-zenhei \ gdb \ gir1.2-gtk-3.0 \ + gir1.2-gtk-4.0 \ graphviz \ inkscape \ language-pack-de \ @@ -150,7 +151,6 @@ jobs: fi 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 \ 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/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 e2291e3255e6..3667d6e20a5d 100644 --- a/doc/devel/contribute.rst +++ b/doc/devel/contribute.rst @@ -188,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: @@ -238,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: @@ -306,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 5be8500428a0..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. @@ -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/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/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 e19d8e79faf2..11317669817f 100644 --- a/doc/install/dependencies.rst +++ b/doc/install/dependencies.rst @@ -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: @@ -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 4058b0549738..68ccfb8634ff 100644 --- a/doc/install/index.rst +++ b/doc/install/index.rst @@ -70,7 +70,7 @@ Python distributions Matplotlib is part of major Python distributions: -- `Anaconda `_ +- Anaconda_ - `ActiveState ActivePython `_ - `WinPython `_ 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/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/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/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 bc89131a6742..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,12 +46,12 @@ 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<26 - coverage 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/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/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/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/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/_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 56fda4ec6849..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'] diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index 8548d16d43de..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__) @@ -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 @@ -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) 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 f047fe1809aa..ecff24540690 100644 --- a/lib/matplotlib/axes/_base.py +++ b/lib/matplotlib/axes/_base.py @@ -3206,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 diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index fc7d651a6eb4..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). """ @@ -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). diff --git a/lib/matplotlib/backend_bases.pyi b/lib/matplotlib/backend_bases.pyi index 7a2b28262249..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, diff --git a/lib/matplotlib/backends/backend_gtk3.py b/lib/matplotlib/backends/backend_gtk3.py index 9c6d6250f486..20a1a3c8f0a9 100644 --- a/lib/matplotlib/backends/backend_gtk3.py +++ b/lib/matplotlib/backends/backend_gtk3.py @@ -18,13 +18,7 @@ # :raises ValueError: If module/version is already loaded, already # required, or unavailable. gi_require_version("Gtk", "3.0") - # Also require GioUnix to avoid PyGIWarning when Gio is imported - # GioUnix is platform-specific and may not be available on all systems - try: - gi_require_version("GioUnix", "2.0") - except ValueError: - # GioUnix is not available on this platform, which is fine - pass + 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. diff --git a/lib/matplotlib/backends/backend_gtk4.py b/lib/matplotlib/backends/backend_gtk4.py index 2fe2115b73cf..95b116e9a6ba 100644 --- a/lib/matplotlib/backends/backend_gtk4.py +++ b/lib/matplotlib/backends/backend_gtk4.py @@ -17,13 +17,8 @@ # :raises ValueError: If module/version is already loaded, already # required, or unavailable. gi_require_version("Gtk", "4.0") - # Also require GioUnix to avoid PyGIWarning when Gio is imported - # GioUnix is platform-specific and may not be available on all systems - try: - gi_require_version("GioUnix", "2.0") - except ValueError: - # GioUnix is not available on this platform, which is fine - pass + 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. diff --git a/lib/matplotlib/backends/backend_qt.py b/lib/matplotlib/backends/backend_qt.py index 974ebabf8a16..0b0240c90310 100644 --- a/lib/matplotlib/backends/backend_qt.py +++ b/lib/matplotlib/backends/backend_qt.py @@ -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"), 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/colors.py b/lib/matplotlib/colors.py index 07cbe4a79cb0..628d9f0acf77 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -3631,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/figure.py b/lib/matplotlib/figure.py index eba873cdc221..4cd7fd01a995 100644 --- a/lib/matplotlib/figure.py +++ b/lib/matplotlib/figure.py @@ -3003,12 +3003,10 @@ def _set_base_canvas(self): This is used upon initialization of the Figure, but also to reset the canvas when decoupling from pyplot. """ - # check if we have changed the DPI due to hi-dpi screens - orig_dpi = getattr(self, '_original_dpi', self._dpi) FigureCanvasBase(self) # Set self.canvas as a side-effect - # put it back to what it was - if orig_dpi != self._dpi: - self.dpi = orig_dpi + # undo any high-dpi scaling + if self._dpi != self._original_dpi: + self.dpi = self._original_dpi def set_canvas(self, canvas): """ @@ -3323,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/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/pyplot.py b/lib/matplotlib/pyplot.py index 25aa1a1b2821..225684d068f4 100644 --- a/lib/matplotlib/pyplot.py +++ b/lib/matplotlib/pyplot.py @@ -126,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 ( @@ -1262,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()`. @@ -3957,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, @@ -3981,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( @@ -4185,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, diff --git a/lib/matplotlib/quiver.py b/lib/matplotlib/quiver.py index df693c57d272..9ffcec5117d9 100644 --- a/lib/matplotlib/quiver.py +++ b/lib/matplotlib/quiver.py @@ -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/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/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_axes.py b/lib/matplotlib/tests/test_axes.py index 7307951595cb..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 @@ -2267,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) @@ -2957,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): @@ -3429,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 @@ -5045,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 @@ -6608,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(): @@ -9104,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") @@ -9966,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_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_colors.py b/lib/matplotlib/tests/test_colors.py index 4af0c84261b8..ee6e35f580a4 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -947,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)) 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_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_figure.py b/lib/matplotlib/tests/test_figure.py index 5f0e68648966..e666a3b99f7f 100644 --- a/lib/matplotlib/tests/test_figure.py +++ b/lib/matplotlib/tests/test_figure.py @@ -1688,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_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_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 '

= 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/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/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 a617d12c7072..0a1a26c7cb76 100644 --- a/tools/boilerplate.py +++ b/tools/boilerplate.py @@ -263,6 +263,7 @@ def boilerplate_gen(): 'pcolormesh', 'phase_spectrum', 'pie', + 'pie_label', 'plot', 'psd', 'quiver',