Skip to content

Avoid using pyplot for check_figures_equal#31076

Open
ayshih wants to merge 6 commits intomatplotlib:mainfrom
ayshih:check_figures_equal_thread_safety
Open

Avoid using pyplot for check_figures_equal#31076
ayshih wants to merge 6 commits intomatplotlib:mainfrom
ayshih:check_figures_equal_thread_safety

Conversation

@ayshih
Copy link
Contributor

@ayshih ayshih commented Feb 4, 2026

PR summary

The @check_figures_equal testing decorator currently uses pyplot to create the two figures, but the use of pyplot means that a wrapped test is automatically not thread-safe, even when the test itself does not need any pyplot functionality. This PR changes the testing decorator to create the two Figure instances directly, which means that such tests will not immediately fail when testing for thread safety (e.g., using pytest-run-parallel). I also removed the check for whether any new pyplot figures are created during the test because that is also not thread-safe.

PR checklist

@ayshih ayshih force-pushed the check_figures_equal_thread_safety branch 2 times, most recently from f22fb3b to 4db42cf Compare February 4, 2026 15:28
@ayshih ayshih marked this pull request as ready for review February 4, 2026 15:50
@ayshih
Copy link
Contributor Author

ayshih commented Feb 4, 2026

It doesn't appear that any existing test needs these figures to be in the pyplot stack, much less named, so I think this PR is good to go

@story645
Copy link
Member

story645 commented Feb 4, 2026

Cool!

@story645 story645 added this to the v3.11.0 milestone Feb 4, 2026
@story645
Copy link
Member

story645 commented Feb 4, 2026

I'm not sure what's going on w/ coverage here but the failures are in related functions.

jklymak
jklymak previously requested changes Feb 4, 2026
Copy link
Member

@jklymak jklymak left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You never call func so the none of the code in any of the tests gets called.

Please test locally with a test that should fail and make sure it fails.

@ayshih
Copy link
Contributor Author

ayshih commented Feb 4, 2026

Ha ha, quite right

@ayshih ayshih marked this pull request as draft February 4, 2026 17:17
@ayshih ayshih force-pushed the check_figures_equal_thread_safety branch from 4db42cf to 0e85fc7 Compare February 4, 2026 17:22
@tacaswell
Copy link
Member

I am very 👍🏻 on this change!

@ayshih ayshih force-pushed the check_figures_equal_thread_safety branch from 6a732d8 to d0ac853 Compare February 5, 2026 04:07
@tacaswell
Copy link
Member

It is probably worth adding a check that at least one of the figures has any marks. Means that "empty figure is empty" is not a test we can write, but I'm not super worried about preserving that possibility!

@ayshih ayshih force-pushed the check_figures_equal_thread_safety branch from d0ac853 to 9e5f156 Compare February 5, 2026 04:42
@ayshih
Copy link
Contributor Author

ayshih commented Feb 5, 2026

Okay, check added

fig_test = Figure()
fig_ref = Figure()
func(*args, fig_test=fig_test, fig_ref=fig_ref, **kwargs)
if not (fig_test.get_axes() or fig_ref.get_axes()):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not a requirement that we add Axes for a test; some tests may just draw text or something. For non-empty, you really need to check that all pixels are not white.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the fig.get_children() == 1 check is good enough and much cheaper than checking every pixel. 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are two tests that check that an empty figure equals an empty figure, so I have modified those tests

@ayshih ayshih force-pushed the check_figures_equal_thread_safety branch from 9e5f156 to 5cc7fca Compare February 5, 2026 05:23
@ayshih ayshih force-pushed the check_figures_equal_thread_safety branch from c1977d3 to dd31993 Compare February 5, 2026 14:59
@ayshih ayshih force-pushed the check_figures_equal_thread_safety branch from dd31993 to 97612de Compare February 5, 2026 15:02
@ayshih ayshih force-pushed the check_figures_equal_thread_safety branch from 97612de to 8bc5453 Compare February 5, 2026 15:22
Comment on lines 51 to 53
# TODO: Look into why the figures need to be pyplot figures to match
plt.figure(fig_ref)
plt.figure(fig_test)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have no idea why this is necessary. Rendered figures using the Cairo backend are apparently slightly different depending on whether the figures are pyplot-managed or not. fig_ref and fig_test do not match when the figures are not pyplot-managed, but they do match when the figures are pyplot-managed.

For comparison, with the Agg backend instead, fig_ref and fig_test do not match regardless of whether the figures are pyplot-managed or not.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because when we do fig.savefig('foo.png') we look at the current canvas and ask "can you save a png?" if yes then we save the png using the current canvas. If we can not, then go look up a canvas that can save a png, swap the canvas on the figure, re-render to save the file, and then put the old canvas back.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The @pytest.mark.backend('cairo') mark is picked up by the auto-fixture and sets the backend via plt.set_backend to be what ever backend is needed. If pyplot knows about the figures, then they get the cairo backend and use cairo to save the png. If they are "free" figures, then CanvasBase can not save a png so we select the Agg backend to render it which (for reasons I do not know but am guess are related to drawing collections aware of their parts or not) gives different behavior in this case.

Explictly creating and setting to a cairo canvas should also work.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, understood, I'll adjust my comment

@ayshih ayshih marked this pull request as ready for review February 5, 2026 20:14
@ayshih
Copy link
Contributor Author

ayshih commented Feb 5, 2026

Okay, ready for review again!

match=f'Data with more than {msg} cannot be '
'accurately displayed.'):
fig_test.canvas.draw()
with io.BytesIO() as buffer:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the same underlying problem as with cairo, because CanvasBase does not have the ability to draw anything (and if I recall this warning comes out of Agg) so this forces Agg to be used to render.

@tacaswell tacaswell dismissed jklymak’s stale review February 5, 2026 22:04

Bug was fixed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants