Skip to content

[pyplot] Make gallery behavior gates fail closed under callback errors and Matplotlib version skew #416

Description

@Alek99

Problem

Reaching the end of a script is not proof that an interactive Matplotlib example works. A callback can raise after the initial draw, a selector can fail to mutate its artists, or a small patch-version API change can make a reference example fail before XY is evaluated.

The gallery contract in PR #413 now drives required interactions deterministically, records behavioral evidence, fails on callback exceptions or unchanged required state, and confines version adaptation to an exact source/engine/version match.

The supplied 3.11.1 resample.py source needs one audited Matplotlib-3.11.0 reference-only adapter for the FillBetweenPolyCollection.set_data signature. No adapter is applied to XY, other files, or other versions.

Comparisons

Resample callback

Matplotlib xy.pyplot
Matplotlib resample XY resample

Polygon selector

Matplotlib xy.pyplot
Matplotlib polygon selector XY polygon selector

Complete upstream Matplotlib examples

event_handling/resample.py

"""
===============
Resampling Data
===============

Downsampling lowers the sample rate or sample size of a signal. In
this tutorial, the signal is downsampled when the plot is adjusted
through dragging and zooming.

.. note::
    This example exercises the interactive capabilities of Matplotlib, and this
    will not appear in the static documentation. Please run this code on your
    machine to see the interactivity.

    You can copy and paste individual parts, or download the entire example
    using the link at the bottom of the page.
"""

import matplotlib.pyplot as plt
import numpy as np


# A class that will downsample the data and recompute when zoomed.
class DataDisplayDownsampler:
    def __init__(self, xdata, y1data, y2data):
        self.origY1Data = y1data
        self.origY2Data = y2data
        self.origXData = xdata
        self.max_points = 50
        self.delta = xdata[-1] - xdata[0]

    def plot(self, ax):
        x, y1, y2 = self._downsample(self.origXData.min(), self.origXData.max())
        (self.line,) = ax.plot(x, y1, 'o-')
        self.poly_collection = ax.fill_between(x, y1, y2, step="pre", color="r")

    def _downsample(self, xstart, xend):
        # get the points in the view range
        mask = (self.origXData > xstart) & (self.origXData < xend)
        # dilate the mask by one to catch the points just outside
        # of the view range to not truncate the line
        mask = np.convolve([1, 1, 1], mask, mode='same').astype(bool)
        # sort out how many points to drop
        ratio = max(np.sum(mask) // self.max_points, 1)

        # mask data
        xdata = self.origXData[mask]
        y1data = self.origY1Data[mask]
        y2data = self.origY2Data[mask]

        # downsample data
        xdata = xdata[::ratio]
        y1data = y1data[::ratio]
        y2data = y2data[::ratio]

        print(f"using {len(y1data)} of {np.sum(mask)} visible points")

        return xdata, y1data, y2data

    def update(self, ax):
        # Update the artists
        lims = ax.viewLim
        if abs(lims.width - self.delta) > 1e-8:
            self.delta = lims.width
            xstart, xend = lims.intervalx
            x, y1, y2 = self._downsample(xstart, xend)
            self.line.set_data(x, y1)
            self.poly_collection.set_data(x, y1, y2, step="pre")
            ax.figure.canvas.draw_idle()


# Create a signal
xdata = np.linspace(16, 365, (365-16)*4)
y1data = np.sin(2*np.pi*xdata/153) + np.cos(2*np.pi*xdata/127)
y2data = y1data + .2

d = DataDisplayDownsampler(xdata, y1data, y2data)

fig, ax = plt.subplots()

# Hook up the line
d.plot(ax)
ax.set_autoscale_on(False)  # Otherwise, infinite loop

# Connect for changing the view limits
ax.callbacks.connect('xlim_changed', d.update)
ax.set_xlim(16, 365)
plt.show()

# %%
# .. tags:: interactivity: zoom, interactivity: event-handling

widgets/polygon_selector_simple.py

"""
================
Polygon Selector
================

Shows how to create a polygon programmatically or interactively
"""

import matplotlib.pyplot as plt

from matplotlib.widgets import PolygonSelector

# %%
#
# To create the polygon programmatically
fig, ax = plt.subplots()
fig.show()

selector = PolygonSelector(ax, lambda *args: None)

# Add three vertices
selector.verts = [(0.1, 0.4), (0.5, 0.9), (0.3, 0.2)]


# %%
#
# To create the polygon interactively

fig2, ax2 = plt.subplots()
fig2.show()

selector2 = PolygonSelector(ax2, lambda *args: None)

print("Click on the figure to create a polygon.")
print("Press the 'esc' key to start a new polygon.")
print("Try holding the 'shift' key to move all of the vertices.")
print("Try holding the 'ctrl' key to move a single vertex.")


# %%
# .. tags::
#
#    component: axes,
#    styling: position,
#    plot-type: line,
#    level: intermediate,
#    domain: cartography,
#    domain: geometry,
#    domain: statistics,
#
# .. admonition:: References
#
#    The use of the following functions, methods, classes and modules is shown
#    in this example:
#
#    - `matplotlib.widgets.PolygonSelector`

Acceptance

  • Callback exceptions fail the example even if initial rendering succeeded.
  • Required widget, event, and animation interactions produce changed semantic or device-space evidence.
  • Animation tests drive initial, middle, and final frames plus timer/callback behavior.
  • Version adapters are allowlisted by exact source hash, engine, and Matplotlib version and are reported in provenance.
  • Acceptance rejects unapproved adapters, fallbacks, and waivers.
  • The 71-example standard behavior gate remains fully green.

Metadata

Metadata

Assignees

No one assigned

    Labels

    pyplotxy.pyplot Matplotlib compatibility

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions