Skip to content

feat(py): capture matplotlib plots in the worker's ordered output - #411

Open
jat255 wants to merge 10 commits into
mainfrom
jat255/fj8r-plot-capture
Open

jat255 wants to merge 10 commits into
mainfrom
jat255/fj8r-plot-capture

Conversation

@jat255

@jat255 jat255 commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

This PR adds plot capture to the Python execution worker. Figures a call draws come back as PNG images, in order with the text the call printed. This is the worker and protocol half of plotting; the run_python tool that shows plots to the model and the user comes separately.

Review notes

  • Result and Error replace stdout/stderr with output, an ordered tuple of text and plot segments (_protocol.py, captured by _repl.py). This is internal API, and every caller is updated.
  • A figure enters the output at plt.show() or when the call ends, which is how Jupyter behaves (_plots.py). R also flushes a plot when text follows it; matplotlib edits figures in place, so doing that here would capture half-built figures.
  • Plots keep the figure's own size (matplotlib's default is 640×480), with the model image's long edge capped at 1568 px and a 2× image for display. R uses a fixed 768×512 device. This difference is deliberate, and it will be recorded when run_python lands.
  • A call returns at most 20 plots and 16 MiB of PNG data. The driver checks both again, along with each image's PNG header.

Testing

The full Python suite passes on macOS under seatbelt. The execution tests pass on Linux under Landlock and seccomp, run in a container, and guardrails mode is covered too. matplotlib is a dev dependency only.

Some manual testing:

Agent-written detail

kata: fj8r (this work); 2q9b (run_python, which will consume output).

Each figure is rendered twice (the model PNG and a 2× display PNG), then closed. Failures from model code (a broken artist, a replaced savefig or plt.close, a broken pyplot, an exception whose repr fails) cost only the affected figure and add a stderr note where the figure would have been. They never end the session.

@jat255 jat255 added py Affects the Python implementation needs-manual-review Agent-created work that needs a human review labels Oct 9, 2026
@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

Preview root: https://posit-dev.github.io/commons/pr-411/

Python site preview: https://posit-dev.github.io/commons/pr-411/py/

Built from the latest commit on this branch. The R links in it point at the published R site, which no pull request rebuilds.

@jat255
jat255 marked this pull request as ready for review October 10, 2026 01:28
@jat255 jat255 removed the needs-manual-review Agent-created work that needs a human review label Oct 10, 2026
@jat255
jat255 added this pull request to stack #416 October 10, 2026 01:34
@jat255 jat255 added this to the py-M6: code execution milestone Oct 10, 2026
jat255 added 10 commits October 9, 2026 19:51
After each call the worker renders every open pyplot figure twice, once
for the model and once at twice the pixels for display, then closes them
all so none carries into the next call. Result and Error gain a `plots`
field; an Error keeps what was drawn before the exception. A no-op
matplotlib backend keeps `plt.show()` quiet.

The model image keeps the figure's own size, scaled down so its long edge
fits 1568 px. A call returns at most 20 plots, and the protocol drops
plots before values when a reply would overrun the channel. A figure that
fails to render, or a pyplot that model code broke, costs the plots and
says so in stderr, never the session.
…raises

Plots now have a byte budget (a quarter of the channel line). The worker
stops rendering once a call's images exceed it, and the encoder drops
over-budget plots before base64-encoding them, so oversized figures are
never copied only to be thrown away. discard() catches any exception but
KeyboardInterrupt, so model code replacing plt.close cannot end the
session.
The early plot drop now clips the printed output before appending its
note, so the clip that follows cannot cut the note off.
…ng plots

Dropping over-budget plots no longer clips the printed output first.
Clipping instead keeps a trailing dropped-plots note, so the note
survives whichever path shrinks the reply.
…t PNGs

Worker side: a figure whose draw raises an exception with a failing
repr, or whose savefig writes something other than a PNG, now costs
only that figure. Saves reset savefig.bbox, so a tight bounding box or
padding from rcParams cannot grow an image past its size cap.

Driver side: decoding checks each image's IHDR chunk, bounds both
edges to PLOT_EDGE_LIMIT, and re-enforces PLOT_BYTES_LIMIT, since the
worker runs model code and cannot be relied on to keep either bound.
Result and Error replace their stdout and stderr fields with `output`, a
tuple of Text and Plot segments in the order they happened, so a plot
sits between the text written before and after it, and a warning keeps
its place between two prints.

The worker captures both streams into one Transcript. plt.show() puts
the open figures into it at that point, as Jupyter's inline backend
does, and the worker flushes once more when the call ends. Notes about
dropped or broken figures sit where those figures would have.

Each stream keeps its own capture bound, and a call may start at most
9,000 text segments, which leaves room in the protocol's 10,000-segment
limit for plots and notes. Clipping a reply now cuts each stream where
it crosses the limit and keeps everything else in place.
A note naming a failed figure keeps at most 500 characters of the
exception's repr, since notes bypass the capture's bound. Closing the
figures after a call runs outside the interrupt window, where only model
code can raise, so any exception there, KeyboardInterrupt included, now
costs the open figures rather than the session.
A figure left open after a call would come back with the next one, so
when model code has broken plt.close the figures are destroyed through
Gcf instead, an interrupt from the broken close still propagating once
they are gone. The 500-character clip on a note now covers the type-name
fallback too.
…lies

The figures still open when a call's code finishes are now drawn while
output is still captured, so text and warnings from that drawing reach
the reply instead of being lost.

The worker clamps a reply to the segment and plot counts the driver
accepts, and a malformed transcript entry is skipped instead of ending
the worker, so model code that changes the transcript directly cannot
make the driver restart the session.

fig.show() now places a figure in the output the way plt.show() does,
and the warning that the Agg backend cannot show figures is silenced,
since model code that selects Agg still gets its figures at the end of
the call. Comments and docstrings that misstated these limits are
corrected.
@jat255
jat255 force-pushed the jat255/fj8r-plot-capture branch from e8f9d2f to ebcdb79 Compare October 10, 2026 01:51

This branch has not been deployed

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

Labels

py Affects the Python implementation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant