From 2c5e6e6c55db3e33f3f1876f282c6a9ff84c4936 Mon Sep 17 00:00:00 2001 From: Josh Taillon Date: Fri, 9 Oct 2026 07:34:53 -0400 Subject: [PATCH 01/10] feat(py): return the figures a worker call draws as PNG plots 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. --- pkg-py/pyproject.toml | 2 +- pkg-py/src/commons/_execution/_protocol.py | 112 ++++- .../_execution/_runtime/_figure_backend.py | 18 + .../src/commons/_execution/_runtime/_plots.py | 97 ++++ .../commons/_execution/_runtime/_worker.py | 24 +- pkg-py/tests/test_execution_guardrails.py | 12 + pkg-py/tests/test_execution_plots.py | 195 ++++++++ pkg-py/tests/test_execution_protocol.py | 85 ++++ pkg-py/uv.lock | 471 +++++++++++++++++- 9 files changed, 1006 insertions(+), 10 deletions(-) create mode 100644 pkg-py/src/commons/_execution/_runtime/_figure_backend.py create mode 100644 pkg-py/src/commons/_execution/_runtime/_plots.py create mode 100644 pkg-py/tests/test_execution_plots.py diff --git a/pkg-py/pyproject.toml b/pkg-py/pyproject.toml index 0c8bb203..f701c5ea 100644 --- a/pkg-py/pyproject.toml +++ b/pkg-py/pyproject.toml @@ -43,7 +43,7 @@ shiny = [ ] [dependency-groups] -dev = ["pytest>=8", "pytest-asyncio", "ruff", "pyrefly", "pandas>=2", "polars>=1", "pins>=0.9", "commons[shiny]"] +dev = ["pytest>=8", "pytest-asyncio", "ruff", "pyrefly", "pandas>=2", "polars>=1", "pins>=0.9", "commons[shiny]", "matplotlib>=3.8"] docs = [ "great-docs>=0.17", ] diff --git a/pkg-py/src/commons/_execution/_protocol.py b/pkg-py/src/commons/_execution/_protocol.py index 7a17ddcb..d22fb54a 100644 --- a/pkg-py/src/commons/_execution/_protocol.py +++ b/pkg-py/src/commons/_execution/_protocol.py @@ -37,12 +37,14 @@ __all__ = [ "FRAME_BYTES_LIMIT", + "PLOT_LIMIT", "STREAM_LIMIT", "Call", "ChannelError", "Error", "Message", "OpaqueValue", + "Plot", "ProtocolError", "Ready", "Result", @@ -90,6 +92,14 @@ _TRUNCATION_NOTE = "\n[truncated by commons: the output exceeded the channel limit]" +# Most plots one reply may carry; matplotlib's own warning about open figures +# starts at the same count. +PLOT_LIMIT = 20 + +_PLOT_DROP_NOTE = "\n[commons: the plots were dropped; they exceeded the channel limit]" + +_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" + # Arrow IPC header types and the buffer-less field type, numbered per the # format's flatbuffer schemas. _SCHEMA = 1 @@ -143,6 +153,30 @@ class OpaqueValue: text: str +@dataclass(frozen=True, kw_only=True) +class Plot: + """A figure the call drew, rendered once for the model and once for display. + + ``display_png`` has twice the pixels of ``png`` and is meant to be shown + at ``png``'s size, so text stays sharp on high-density screens. Both + images came from the worker; the decode side checks that each is a PNG + and nothing more. + """ + + png: bytes + display_png: bytes + + @property + def width(self) -> int: + """The model image's width in pixels, read from its header.""" + return int.from_bytes(self.png[16:20], "big") + + @property + def height(self) -> int: + """The model image's height in pixels, read from its header.""" + return int.from_bytes(self.png[20:24], "big") + + @dataclass(frozen=True, kw_only=True) class Ready: """Sent by the worker to the driver once the sandbox is up and running.""" @@ -170,15 +204,20 @@ class Result: value: Any = None stdout: str = "" stderr: str = "" + plots: tuple[Plot, ...] = () @dataclass(frozen=True, kw_only=True) class Error: - """Sent by the worker to the driver: the code raised, and this is what it said.""" + """Sent by the worker to the driver: the code raised, and this is what it said. + + ``plots`` are the figures the code drew before it raised. + """ id: str message: str traceback: str = "" + plots: tuple[Plot, ...] = () Message = Ready | Call | Result | Error @@ -810,7 +849,8 @@ def encode_message(message: Message) -> bytes: """Render ``message`` as the single line that carries it. A message that would exceed ``STREAM_LIMIT`` is shrunk first: printed - output is clipped, then values are replaced by their reprs. Raises + output is clipped, then plots are dropped with a note, then values are + replaced by their reprs. Raises ``ProtocolError`` when nothing is left to shrink — a ``Call`` carrying megabytes of code, say — because a line no reader can consume would wedge the channel. @@ -818,7 +858,12 @@ def encode_message(message: Message) -> bytes: line = _encode_line(message) if len(line) <= STREAM_LIMIT: return line - line = _encode_line(_shrink_text(message)) + message = _shrink_text(message) + line = _encode_line(message) + if len(line) <= STREAM_LIMIT: + return line + message = _drop_plots(message) + line = _encode_line(message) if len(line) <= STREAM_LIMIT: return line line = _encode_line(_shrink_values(message)) @@ -859,6 +904,7 @@ def _message_body(message: Message) -> dict[str, Any]: "value": encode_value(message.value), "stdout": message.stdout, "stderr": message.stderr, + "plots": [_plot_body(plot) for plot in message.plots], } case Error(): return { @@ -866,6 +912,7 @@ def _message_body(message: Message) -> dict[str, Any]: "id": message.id, "message": message.message, "traceback": message.traceback, + "plots": [_plot_body(plot) for plot in message.plots], } case _: raise ProtocolError( @@ -873,6 +920,14 @@ def _message_body(message: Message) -> dict[str, Any]: ) +def _plot_body(plot: Plot) -> dict[str, str]: + """The JSON object for ``plot``, its images in base64.""" + return { + "png": base64.b64encode(plot.png).decode("ascii"), + "display_png": base64.b64encode(plot.display_png).decode("ascii"), + } + + def _shrink_text(message: Message) -> Message: """The same message with its printed output and traceback clipped.""" match message: @@ -892,6 +947,19 @@ def _shrink_text(message: Message) -> Message: return message +def _drop_plots(message: Message) -> Message: + """The same message without its plots, saying so where the model reads it.""" + match message: + case Result() if message.plots: + return replace(message, plots=(), stderr=message.stderr + _PLOT_DROP_NOTE) + case Error() if message.plots: + return replace( + message, plots=(), traceback=message.traceback + _PLOT_DROP_NOTE + ) + case _: + return message + + def _shrink_values(message: Message) -> Message: """The same message with its values replaced by their reprs. @@ -963,20 +1031,24 @@ def decode_message(line: bytes | str, *, max_frame_bytes: int = FRAME_BYTES_LIMI ) case "result": _refuse_unknown_fields( - body, "result", {"type", "id", "value", "stdout", "stderr"} + body, "result", {"type", "id", "value", "stdout", "stderr", "plots"} ) return Result( id=_required_text(body, "id", "result"), value=_decode_result_value(body, max_frame_bytes), stdout=_optional_text(body, "stdout", "result"), stderr=_optional_text(body, "stderr", "result"), + plots=_decode_plots(body, "result"), ) case "error": - _refuse_unknown_fields(body, "error", {"type", "id", "message", "traceback"}) + _refuse_unknown_fields( + body, "error", {"type", "id", "message", "traceback", "plots"} + ) return Error( id=_required_text(body, "id", "error"), message=_required_text(body, "message", "error"), traceback=_optional_text(body, "traceback", "error"), + plots=_decode_plots(body, "error"), ) case _: raise ProtocolError(f"unknown message type: {kind!r}") @@ -1022,6 +1094,36 @@ def _decode_handles(body: dict[str, Any], max_frame_bytes: int) -> dict[str, Any } +def _decode_plots(body: dict[str, Any], kind: str) -> tuple[Plot, ...]: + """The ``plots`` of a result or error body, each image checked to be a PNG.""" + raw = body.get("plots", []) + if not isinstance(raw, list) or len(raw) > PLOT_LIMIT: + raise ProtocolError( + f"malformed {kind} message: plots must be a list of at most {PLOT_LIMIT}" + ) + return tuple(_decode_plot(item) for item in raw) + + +def _decode_plot(item: Any) -> Plot: + """One plot of a reply, refusing anything but two base64 PNGs.""" + if not isinstance(item, dict) or set(item) != {"png", "display_png"}: + raise ProtocolError("malformed plot: expected exactly png and display_png") + images = [] + for name in ("png", "display_png"): + text = item[name] + if not isinstance(text, str): + raise ProtocolError(f"malformed plot: {name} must be a string") + try: + data = base64.b64decode(text, validate=True) + except ValueError as error: + raise ProtocolError(f"malformed plot: {name} is not base64") from error + # The signature, then the IHDR chunk the size is read from. + if not data.startswith(_PNG_SIGNATURE) or data[12:16] != b"IHDR": + raise ProtocolError(f"malformed plot: {name} is not a PNG image") + images.append(data) + return Plot(png=images[0], display_png=images[1]) + + def _decode_result_value(body: dict[str, Any], max_frame_bytes: int) -> Any: """The decoded ``value`` of a result body, or ``None`` when absent.""" if "value" not in body: diff --git a/pkg-py/src/commons/_execution/_runtime/_figure_backend.py b/pkg-py/src/commons/_execution/_runtime/_figure_backend.py new file mode 100644 index 00000000..0fd97c95 --- /dev/null +++ b/pkg-py/src/commons/_execution/_runtime/_figure_backend.py @@ -0,0 +1,18 @@ +"""The matplotlib backend the worker selects: Agg rendering, and a quiet ``show()``. + +The worker collects every open figure after each call (see ``_plots``), so +``show()`` has nothing to do. Under plain Agg it would warn that the backend +is non-interactive, and that warning would reach the model on every script +that ends the usual way. matplotlib imports this module by name, through +``MPLBACKEND``, the first time pyplot needs a backend. +""" + +from __future__ import annotations + +from matplotlib.backends.backend_agg import FigureCanvasAgg + +FigureCanvas = FigureCanvasAgg + + +def show(*args: object, **kwargs: object) -> None: + """Do nothing: the figures come back with the call's reply.""" diff --git a/pkg-py/src/commons/_execution/_runtime/_plots.py b/pkg-py/src/commons/_execution/_runtime/_plots.py new file mode 100644 index 00000000..674e8625 --- /dev/null +++ b/pkg-py/src/commons/_execution/_runtime/_plots.py @@ -0,0 +1,97 @@ +"""Plot capture: the figures a call leaves open, rendered as PNGs and closed. + +matplotlib keeps every figure until it is closed, so after each call the +worker renders the figures pyplot is tracking and then closes them all, as +Jupyter's inline backend does. Each figure is rendered twice: once for the +model, at its own size, and once at twice the pixels for display. Figures +built without pyplot (``matplotlib.figure.Figure()``) are not tracked and +do not come back. + +Nothing here imports matplotlib. A call that never imported pyplot has no +figures, and the worker does not pay for the import on its behalf. +""" + +from __future__ import annotations + +import contextlib +import io +import sys +from typing import Any + +import _protocol # pyrefly: ignore[missing-import] + +__all__ = ["BACKEND", "collect", "discard"] + +# The value for MPLBACKEND, naming the sibling module by its import name. +BACKEND = "module://_figure_backend" + +# The longest edge of a model image. Larger images are scaled down by the +# provider anyway, so the extra pixels would only cost channel space. +_MODEL_LONG_EDGE = 1568 + +# How many times the display image's pixels exceed the model image's. +_DISPLAY_SCALE = 2 + + +def collect() -> tuple[tuple[_protocol.Plot, ...], str]: + """Render and close every open figure, and say what did not come back. + + Returns the plots in the order the figures were made, and a note for the + call's stderr naming any figure that failed to render or fell past + ``PLOT_LIMIT``. Model code can run inside ``savefig`` (an artist's + ``draw``) and can replace pyplot's own functions, so no failure here + may escape: a broken figure costs that figure, and a broken pyplot + costs the plots, never the session. + ``KeyboardInterrupt`` propagates; the caller then discards the figures. + """ + pyplot = sys.modules.get("matplotlib.pyplot") + if pyplot is None: + return (), "" + try: + figures = [pyplot.figure(number) for number in pyplot.get_fignums()] + except KeyboardInterrupt: + raise + except BaseException as exc: # noqa: BLE001 - model code can break pyplot + discard() + return (), f"[commons: the figures could not be collected: {exc!r}]\n" + notes = [] + if len(figures) > _protocol.PLOT_LIMIT: + notes.append( + f"[commons: the call left {len(figures)} figures open; only the " + f"first {_protocol.PLOT_LIMIT} came back]" + ) + plots = [] + for index, figure in enumerate(figures[: _protocol.PLOT_LIMIT], start=1): + try: + plots.append(_render(figure)) + except KeyboardInterrupt: + raise + except BaseException as exc: # noqa: BLE001 - one figure, not the call + notes.append(f"[commons: figure {index} could not be rendered: {exc!r}]") + discard() + return tuple(plots), "".join(note + "\n" for note in notes) + + +def discard() -> None: + """Close every open figure, so none carries into the next call.""" + pyplot = sys.modules.get("matplotlib.pyplot") + if pyplot is None: + return + # Model code can break pyplot; a figure left open costs nothing more. + with contextlib.suppress(Exception): + pyplot.close("all") + + +def _render(figure: Any) -> _protocol.Plot: + """``figure`` as a model PNG at its own size, scaled down to fit, and a display PNG.""" + width, height = figure.get_size_inches() + dpi = min(float(figure.dpi), _MODEL_LONG_EDGE / max(width, height)) + return _protocol.Plot( + png=_png(figure, dpi), display_png=_png(figure, dpi * _DISPLAY_SCALE) + ) + + +def _png(figure: Any, dpi: float) -> bytes: + buffer = io.BytesIO() + figure.savefig(buffer, format="png", dpi=dpi) + return buffer.getvalue() diff --git a/pkg-py/src/commons/_execution/_runtime/_worker.py b/pkg-py/src/commons/_execution/_runtime/_worker.py index 124b01d0..4d1193f9 100644 --- a/pkg-py/src/commons/_execution/_runtime/_worker.py +++ b/pkg-py/src/commons/_execution/_runtime/_worker.py @@ -69,6 +69,7 @@ def _silence_stderr() -> None: # Imported by bare name from directories put on sys.path at runtime, which # static analysis cannot follow. import _limits # pyrefly: ignore[missing-import] +import _plots # pyrefly: ignore[missing-import] import _protocol # pyrefly: ignore[missing-import] import _repl # pyrefly: ignore[missing-import] @@ -287,12 +288,19 @@ def guard(default): def _execute(call: _protocol.Call, namespace: dict) -> _protocol.Message: - """Run one call in the session namespace and render its reply.""" + """Run one call in the session namespace and render its reply. + + The call's figures are rendered inside the interruptible window, because + drawing a figure runs model code and can take as long as the call did. + """ global _in_call namespace.update(call.handles) + evaluation = None + plots, plot_notes = (), "" _in_call = True try: evaluation = _repl.run(call.code, namespace) + plots, plot_notes = _plots.collect() except KeyboardInterrupt: # The driver's SIGINT broke the call out of its computation. The # session and its variables survive; the driver reports the @@ -301,16 +309,23 @@ def _execute(call: _protocol.Call, namespace: dict) -> _protocol.Message: finally: _in_call = False if evaluation is None: + # Outside the interruptible window, so a second SIGINT cannot + # escape here. The half-drawn figures go with the call. + _plots.discard() return _protocol.Error(id=call.id, message="KeyboardInterrupt") if evaluation.error: return _protocol.Error( - id=call.id, message=evaluation.error, traceback=evaluation.traceback + id=call.id, + message=evaluation.error, + traceback=evaluation.traceback + plot_notes, + plots=plots, ) return _protocol.Result( id=call.id, value=evaluation.value, stdout=evaluation.stdout, - stderr=evaluation.stderr, + stderr=evaluation.stderr + plot_notes, + plots=plots, ) @@ -329,6 +344,9 @@ def _call_id(line: bytes) -> str | None: def main() -> None: network, protection = sys.argv[1], sys.argv[2] + # Read by matplotlib when pyplot first needs a backend, so it must be set + # before model code can import pyplot. + os.environ["MPLBACKEND"] = _plots.BACKEND _engage_sandbox(network, protection) # Installed explicitly, so a worker whose parent ignores SIGINT, as a # shell does for a background job, can still be interrupted. diff --git a/pkg-py/tests/test_execution_guardrails.py b/pkg-py/tests/test_execution_guardrails.py index 1df33133..197cd398 100644 --- a/pkg-py/tests/test_execution_guardrails.py +++ b/pkg-py/tests/test_execution_guardrails.py @@ -177,3 +177,15 @@ async def test_the_workers_own_work_between_calls_is_not_checked(guarded): assert isinstance(reply, Result), reply assert reply.value == 6 + + +async def test_a_plot_comes_back_under_guardrails(guarded): + # matplotlib reads its fonts and writes its cache while the hook is on, + # so the roots have to cover both. + pytest.importorskip("matplotlib") + async with guarded() as worker: + reply = await worker.run( + "import matplotlib.pyplot as plt\nplt.plot([1, 2, 3])\n" + ) + assert isinstance(reply, Result), reply + assert len(reply.plots) == 1 diff --git a/pkg-py/tests/test_execution_plots.py b/pkg-py/tests/test_execution_plots.py new file mode 100644 index 00000000..98f6c8a9 --- /dev/null +++ b/pkg-py/tests/test_execution_plots.py @@ -0,0 +1,195 @@ +"""Plot capture: the figures a call draws come back with its reply. + +Every test runs a real worker with real matplotlib; nothing is mocked. The +module skips where matplotlib is not installed or the host cannot sandbox +the worker. +""" + +from __future__ import annotations + +import os + +import pytest + +from commons._execution._driver import Worker +from commons._execution._protocol import PLOT_LIMIT, Error, Result +from commons._execution._sandbox import protection_mode + +pytest.importorskip("matplotlib") + +try: + _PROTECTION = protection_mode() +except RuntimeError: + _PROTECTION = None + +pytestmark = [ + pytest.mark.skipif( + _PROTECTION is None, reason="this host cannot sandbox the worker" + ), + pytest.mark.skipif( + os.name != "posix", reason="the interrupt escalation is POSIX-shaped" + ), +] + +PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" + +# The first matplotlib import in a fresh worker builds its font cache. +CALL_TIMEOUT = 30 + + +def make_worker(**kwargs) -> Worker: + kwargs.setdefault("call_timeout", CALL_TIMEOUT) + kwargs.setdefault("interrupt_grace", 2) + return Worker(**kwargs) + + +PLOT = """ +import matplotlib.pyplot as plt +plt.plot([1, 2, 3], [1, 4, 9]) +""" + + +async def test_a_figure_the_call_draws_comes_back_as_a_plot(): + async with make_worker() as worker: + reply = await worker.run(PLOT) + assert isinstance(reply, Result), reply + [plot] = reply.plots + assert plot.png.startswith(PNG_SIGNATURE) + assert plot.display_png.startswith(PNG_SIGNATURE) + # matplotlib's default figure is 6.4 x 4.8 inches at 100 dpi. + assert (plot.width, plot.height) == (640, 480) + + +async def test_the_display_image_has_twice_the_pixels_of_the_model_image(): + async with make_worker() as worker: + reply = await worker.run(PLOT) + assert isinstance(reply, Result), reply + [plot] = reply.plots + display_width = int.from_bytes(plot.display_png[16:20], "big") + display_height = int.from_bytes(plot.display_png[20:24], "big") + assert (display_width, display_height) == (2 * plot.width, 2 * plot.height) + + +async def test_showing_a_figure_is_quiet_and_still_returns_it(): + # `plt.show()` is what a script ends with; under a non-interactive + # backend matplotlib would warn that it cannot show anything. + async with make_worker() as worker: + reply = await worker.run(PLOT + "plt.show()\n") + assert isinstance(reply, Result), reply + assert len(reply.plots) == 1 + assert reply.stderr == "" + + +async def test_a_figure_comes_back_once_and_not_with_the_next_call(): + async with make_worker() as worker: + await worker.run(PLOT) + reply = await worker.run("1 + 1") + assert isinstance(reply, Result), reply + assert reply.plots == () + + +async def test_several_figures_come_back_in_the_order_they_were_made(): + code = """ +import matplotlib.pyplot as plt +plt.figure(figsize=(2, 1)) +plt.figure(figsize=(3, 1)) +""" + async with make_worker() as worker: + reply = await worker.run(code) + assert isinstance(reply, Result), reply + assert [plot.width for plot in reply.plots] == [200, 300] + + +async def test_a_call_that_raises_still_returns_what_it_drew(): + async with make_worker() as worker: + reply = await worker.run(PLOT + "raise ValueError('after the plot')\n") + assert isinstance(reply, Error), reply + assert len(reply.plots) == 1 + follow_up = await worker.run("1") + assert isinstance(follow_up, Result) + assert follow_up.plots == () + + +async def test_figures_past_the_limit_are_dropped_with_a_note(): + code = f""" +import matplotlib.pyplot as plt +for _ in range({PLOT_LIMIT + 3}): + plt.figure(figsize=(1, 1)) +""" + async with make_worker() as worker: + reply = await worker.run(code) + assert isinstance(reply, Result), reply + assert len(reply.plots) == PLOT_LIMIT + assert f"{PLOT_LIMIT + 3} figures" in reply.stderr + + +async def test_a_huge_figure_is_scaled_down_for_the_model(): + # A figure's size is the model's to choose, and a poster-sized one would + # cost far more image tokens than it is worth. + code = """ +import matplotlib.pyplot as plt +plt.figure(figsize=(40, 10), dpi=300) +""" + async with make_worker() as worker: + reply = await worker.run(code) + assert isinstance(reply, Result), reply + [plot] = reply.plots + assert plot.width == 1568 + assert plot.height == 392 + + +async def test_a_figure_that_cannot_be_drawn_is_reported_and_the_rest_return(): + code = """ +import matplotlib.pyplot as plt +from matplotlib.artist import Artist + +class Broken(Artist): + def draw(self, renderer): + raise RuntimeError("cannot draw this") + +plt.figure().add_artist(Broken()) +plt.figure() +""" + async with make_worker() as worker: + reply = await worker.run(code) + assert isinstance(reply, Result), reply + assert len(reply.plots) == 1 + assert "cannot draw this" in reply.stderr + follow_up = await worker.run("1") + assert isinstance(follow_up, Result) + assert follow_up.plots == () + + +async def test_an_interrupted_call_leaves_no_figure_for_the_next_one(): + async with make_worker() as worker: + # Pay the font cache in a call of its own, so the timeout below + # interrupts the loop rather than the import. + await worker.run("import matplotlib.pyplot as plt") + worker._call_timeout = 1 + await worker.run(PLOT + "while True:\n pass\n") + reply = await worker.run("1") + assert isinstance(reply, Result), reply + assert reply.plots == () + + +async def test_code_that_never_plots_does_not_load_matplotlib(): + async with make_worker() as worker: + reply = await worker.run("import sys; 'matplotlib' in sys.modules") + assert isinstance(reply, Result), reply + assert reply.value is False + + +async def test_a_broken_pyplot_costs_the_plots_and_not_the_session(): + code = """ +import matplotlib.pyplot as plt +x = 41 +plt.get_fignums = lambda: 1 / 0 +""" + async with make_worker() as worker: + reply = await worker.run(code) + assert isinstance(reply, Result), reply + assert reply.plots == () + assert "ZeroDivisionError" in reply.stderr + follow_up = await worker.run("x + 1") + assert isinstance(follow_up, Result) + assert follow_up.value == 42 diff --git a/pkg-py/tests/test_execution_protocol.py b/pkg-py/tests/test_execution_protocol.py index cc15bf81..e94f6c36 100644 --- a/pkg-py/tests/test_execution_protocol.py +++ b/pkg-py/tests/test_execution_protocol.py @@ -19,11 +19,13 @@ from commons._execution import _protocol from commons._execution._env import worker_command, worker_env from commons._execution._protocol import ( + PLOT_LIMIT, STREAM_LIMIT, Call, ChannelError, Error, OpaqueValue, + Plot, ProtocolError, Ready, Result, @@ -1326,3 +1328,86 @@ def test_a_frame_the_named_library_refuses_to_hold_is_refused(): def test_a_malformed_arrow_payload_is_refused(data): with pytest.raises(ProtocolError, match="malformed value payload"): decode_value({"encoding": "arrow", "library": "pandas", "data": data}) + + +# --- plots ------------------------------------------------------------------ + + +def _png(width: int, height: int) -> bytes: + """The smallest byte string a plot accepts: a signature and an IHDR chunk.""" + ihdr = ( + b"IHDR" + + width.to_bytes(4, "big") + + height.to_bytes(4, "big") + + b"\x08\x06\x00\x00\x00" + ) + return b"\x89PNG\r\n\x1a\n" + len(ihdr[4:]).to_bytes(4, "big") + ihdr + b"\x00" * 4 + + +def _plot_line(**fields) -> bytes: + body = {"type": "result", "id": "c1", "plots": [fields]} + return json.dumps(body).encode() + b"\n" + + +def _b64(data: bytes) -> str: + return base64.b64encode(data).decode("ascii") + + +def test_a_result_carries_its_plots(): + plot = Plot(png=_png(640, 480), display_png=_png(1280, 960)) + result = Result(id="c1", plots=(plot,)) + assert round_trip(result) == result + + +def test_an_error_carries_the_plots_drawn_before_it(): + # What was drawn before the exception is still worth showing, as in R. + plot = Plot(png=_png(640, 480), display_png=_png(1280, 960)) + error = Error(id="c1", message="ValueError: late", plots=(plot,)) + assert round_trip(error) == error + + +def test_a_plot_reports_the_size_of_its_model_image(): + plot = Plot(png=_png(640, 480), display_png=_png(1280, 960)) + assert (plot.width, plot.height) == (640, 480) + + +def test_a_plot_that_is_not_a_png_is_refused(): + with pytest.raises(ProtocolError, match="malformed plot"): + decode_message(_plot_line(png=_b64(b""), display_png=_b64(_png(2, 2)))) + + +def test_a_plot_whose_data_is_not_base64_is_refused(): + with pytest.raises(ProtocolError, match="malformed plot"): + decode_message(_plot_line(png="not base64!", display_png=_b64(_png(2, 2)))) + + +def test_a_plot_with_a_field_it_does_not_have_is_refused(): + with pytest.raises(ProtocolError, match="malformed plot"): + decode_message( + _plot_line(png=_b64(_png(2, 2)), display_png=_b64(_png(2, 2)), alt="hi") + ) + + +def test_plots_that_are_not_a_list_are_refused(): + line = b'{"type": "result", "id": "c1", "plots": "no"}\n' + with pytest.raises(ProtocolError, match="malformed result message"): + decode_message(line) + + +def test_more_plots_than_a_call_may_draw_are_refused(): + plot = {"png": _b64(_png(2, 2)), "display_png": _b64(_png(2, 2))} + body = {"type": "result", "id": "c1", "plots": [plot] * (PLOT_LIMIT + 1)} + with pytest.raises(ProtocolError, match="malformed result message"): + decode_message(json.dumps(body).encode() + b"\n") + + +def test_plots_too_large_for_the_channel_are_dropped_with_a_note(): + # The answer is worth more than the pictures: a result whose plots would + # overrun the line keeps its value and says what it lost. + big = _png(2, 2) + os.urandom(STREAM_LIMIT // 2) + result = Result(id="c1", value=42, plots=(Plot(png=big, display_png=big),)) + crossed = decode_message(encode_message(result)) + assert isinstance(crossed, Result) + assert crossed.value == 42 + assert crossed.plots == () + assert "plot" in crossed.stderr diff --git a/pkg-py/uv.lock b/pkg-py/uv.lock index e842cbaa..a5108acc 100644 --- a/pkg-py/uv.lock +++ b/pkg-py/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.15' and sys_platform == 'win32'", @@ -576,6 +576,7 @@ tracing = [ [package.dev-dependencies] dev = [ { name = "commons", extra = ["shiny"] }, + { name = "matplotlib" }, { name = "pandas" }, { name = "pins" }, { name = "polars" }, @@ -614,6 +615,7 @@ provides-extras = ["tracing", "shiny"] [package.metadata.requires-dev] dev = [ { name = "commons", extras = ["shiny"] }, + { name = "matplotlib", specifier = ">=3.8" }, { name = "pandas", specifier = ">=2" }, { name = "pins", specifier = ">=0.9" }, { name = "polars", specifier = ">=1" }, @@ -624,6 +626,196 @@ dev = [ ] docs = [{ name = "great-docs", specifier = ">=0.17" }] +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773, upload-time = "2025-07-26T12:01:02.277Z" }, + { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149, upload-time = "2025-07-26T12:01:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222, upload-time = "2025-07-26T12:01:05.688Z" }, + { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234, upload-time = "2025-07-26T12:01:07.054Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555, upload-time = "2025-07-26T12:01:08.801Z" }, + { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238, upload-time = "2025-07-26T12:01:10.319Z" }, + { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218, upload-time = "2025-07-26T12:01:12.659Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867, upload-time = "2025-07-26T12:01:15.533Z" }, + { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677, upload-time = "2025-07-26T12:01:17.088Z" }, + { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234, upload-time = "2025-07-26T12:01:18.256Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123, upload-time = "2025-07-26T12:01:19.848Z" }, + { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, + { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, + { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, + { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, + { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, + { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, + { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, + { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, + { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, + { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, + { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, + { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, + { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, + { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, + { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, + { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, + { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, + { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, + { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, + { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, + { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, + { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, + { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809, upload-time = "2025-07-26T12:02:52.74Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593, upload-time = "2025-07-26T12:02:54.037Z" }, + { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" }, + { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207, upload-time = "2025-07-26T12:02:57.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" }, +] + +[[package]] +name = "contourpy" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15' and sys_platform == 'win32'", + "python_full_version >= '3.15' and sys_platform == 'emscripten'", + "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", + "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", version = "2.5.2", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/5a/a55177dd22553a277388e8a1b3220e92de91bacb28356cdc73caa240121d/contourpy-1.4.0.tar.gz", hash = "sha256:20156f5a1ac4f8ce02656e39a61e82164a3d359796dc8026f75b062783d500e1", size = 13323726, upload-time = "2026-09-11T19:05:05.808Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/ba/01bde9753bdbba04a6da9d2bff3881f021bc708f654655e95fae26d3b3a3/contourpy-1.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:186ba929df36d61b6127da2e89cd1357e4cb79aca647381ab1a6feb0b152877b", size = 297302, upload-time = "2026-09-11T19:02:45.103Z" }, + { url = "https://files.pythonhosted.org/packages/dd/f5/c5bf67522d49a2222f3154fe46879451d26f0e35ece41770758a64ea78cb/contourpy-1.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c76f3a5318164db7d9401132fc1b5364b784c613c93fa506e3b5e6d1bf353ec8", size = 284921, upload-time = "2026-09-11T19:02:48.049Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cc/cb989599eec12fda312e127eb8e04a8b21a7a6c94bf7ff1bc68273334a42/contourpy-1.4.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:43c3ccbb32c6294b183dcc8e8c46dacf5ecef809497e3d48a5be298eef185ad0", size = 356332, upload-time = "2026-09-11T19:02:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/47/fb/6f620d7602507817b0b4c21dd790ed68688f1f4ae9c29aacf21f08db5cf1/contourpy-1.4.0-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86d05cec773c9507a3950122e0e40ce77c23c75ceeb2fc189514e71893cbb34b", size = 405878, upload-time = "2026-09-11T19:02:54.093Z" }, + { url = "https://files.pythonhosted.org/packages/32/4e/693c6d6bdece679f0953775eef3a1b66d56be55478d936e7deb1f3004422/contourpy-1.4.0-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2deb580178ca19437a84bd77e4bc2cd91a8ccad212413a83c273900868b5978c", size = 408296, upload-time = "2026-09-11T19:02:56.528Z" }, + { url = "https://files.pythonhosted.org/packages/78/f9/b6831508960d559581c448532ba4df312217072975486dbf2b24fcc5b76f/contourpy-1.4.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:875f42444c9cf48d56f724f2637e60d0f73b3b12c9041e1484580a233edf9591", size = 383243, upload-time = "2026-09-11T19:02:58.638Z" }, + { url = "https://files.pythonhosted.org/packages/f2/21/52903825a0ae7bb625e8bd30a09816d4c3c5d6174a469c10a616166cf780/contourpy-1.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f863c6100bf926cf47d13f3cd75f9bb8ebd98aaab230eeb4468b91f98f39a6b3", size = 1357306, upload-time = "2026-09-11T19:03:01.036Z" }, + { url = "https://files.pythonhosted.org/packages/d8/79/d68b1ce8539e4071518fed9523f558398f34dcd078b8927b109c72dad2ef/contourpy-1.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3863ef2e2b13fe93f8c0ebb08ee400cb07153b8b0e91c5acb26e4537f283634f", size = 1427243, upload-time = "2026-09-11T19:03:03.866Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f0/b75a10e9d0616b97a30de277b6dfe83f1879880854c7330337309530eb48/contourpy-1.4.0-cp312-cp312-win32.whl", hash = "sha256:5450f091ac1be0be3ad3a2a3b3f23b5e443e78c670ced4fd347d626f92a28fd2", size = 347345, upload-time = "2026-09-11T19:03:06.192Z" }, + { url = "https://files.pythonhosted.org/packages/50/a9/dab08786bb4d77ef9a046b3c1be923be4e73c5d07d91f5a54e8ea9b09e41/contourpy-1.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:6e697d94e69f499ff6bebb899cae97a58d5d14f0e1fe9568b43a0248d2f9af8c", size = 234083, upload-time = "2026-09-11T19:03:08.122Z" }, + { url = "https://files.pythonhosted.org/packages/47/b9/3ba509755a970dbde5948e7142ac21f266be72f38017cc4087a05fdceee1/contourpy-1.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:0c7a4c2716a4e98342221954416a836cca77c996c14ddbf22b5f01d5d93ca09c", size = 559521, upload-time = "2026-09-11T19:03:10.047Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8b/62a6eacea08ea0b6ab0159a7783df7ed421ff9c1ff58fef7c31f58fa5e52/contourpy-1.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e21d1d4a9db5b3a793649c7ab0ddb4697f2818929eec871a7b9c45fce7b2a1ea", size = 297330, upload-time = "2026-09-11T19:03:11.809Z" }, + { url = "https://files.pythonhosted.org/packages/3f/2f/103cdc5b7077cd569989d3ddb99ee3d4e96ee31bde8df2a6e426b2dc6195/contourpy-1.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:196759a3e4db60e1546167e361090ebb6d199aa3aafc9e34ea17a5ac3b23e814", size = 285037, upload-time = "2026-09-11T19:03:13.781Z" }, + { url = "https://files.pythonhosted.org/packages/bf/25/97001d1fdf940e6f5adfc592d4f5899fff95daf7dceead3988fe09987d2d/contourpy-1.4.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c0e07c691f3b3321913ed9b8161c50ea5f77fadd006f52f5e755359b0dcbfc5", size = 356675, upload-time = "2026-09-11T19:03:15.374Z" }, + { url = "https://files.pythonhosted.org/packages/a6/5e/9a7241a89e2f74402aefa2569e1349ad2750ff77823c04c788eaaedb7fdd/contourpy-1.4.0-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7f861af508fca2384eef392bc1d8377cfa349b2b854cb11b45d89c75780e2561", size = 406990, upload-time = "2026-09-11T19:03:17.44Z" }, + { url = "https://files.pythonhosted.org/packages/36/81/3a4fbbf6a806eada5453a6d16f6c7d96b9f4b3e57a3127368fb545986b8a/contourpy-1.4.0-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a03b32c2e7eda8b17757c022162c13c86f5c3d6f937dddf9ba3c3ff7b513953b", size = 407271, upload-time = "2026-09-11T19:03:19.523Z" }, + { url = "https://files.pythonhosted.org/packages/68/77/8f93ecbde1a4d11dfd460cd19a0c159f5487352e751fa87f5d78cc9db08e/contourpy-1.4.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1219a8898523cba821085f2da8a1b962cc696325763f09d7f93538ba43b2d70", size = 384707, upload-time = "2026-09-11T19:03:21.208Z" }, + { url = "https://files.pythonhosted.org/packages/05/07/e5cdf94d68961ee464de6b4fedd3d2f666584f59ccd124bb1290597122ab/contourpy-1.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:20da2f86bfaed60dbba729fe738c8241b1dd22b6cf7e8cbbf30df290bd04ba28", size = 1356741, upload-time = "2026-09-11T19:03:23.621Z" }, + { url = "https://files.pythonhosted.org/packages/fd/8a/2301d07accb257ac08daeb631875fada06fedd019c20ff7b00ee07e9d211/contourpy-1.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6ced1670fafee703b8277ab1645072a226e74f167166eae146fb743916119b67", size = 1426793, upload-time = "2026-09-11T19:03:25.955Z" }, + { url = "https://files.pythonhosted.org/packages/7d/5d/a62648f06259d15c139b14eb8cf3c58fe53cb970a55f2bb77d1070e80a41/contourpy-1.4.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:abd0f7e51ecc52bf8c95713bd80b1c17c516a71fa391c54475308a4377903c2b", size = 117961, upload-time = "2026-09-11T19:03:27.713Z" }, + { url = "https://files.pythonhosted.org/packages/71/31/c5b7771ba6982f319ec90c0cf5c425b36891adfeee2d9f667f90b2d0c166/contourpy-1.4.0-cp313-cp313-win32.whl", hash = "sha256:510f7d93d94cf6ebf4e2cd0640bea16ab8affac7547230175b6745b596ce0599", size = 347438, upload-time = "2026-09-11T19:03:29.829Z" }, + { url = "https://files.pythonhosted.org/packages/68/c1/a59df0754bf83a6f3e153cc19d550a5b01f607e389b1c4d57f436f9139e0/contourpy-1.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:6507a016976a75a15aee47809a34605de6244ccc056e6164bf9fb14b27eecad4", size = 234084, upload-time = "2026-09-11T19:03:31.657Z" }, + { url = "https://files.pythonhosted.org/packages/41/48/97224f5decb1efa5a610f912195deb45321f73ec9b9dac485cd6fe2d5a6a/contourpy-1.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:423bd5f4b3f11d54a8c597234e513382e3454bb106b8ec7ae32ba4ed63f8d99b", size = 559559, upload-time = "2026-09-11T19:03:33.528Z" }, + { url = "https://files.pythonhosted.org/packages/75/b0/b548628c8dddf6e5e0a981b7fa8e4c008024df09c7d17c6a9e271edfa0b3/contourpy-1.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a3e67bc1a6a4618a1dac7c3053a9ffece5ddfa2046b2670b342cf430bb0b87d1", size = 297354, upload-time = "2026-09-11T19:03:35.017Z" }, + { url = "https://files.pythonhosted.org/packages/04/5a/513484208742f65af2648c519f0b7ab034616e183c4403538024c136a1ab/contourpy-1.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:072a702e2e178f4fcf96f04775d0c059e4c917925abf0d3208ebb6865df3c23d", size = 283729, upload-time = "2026-09-11T19:03:36.818Z" }, + { url = "https://files.pythonhosted.org/packages/35/da/5a6562febc994b2c4bf9d01e57a50458ef8a2051bb3e7b35a00333e0fc26/contourpy-1.4.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:404dbcd9233513dfd1323b66ea593fef90e899f658f6b8a9ed8e93bd0ca669db", size = 357819, upload-time = "2026-09-11T19:03:39.058Z" }, + { url = "https://files.pythonhosted.org/packages/6d/99/7b358ce888aaa426f755daa096c99c59d17c352037344e836f20e5d3b957/contourpy-1.4.0-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:61624f6722480aa7e168fd746164e7bfdf48f8ccb6542f1613741200dc37e2b1", size = 407795, upload-time = "2026-09-11T19:03:41.293Z" }, + { url = "https://files.pythonhosted.org/packages/be/02/34d7548adf08c60967435d79d7a90882eacc84f5093435287974c7e99943/contourpy-1.4.0-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2e741a39dfc96babe1722561e81351aeec104526c57dbe91c167c1db606c2d55", size = 408552, upload-time = "2026-09-11T19:03:42.728Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8c/9677dad226e5aaac5b823d5b09d881ebc32ff1648af417597649841e516c/contourpy-1.4.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e439ab450c93455feb0218532ded3e946ec7a9b5f459069f122cfa44b89229f6", size = 384850, upload-time = "2026-09-11T19:03:44.402Z" }, + { url = "https://files.pythonhosted.org/packages/08/f5/e3fbeaa489c223629fbb434737cb3de4ecaca9cae4825e77b92490f640f8/contourpy-1.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:417be048f7e122cefbe2a34c51a1f2b0410f8eb35796f794d45475ddb7872d9d", size = 1357601, upload-time = "2026-09-11T19:03:46.746Z" }, + { url = "https://files.pythonhosted.org/packages/88/be/55fec293d342c222ed58a5d4056df4362e192af2e59527a3d5d822de8828/contourpy-1.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:43d80072a32299bf945de0a6dd4e034ea0162e832261de2e07a274a5adbba9c9", size = 1427340, upload-time = "2026-09-11T19:03:48.745Z" }, + { url = "https://files.pythonhosted.org/packages/86/40/0faabf453edf59b0e3633381d7ca72c3414fb9b72b5cbb08b06ed792d3e5/contourpy-1.4.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:fa1b787362a3856e63dd2b89449f6c384d55ee1beb8f94f4bcc871b40f02462c", size = 118355, upload-time = "2026-09-11T19:03:50.047Z" }, + { url = "https://files.pythonhosted.org/packages/38/0c/7ea75aa3559ae8a6805015e15a19c92b6480752ecc92526361e5776e88c5/contourpy-1.4.0-cp314-cp314-win32.whl", hash = "sha256:738c44fa71735a617f36da58e32810512407d283d5d1bb5b1cecb00d7eeba7cf", size = 356977, upload-time = "2026-09-11T19:04:11.401Z" }, + { url = "https://files.pythonhosted.org/packages/15/41/3df8cc14bb572c8b447f9dfbc876703f0f2f2896058285b5c87a1d4a638d/contourpy-1.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:181bea01bc742734ae672fa00c717d855dd35e1f029536406538c52e7b0cd73d", size = 240101, upload-time = "2026-09-11T19:04:12.772Z" }, + { url = "https://files.pythonhosted.org/packages/66/01/ee6830a5aa4565662a345172f6f35eeeed625d20f1c9d4ecd35318fe4ade/contourpy-1.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:64039341e2d8804f1a13bda8c69083eab935167bbd7e856cf096241f17d5bd45", size = 576359, upload-time = "2026-09-11T19:04:14.983Z" }, + { url = "https://files.pythonhosted.org/packages/51/af/49ee9c9cf012699e505c1b63c531cc99f19df50f205173092c4d541e3ec7/contourpy-1.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ef47fef9a912c77e3d84b014f701e5a9340f25703e9ed3bcebe37f91fb69dc49", size = 313601, upload-time = "2026-09-11T19:03:51.452Z" }, + { url = "https://files.pythonhosted.org/packages/ad/59/ce411ab2be0f805038626d8f4ca9ce563487ff51ff6c363e7512271564ed/contourpy-1.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:96019f059bcb3774acc0104be3360a57b36d33eaaa2e1d7f77c800ca8e07618b", size = 302812, upload-time = "2026-09-11T19:03:53.022Z" }, + { url = "https://files.pythonhosted.org/packages/95/b0/390915f9af14c1e2d3b2a9ad99d85b98df4352346a51e368cbb825f813c3/contourpy-1.4.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ddf5a2596d716fd3793434844caf31abc7a40b1e8718431420c89858268fb909", size = 348996, upload-time = "2026-09-11T19:03:54.594Z" }, + { url = "https://files.pythonhosted.org/packages/c7/85/e0952576d54322f3e9983b1e049a7a31f42ce82c5c29d01821e6dd3780d7/contourpy-1.4.0-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dd59df9e2fb0aff8bd7fd1adb9f349b7c835245a9d0f18c2f2deeb537190f2b1", size = 400272, upload-time = "2026-09-11T19:03:56.822Z" }, + { url = "https://files.pythonhosted.org/packages/d6/7d/f13543a5e1598e4a4ad01d9d5e2d65f9c09c37660616429913d606c5977d/contourpy-1.4.0-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b7794ea07cab575633daad8d6e963b662053391022294d3aacc1d9ba9ef54114", size = 412895, upload-time = "2026-09-11T19:03:58.487Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a8/6e5eaff53507dfd2dfc428240eecda32453067277d4b3e7ca71210b2d611/contourpy-1.4.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1c8a74744fa746eeaa12f0f9ed7f8b4add9d8f1b14d95e3b5e133675eac888f", size = 377060, upload-time = "2026-09-11T19:04:00.122Z" }, + { url = "https://files.pythonhosted.org/packages/16/a3/d00e44d35511b3cead5bd794ee672d4eecf30d9de4ceac57a49d6204064f/contourpy-1.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:107ec46f7aa1664266d69b1181aadc953d58d39f7d8d648fa5b795d4a060b0de", size = 1345688, upload-time = "2026-09-11T19:04:02.705Z" }, + { url = "https://files.pythonhosted.org/packages/1e/01/1e533b0cae32c8edd63fe40435ec3b29345dab87321afcd28722d7f957d1/contourpy-1.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5a4c89c7f38a0d7a94356d74e3391073c4325c4d47ad2fd065c3fe7dbae16c0c", size = 1416140, upload-time = "2026-09-11T19:04:04.77Z" }, + { url = "https://files.pythonhosted.org/packages/6d/0c/4ee66759f00d6a5d6909782f14f441d639fa18a4a43142e334351efc47f8/contourpy-1.4.0-cp314-cp314t-win32.whl", hash = "sha256:912c6afaa106e2f74ba22b30416b77ef7eb94e3bdc5a4df51ab147845dba9b6d", size = 371982, upload-time = "2026-09-11T19:04:06.233Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ac/b3e5324138c3e3741f526663f4f265f4f69dfdf26415a39428940864a5f6/contourpy-1.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:ef9440f6f8506246269a82734f5ff9e2e4c5e775b3996fc883cc491c5257eca6", size = 262907, upload-time = "2026-09-11T19:04:07.882Z" }, + { url = "https://files.pythonhosted.org/packages/19/37/c9aa45e47819dc15a38fc5c81a2fb987fde55e9d3b991fbde514e3b6b5f5/contourpy-1.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:fc9feef8f1f001c5b87decadc67c4a5d1eebb62ca39c4763d1237ff62cf2b707", size = 587071, upload-time = "2026-09-11T19:04:09.898Z" }, + { url = "https://files.pythonhosted.org/packages/cc/74/66d4e6fbea3bcb3fd4e5ae21c039ba31b8fc408b35884b4bc09b88016dce/contourpy-1.4.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:79b06c60d5e569ce12c5da8f0c51217cdc484386e2349fa717c30fafb56d2871", size = 297358, upload-time = "2026-09-11T19:04:16.718Z" }, + { url = "https://files.pythonhosted.org/packages/2e/8d/8f7f25e71d73f15d7859943189900d2d348e740388c9ef52a0180d284762/contourpy-1.4.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6f963ea9f0d68d2b6a02d861e3c0aae09ae25ba21f243fcf281e9fed468b078d", size = 283732, upload-time = "2026-09-11T19:04:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/f0/90/957549e3b03c407c670e3e191db63f4aef60dab6605bc967af3a806b543e/contourpy-1.4.0-cp315-cp315-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5545ff38229c15d11d822fc6f7415b932da9f4975f53e305b7b96ebfb01f889c", size = 357820, upload-time = "2026-09-11T19:04:20.077Z" }, + { url = "https://files.pythonhosted.org/packages/56/4c/fa8de87d907b41b05d03f4945a51675cd67319650d01b4c927d209ffa6ee/contourpy-1.4.0-cp315-cp315-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:630d32f06cedf37b7f1dd2b6a12e4492826bd477a4c9c7b2e2fee6ce3a2cea76", size = 407804, upload-time = "2026-09-11T19:04:21.781Z" }, + { url = "https://files.pythonhosted.org/packages/04/e7/96bd4f4a4ae285727befbdb4b332d53a2654cc5da4585d2525b47a063e1f/contourpy-1.4.0-cp315-cp315-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:be85d160e7c795113c2a93254dde2fc7e86b375322570eee78b420969426973c", size = 408559, upload-time = "2026-09-11T19:04:23.432Z" }, + { url = "https://files.pythonhosted.org/packages/ae/94/39660e8dc309f96bf7971eb4ec5659f272f84e42b34c9990a96ecce8442c/contourpy-1.4.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:018227e134b73090b06f911e773a526c77c9af426be29065500ce096a5accd4c", size = 384861, upload-time = "2026-09-11T19:04:25.198Z" }, + { url = "https://files.pythonhosted.org/packages/ab/7b/411dccbf73d5d19da49bf7735786b3baf4ddb1456412d48ae4d4ab1fc761/contourpy-1.4.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:4bffcb2e8cc5c0e837631acab59dd04265fdac376550e7c3e310523877c68cb7", size = 1357612, upload-time = "2026-09-11T19:04:27.614Z" }, + { url = "https://files.pythonhosted.org/packages/a0/39/bf2442130e7d5aae75a4de4504f0c8aca3ef3da3feac3940ee27ca985d83/contourpy-1.4.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc87f2ffa84a49a77d76cb792f28a48ff3b05a222aa0b2bddbd839ba78e1d5ca", size = 1427345, upload-time = "2026-09-11T19:04:29.646Z" }, + { url = "https://files.pythonhosted.org/packages/96/60/e5d9196dd83ccd7dd103762982b10ef19a4f72e8ef67ae7157699b9be0bb/contourpy-1.4.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:c8be3392b84cf43373a488874dc1ebf46f60784a70977f92586e18019d71c7ed", size = 118157, upload-time = "2026-09-11T19:04:31.091Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b2/ae3d90f08ba2ed82189d93b55ffbd41e559760e89af4f9ef54d51869f41d/contourpy-1.4.0-cp315-cp315-win32.whl", hash = "sha256:618137ec5778fb76d494c9ec854b104a377fddba0d128bef7136cce2c3bf0df8", size = 356982, upload-time = "2026-09-11T19:04:52.913Z" }, + { url = "https://files.pythonhosted.org/packages/02/a0/36648fff407dcf8b8482985d33367ffc0fe90be72383869312cd0bd00a1b/contourpy-1.4.0-cp315-cp315-win_amd64.whl", hash = "sha256:ea09dc704029930cbd097f75cb0cc1db9852adbcf1a151fac7e9559f7bcbfd19", size = 240101, upload-time = "2026-09-11T19:04:54.587Z" }, + { url = "https://files.pythonhosted.org/packages/07/09/49e987a96706b8f877596cee00d558d7880793a833addc6b33f80805e5dc/contourpy-1.4.0-cp315-cp315-win_arm64.whl", hash = "sha256:a5e2bb871b90cd7a52bdee63bf80ef7acb46398d27efc66d7679240016efc5aa", size = 576375, upload-time = "2026-09-11T19:04:56.515Z" }, + { url = "https://files.pythonhosted.org/packages/e3/18/820ba24a070fe2a834af73e52245c501960746f5c1777ebc64ec0c0dec12/contourpy-1.4.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:432e89f835cf01f8d2123a130a3126b17ee6e0348e4d62c0973872b30aa873bf", size = 313543, upload-time = "2026-09-11T19:04:32.582Z" }, + { url = "https://files.pythonhosted.org/packages/d3/4c/f61a63d8107e0c1c3fa47d30ce45abad90b2ad48a1a9a4510332db7e0176/contourpy-1.4.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:3e0a2392533e5e2abdf277c951047516acc8d2f3c42b2dd9dc34c907c9ebabf2", size = 302702, upload-time = "2026-09-11T19:04:34.257Z" }, + { url = "https://files.pythonhosted.org/packages/9d/93/5af95cb5113ecc6e780ca0c6c79756a2cea8833f971bbae112dd4ad73ded/contourpy-1.4.0-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:758e7496cec3195fc28a28945eab3ceef348c47fb95c7afc2eca2f3901209c8c", size = 354370, upload-time = "2026-09-11T19:04:36.158Z" }, + { url = "https://files.pythonhosted.org/packages/47/75/5a9942446647202beb7b4d150891bfcb78fb112c73862484bbe4cc031092/contourpy-1.4.0-cp315-cp315t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bee6ff96653e0807cd0f8eaef00174a031e3a2effa5c20f49a5a8d574b05af23", size = 402340, upload-time = "2026-09-11T19:04:37.914Z" }, + { url = "https://files.pythonhosted.org/packages/c3/4f/bfdb0c67775a584f957211936661289789c4d0ec3e988bca2c8c02f8090e/contourpy-1.4.0-cp315-cp315t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a58b5f130afec61a093d743cdda435cc4047748b6791627e34a68f41ec3a9b07", size = 410721, upload-time = "2026-09-11T19:04:39.925Z" }, + { url = "https://files.pythonhosted.org/packages/65/d3/dddf13d6bb26e145c99debd1f2695018b5f3e24452dd9bfc9904c9d2ec89/contourpy-1.4.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9dc493e7924e5aa32d89d0b8e50986280f3d4b284968a792787bd7bcffd3cad", size = 382604, upload-time = "2026-09-11T19:04:41.793Z" }, + { url = "https://files.pythonhosted.org/packages/37/b4/89f0a5f6c6ccd322eae2704c7e933c82a4e594a61773933b4b0a18646866/contourpy-1.4.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:c927ec747633e68c9920bcaad48b0bcefe648812b822f18c34e2d0e2270a3d2a", size = 1354996, upload-time = "2026-09-11T19:04:43.856Z" }, + { url = "https://files.pythonhosted.org/packages/5d/29/fc91cc1ac6f3b591a269f4357b656d6ed0c39eb17c148f41f6da2f9729b9/contourpy-1.4.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:80c7fc8e7ac217777ce7ba2db3cf8478652b5a4b335283111012ff9eaac9d842", size = 1425030, upload-time = "2026-09-11T19:04:45.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/b8/bb129f524210c5d3eb8616876e02af280fba8b5554e0ac1afad80b51e06b/contourpy-1.4.0-cp315-cp315t-win32.whl", hash = "sha256:90feb8da006803a95ba573fddc9536357d0c3faddc3a4c9e22816ca49af52878", size = 371854, upload-time = "2026-09-11T19:04:48.106Z" }, + { url = "https://files.pythonhosted.org/packages/d8/b1/bc774d9d200cfc4f073bba78b1bb1cb3b55bf1c8c345eae41fc2db510459/contourpy-1.4.0-cp315-cp315t-win_amd64.whl", hash = "sha256:de503609fdb71f597634ca64fb4bd2f13d27c97f140e912b1e8ed93544840df0", size = 262450, upload-time = "2026-09-11T19:04:49.502Z" }, + { url = "https://files.pythonhosted.org/packages/54/bf/eb4ff492e1c3d027617b5a20e4e6d63a035b41b8cf77a95950159556a3a5/contourpy-1.4.0-cp315-cp315t-win_arm64.whl", hash = "sha256:78ed5c5f962b3e156109f0531b174a65ba80d13cc681a70227b12524c162e184", size = 587064, upload-time = "2026-09-11T19:04:51.148Z" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + [[package]] name = "databackend" version = "0.0.3" @@ -729,6 +921,71 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, ] +[[package]] +name = "fonttools" +version = "4.66.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/87/b6/126c659ab7e0e03e01a5f5d223abf7b2c0691ae92718085a212a3924a2a3/fonttools-4.66.1.tar.gz", hash = "sha256:64967c6ddb0d4c610dfd8cb1485981b2d27972ddfb7d4bbbd9e199d2a089c450", size = 3694174, upload-time = "2026-09-29T16:11:53.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/2e/2c6d3daaa5152bbb2fc2b44037399366af7eb5fb2fbf7d9a236813753f77/fonttools-4.66.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d4f76868aea9cc4ce47fdbeaa904c02ee7d85dd0ad095071ae77f0bda6e62cf5", size = 3089406, upload-time = "2026-09-29T16:09:50.871Z" }, + { url = "https://files.pythonhosted.org/packages/b9/1c/500fbc0fd5b6d9cb701c1107a6f38ec4d3319057681e520014c1b9beb009/fonttools-4.66.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:34378db9a398b59de18cc79d942f0a907c6fc6301945e065ec888202f607aa3f", size = 2586728, upload-time = "2026-09-29T16:09:53.654Z" }, + { url = "https://files.pythonhosted.org/packages/38/f2/f3ac6374058bc93bd8a685c4849815b598bbd5e3ec5f706f3f4944a060e1/fonttools-4.66.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05c0fff6b4a5d872ed89cab2c4f81060b86ace263903eb4e8d0edcac47a60dfa", size = 5484428, upload-time = "2026-09-29T16:09:55.987Z" }, + { url = "https://files.pythonhosted.org/packages/c3/e0/ed45f50fe7a7320656ac7dde60f26afa3a92a21e14749135b4c03f3385b7/fonttools-4.66.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:72299346b96b9244dabcc051b24e4653da4edfda6105544cfb10ce856a1afaac", size = 5447695, upload-time = "2026-09-29T16:09:58.02Z" }, + { url = "https://files.pythonhosted.org/packages/3c/c0/919293f7b38ff81a6014a7fce45fbcc113aaf473f22180a7f7897c702a67/fonttools-4.66.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c724e56213494c6695335577822b2d1628d102e71614de8b7eb8e30886d6a314", size = 5450236, upload-time = "2026-09-29T16:10:00.105Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2a/00864b96e013a05df55b347b3eb9b1726803267d52345676cd86e928ddf1/fonttools-4.66.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b913b8e9f7ca9bec44d1eb919f591c596c61041aa357c96be55ff93169859e91", size = 5588056, upload-time = "2026-09-29T16:10:02.225Z" }, + { url = "https://files.pythonhosted.org/packages/8c/88/7b0259de6d874686532a781059fd85796dcd3e07a12146361092141169f3/fonttools-4.66.1-cp311-cp311-win32.whl", hash = "sha256:e7ea7a08547a453fa000db96ed5714a3dc7e2b4255b9243f897921f8c10c169a", size = 1577702, upload-time = "2026-09-29T16:10:04.082Z" }, + { url = "https://files.pythonhosted.org/packages/39/ff/ccaddfb8ac343e90f40f86fa460f832cd45460c65ee9288b6da923c72728/fonttools-4.66.1-cp311-cp311-win_amd64.whl", hash = "sha256:36bb24d4b98faacaff04af1d5e0a4285feba6ed1da6728cd34b6b6deb6bbb934", size = 1635298, upload-time = "2026-09-29T16:10:06.026Z" }, + { url = "https://files.pythonhosted.org/packages/06/1b/fcb22638f2f5918c855abfbab203701e4b03739953d26a59f3e6e59b8f30/fonttools-4.66.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8526b2b7ec4db6b81efb83438be52b1264eda9a4994d867163cfe8c65581ce8d", size = 3097433, upload-time = "2026-09-29T16:10:07.869Z" }, + { url = "https://files.pythonhosted.org/packages/7f/0d/f51141407f9a64efc9fb39b94b0194c4a99c1ffff50834e7ca7a3f1cdd53/fonttools-4.66.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6946fe7bfb28590a1fd4061a17609c9a843952deb65dcf30d1fe725070c3e7a4", size = 2587566, upload-time = "2026-09-29T16:10:10.099Z" }, + { url = "https://files.pythonhosted.org/packages/75/c0/5810d73f9102eb1a08f26b8f7a6c22498622b43e05e684cd5ec602be8ffd/fonttools-4.66.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09ae73bd219e1245debd8376077a0fa6e03175e255c4f51bae5f6a271bfe384a", size = 5416460, upload-time = "2026-09-29T16:10:12.182Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6e/babde908b879a3b51ffc230919d06a804912559dd4cf8c94f3fa2af69fec/fonttools-4.66.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b8ff9e0edbcee2fbf7dff0c41b9041c1901c26acf64e23adb67495012df11de", size = 5397326, upload-time = "2026-09-29T16:10:14.133Z" }, + { url = "https://files.pythonhosted.org/packages/e6/98/8522cc7a5e5ad64a2b2b6e9598489809ed4956c532b887c65b131b8500b7/fonttools-4.66.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:38ce8f5fbd5c17dd2153d47d7c8d4108f3deda3f2b4a79b60ddc470a58faded3", size = 5353857, upload-time = "2026-09-29T16:10:16.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/f9/ab87d67c23178886e57f24397b3113b4ac35297f086467c2c4d4231671a8/fonttools-4.66.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8aed2bbcd6216253ef1b015763593365ee8084f621dfa53bb957c19d5e05f7cd", size = 5517604, upload-time = "2026-09-29T16:10:18.791Z" }, + { url = "https://files.pythonhosted.org/packages/d4/20/e126062610aea31919310b0b3de4d0bbe73ccbdcb3d0409cc52027db93ee/fonttools-4.66.1-cp312-cp312-win32.whl", hash = "sha256:9ea6c93091cbf83161a544388746a0911550bd98cb911faca3591cf5ead166ac", size = 2435084, upload-time = "2026-09-29T16:10:21.145Z" }, + { url = "https://files.pythonhosted.org/packages/f0/af/5c245a0587e5b7b3dd209f640b9f70d8e63404ad8dc93819ba578d685985/fonttools-4.66.1-cp312-cp312-win_amd64.whl", hash = "sha256:261d8dc95845e751f975fe8d6075600593ee253470d46d1b84801688051b09f6", size = 2486987, upload-time = "2026-09-29T16:10:22.924Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f4/e410b8c913da5b3fdbb4d16db0f2d2a0952f59c4db8d52dcf2d421d82044/fonttools-4.66.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:53e5854ea8003efec34adc0863c18ce91da923018354d27366f7fee7db928d7a", size = 3095249, upload-time = "2026-09-29T16:10:25.261Z" }, + { url = "https://files.pythonhosted.org/packages/5c/6a/275108baf41d9f2f4d1d77cf5f1e22200fe47efd5099dafabc3eba0b6197/fonttools-4.66.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:60f5ea17aed4262630afa43f26997ceabd6417fa05dcedf54c665f5a29193e18", size = 2587288, upload-time = "2026-09-29T16:10:27.101Z" }, + { url = "https://files.pythonhosted.org/packages/db/e7/11e5e6beb7e336d80f0ca870ae080033a91ebfe34fd5390dbcf78f8df56f/fonttools-4.66.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1801fdad5600118327171e0e8aa79f7cc48831dd55ab36998c9de03bd5ffe6cd", size = 5389836, upload-time = "2026-09-29T16:10:28.988Z" }, + { url = "https://files.pythonhosted.org/packages/4c/1c/6ec22372362b03350fe3da7bf33491a07cc9a553a36dd2383b76ec1741eb/fonttools-4.66.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:83572afe48733bad7a4a9c11721d3a726c2e976d82b063fc9bdd049d76955abd", size = 5372059, upload-time = "2026-09-29T16:10:31.011Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4a/cb7971f1c0f40f891028ee8c46dadc6897ef61e44aa925a23fba2ef06e2a/fonttools-4.66.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:08d8956e3ec990c75230d92f1630b215e8f3738c83a003421c22b31ebfd0ce15", size = 5332824, upload-time = "2026-09-29T16:10:33.563Z" }, + { url = "https://files.pythonhosted.org/packages/e0/86/563e671f1d43fa8ffb2518d7fe16630fb16c7faf0420cc39f8e80181f486/fonttools-4.66.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fdf4afd75c643e60ef4a96fe64fc8a9def27d2a542112332371a9e5066885f9a", size = 5490659, upload-time = "2026-09-29T16:10:35.788Z" }, + { url = "https://files.pythonhosted.org/packages/79/f7/2573ddfd256be6503458f8523e2893443e66257fc17f6055d7e0f0e721b7/fonttools-4.66.1-cp313-cp313-win32.whl", hash = "sha256:dbb7b950f8c02deaffb6968994691e8589d671b7ef8396bc9d5b5c0dfbb7292f", size = 2433450, upload-time = "2026-09-29T16:10:37.738Z" }, + { url = "https://files.pythonhosted.org/packages/d1/86/68bc2be04b83535607fbb70ebb2ba02380bf4286d79597c4515b7d247187/fonttools-4.66.1-cp313-cp313-win_amd64.whl", hash = "sha256:43d1284c1964666ee833f2badd3017dc138f53d4889043ffca66c5ce4188f188", size = 2485146, upload-time = "2026-09-29T16:10:39.772Z" }, + { url = "https://files.pythonhosted.org/packages/12/83/c745b210ec49379ebfe627e166b527f44671a1f6ec5e1e219d91caa8964d/fonttools-4.66.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b18803cbdef248e7ee1be59cb277fbbe1da1faaa6f726fa5d3557904e6a3d967", size = 3099264, upload-time = "2026-09-29T16:10:41.998Z" }, + { url = "https://files.pythonhosted.org/packages/35/af/dd698f10bf0f743873077259e8a6fce075861dde3bb01eb22b2c4f7aefe8/fonttools-4.66.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f08ab7f8461c37ecfdd29ad97fb0c0780b50501bd664bb0f46b6e83ed2b9d2a7", size = 2588769, upload-time = "2026-09-29T16:10:43.933Z" }, + { url = "https://files.pythonhosted.org/packages/c5/65/10b5caa2aa779e62411b67949bda9741d4d7532ba0b6dea647b715131260/fonttools-4.66.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7cf4f996f9b1cb549bff9ea4c50813988a26ec922c95cfa85c7e4f1270447e06", size = 5374278, upload-time = "2026-09-29T16:10:45.727Z" }, + { url = "https://files.pythonhosted.org/packages/6a/db/9ac5c6773feec1b40e57eac106d869886f66a1e44082d343ac1e1e1fb773/fonttools-4.66.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9261ef507f2dd74203443a472b65b5a26429eb378f975016dec7dc7305b24898", size = 5317826, upload-time = "2026-09-29T16:10:48.056Z" }, + { url = "https://files.pythonhosted.org/packages/04/0a/69beb11f6b714ac90ee73ad4600ac91d7dd4e1ce361d087c8425bb8472de/fonttools-4.66.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e1cde50b3ec84ca6fe63ca815de183dbecb88e8adf8ada82d8ea130ef12b2b43", size = 5316239, upload-time = "2026-09-29T16:10:50.201Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/7a77359e469d3a638df91d3e225cef4a3c1184c20e98381238042f7835fa/fonttools-4.66.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d8f0a8f16c4f3a5a87ca971de2631792d8cb4d570951f2000acf712f157d40db", size = 5449423, upload-time = "2026-09-29T16:10:52.337Z" }, + { url = "https://files.pythonhosted.org/packages/93/cf/ea0b2f1ef90431b1879d6e6c680a7fde497129cf511ab995ade0ff8e19a7/fonttools-4.66.1-cp314-cp314-win32.whl", hash = "sha256:b878c78b2af11b879bd4f26bb0d8bda2a4c64543fdd3f28efe2c80f97f043885", size = 2437673, upload-time = "2026-09-29T16:10:54.281Z" }, + { url = "https://files.pythonhosted.org/packages/b2/53/629dbb4a40c4a7b3de61442c6b4430d36ab6e0e8cf941c547f4fd66f3337/fonttools-4.66.1-cp314-cp314-win_amd64.whl", hash = "sha256:05aeb146451f37289f782c3c861f3d0f4b86c2dd2e4620b46683544c7406640e", size = 2489763, upload-time = "2026-09-29T16:10:56.262Z" }, + { url = "https://files.pythonhosted.org/packages/0e/59/342e5fce9438f88882524128d1feb0311d4014cb6f8bdeb4607fcc00713f/fonttools-4.66.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:66fad3b7874062c2a2692f0ae6dea56d24f01b778c7f191950ca3ff997e25a88", size = 3172931, upload-time = "2026-09-29T16:10:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/50/92/96196ebfd02676f28fa9b3776d85e18281bca0c8450d7e214c40e346bf92/fonttools-4.66.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eef76d5796e604f9d6753fa6d323c4eb9f4e0e43f1dcca553f3e6914f1667b64", size = 2621937, upload-time = "2026-09-29T16:11:00.845Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c3/3f4b761037ebc2e5597c52c218a9e95dbc4a2cab572828654f6004f422f5/fonttools-4.66.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c47299bca4b5acaaeb32100f77b944feea151de9ef1773365a410dc3d49b945b", size = 5546932, upload-time = "2026-09-29T16:11:03.126Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d1/3f506cc79608becbc287785db8c44eb3f93079b49752266eb9f57700ecc4/fonttools-4.66.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dfba62cc93199ba62c376f90f2a9147d92730d301e44f88e013e50ff5edf6193", size = 5353546, upload-time = "2026-09-29T16:11:05.394Z" }, + { url = "https://files.pythonhosted.org/packages/6d/27/6534d84430ba1641185f8a0c9e2c7ecd395b15ff98f96967e3fb3c728b09/fonttools-4.66.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2c7340497cf53490293e0c2b61011e0191633022ede0a0a964a68157a98b0fb4", size = 5414313, upload-time = "2026-09-29T16:11:07.618Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/831ceca06d78855b11dc203b0e3ba5e6fd8a63a71ee0343ea8bd367fda55/fonttools-4.66.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c666fefdd5613a0e99aa4516e6ff4ef87aa86cf1c7ba12a73550f4770e46b750", size = 5449661, upload-time = "2026-09-29T16:11:09.997Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e4/21dc18bcbc8d0354814f6ea58af3d76d3bcd9b0d7246df454cb9e00c1740/fonttools-4.66.1-cp314-cp314t-win32.whl", hash = "sha256:2ce4c93160535761f22c80b2afbc96cabc09855363a5d1a5554265b8a4c85901", size = 2471431, upload-time = "2026-09-29T16:11:12.237Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f4/eb0489e7d58ac0d3387584afc7f3e505f60f60fe4b4f5a0274f013d444a2/fonttools-4.66.1-cp314-cp314t-win_amd64.whl", hash = "sha256:b13c8c541ce0b794add3211b3641cc0e113d707f73e06235e6fe9731bd7c45a9", size = 2521288, upload-time = "2026-09-29T16:11:14.52Z" }, + { url = "https://files.pythonhosted.org/packages/eb/95/235679d5fe4265c251418cd02321de069281a700415389e14c4cce442e3d/fonttools-4.66.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:2d637468dac23aac0e223bd52e66f8faa3b0dfcef57435460fa2107e830226cd", size = 3093646, upload-time = "2026-09-29T16:11:16.809Z" }, + { url = "https://files.pythonhosted.org/packages/ad/2b/7bcd4046b3b5644c563059cce6421b488fe57f65c59171ef01ed11b66d3a/fonttools-4.66.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:90de3477394c73481d27d2b86091c1c736053ee13ff52c42f0e151948e8578c6", size = 2587335, upload-time = "2026-09-29T16:11:18.721Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b6/05a093ec04fa2ad449ecc67638aad0f8d60df380df2471b68b549fe2a4b2/fonttools-4.66.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d84ac0bf776b68396185bd919dd29e633d94300660335efc40b55b294b886903", size = 5371509, upload-time = "2026-09-29T16:11:20.742Z" }, + { url = "https://files.pythonhosted.org/packages/65/a9/55effa83e64b9ff4f379d9186236d50d03f6d4770d8346805c1b6620c370/fonttools-4.66.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0dc6fd99cb8c30941036308b148da9432640442a6f26f36d71dad9be24cbd0e9", size = 5334853, upload-time = "2026-09-29T16:11:22.928Z" }, + { url = "https://files.pythonhosted.org/packages/af/a8/44bb4021c585b76f8e480116e1f3fca62eb7d88fe5794e2ec84c10d2da76/fonttools-4.66.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d3b5403e82d0c7659ff1d9f956e29a3a68d094f043e9f5bc0442796fc3a4fb58", size = 5311704, upload-time = "2026-09-29T16:11:25.393Z" }, + { url = "https://files.pythonhosted.org/packages/63/dd/dd482902fb7fd8b71d3b6508431a57938b5e41b29bf6fb252ed3cfce065f/fonttools-4.66.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b8b71db96d605784e2c5ebf0788a406018ea8fdd80338491f4c83613d5cd1fec", size = 5459477, upload-time = "2026-09-29T16:11:27.536Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a5/07611ba4d4b298b90908cb15005a6d730c334e25548f5175a09907b2eea6/fonttools-4.66.1-cp315-cp315-win32.whl", hash = "sha256:668f092bc0de8902167df6a0d5c5aedc3b4f9e43cf88eea92e9b46a2bd3968f5", size = 2436532, upload-time = "2026-09-29T16:11:29.653Z" }, + { url = "https://files.pythonhosted.org/packages/42/a5/5c39a05bf7c518743c6072cd75b63cd27285c58a70b1086e923fc071fb84/fonttools-4.66.1-cp315-cp315-win_amd64.whl", hash = "sha256:7f49f2834f5d006fe0f3bb10fec73b261806c50941f0cfbc08294074ffc32210", size = 2488769, upload-time = "2026-09-29T16:11:31.967Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a6/1205f7a7dd746581498457e55bfbcdfbea87105a454a7b3465259816bb79/fonttools-4.66.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:71c7ca1b5f46f5dd549f56b47d47c0b709217675c23d3a7bc6aa1a69b6d9bbae", size = 3164459, upload-time = "2026-09-29T16:11:33.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/42/915ff8f3c5d3bc9877007e708774e52f7ec431f9e59f607a86e50fe1864c/fonttools-4.66.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:2d320483928c7831f0139ecb361954a26b2e2a8995681200155835dd8cd4a7d5", size = 2618183, upload-time = "2026-09-29T16:11:36.067Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c6/cae2f6ebe38f8927a8d0978a349b202047268016344991a14ae978c2aee3/fonttools-4.66.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2aeb745f2664eb811026997c95628071137a777ea2ad296deec9cb393f0b23cf", size = 5524861, upload-time = "2026-09-29T16:11:38.099Z" }, + { url = "https://files.pythonhosted.org/packages/f2/14/1941629956b526d6fb46ee764cf0942221f0238581adb94de0ac229fe67f/fonttools-4.66.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3087a430722aba8de429c2539fd2a58a9cf05238cdfefd8626460001052ca878", size = 5346561, upload-time = "2026-09-29T16:11:40.366Z" }, + { url = "https://files.pythonhosted.org/packages/62/1f/b7e7f4757dcae74285f4ecd8453d870d63c7ba38a3d46bd9175a124c350b/fonttools-4.66.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:058cd823b80bac59e64dfad9e3b6fcd677852f9a3804971bbf6b48cc611e785c", size = 5392826, upload-time = "2026-09-29T16:11:42.653Z" }, + { url = "https://files.pythonhosted.org/packages/d9/71/76db3cbdcfac0e9b3ba26e1e6e8740040cfe5f7b5199dfb9b854bc8da2c3/fonttools-4.66.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:56d41d650cb8fc6cfe1d85ed7c62a0a56cbeed07bc65ca795475b914d401312a", size = 5439233, upload-time = "2026-09-29T16:11:45.088Z" }, + { url = "https://files.pythonhosted.org/packages/10/37/cdc6b213c9fbabdf36e9169f845e8596b419c7e0cceba48e5594b952d2cf/fonttools-4.66.1-cp315-cp315t-win32.whl", hash = "sha256:c258eba62260beb33c110b03a6912cefa3635239c4ab5615b7225fb6f7b85238", size = 2468614, upload-time = "2026-09-29T16:11:47.363Z" }, + { url = "https://files.pythonhosted.org/packages/fb/35/e2247e7e29e8da213e02691a6ada7a30592c7bc0d1db8d2786ebb9bea138/fonttools-4.66.1-cp315-cp315t-win_amd64.whl", hash = "sha256:5de5d80fbc0e50ff794c244e8fb7afd3eadfe0fa232ba8b162b8c551df22fcb4", size = 2516977, upload-time = "2026-09-29T16:11:49.425Z" }, + { url = "https://files.pythonhosted.org/packages/f6/10/d45b74135d5d642cb3a4fb0a957c1613ef93de4c8548671dfc3a5bf38299/fonttools-4.66.1-py3-none-any.whl", hash = "sha256:7234ae9e28db64273fbbfa72caebd0a97e3bdba6b05064114741b9539ef339d0", size = 1202222, upload-time = "2026-09-29T16:11:51.678Z" }, +] + [[package]] name = "fqdn" version = "1.5.1" @@ -1546,6 +1803,136 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/ef/6d27fc118f58cb24886da413545a7efb0853d405fddbfd8b2d9ac09fbed4/jupyterlab_widgets-3.0.17-py3-none-any.whl", hash = "sha256:40ac1e9955acf116c4d995d9bfa082d86ad9ec6d91c4f134827cf5e0a5eb75e0", size = 217292, upload-time = "2026-08-18T08:52:15.47Z" }, ] +[[package]] +name = "kiwisolver" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/07/bd78e6a8fae171ea041ef5bba3ed21a003522fa088834b069b1909981f30/kiwisolver-1.5.1.tar.gz", hash = "sha256:f1303ef2eec81262a4b708c3e858afe58d7c75ad91c1c05266eda7673369859a", size = 104395, upload-time = "2026-08-28T10:28:27.153Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/7b/2de6908edc668427c149af5f93112e931f87e1fa4cab80bac32c5844dccc/kiwisolver-1.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b3d78f7bb2b9d9a30345be1474b9aaa8685430b54afb51ba3639b5c6c11e9ed6", size = 123364, upload-time = "2026-08-28T10:25:04.359Z" }, + { url = "https://files.pythonhosted.org/packages/8a/24/e70914415c77c97be7e22c80a0740869cb7428768cc380fdcdf6703e7084/kiwisolver-1.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5025e36fb4fb275cef0a4e30dbb11cb4ae61d1c83deb90189cb5d7e4cafd6b55", size = 66558, upload-time = "2026-08-28T10:25:05.506Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2b/8b08b11833db4d475b8ef1f36174f8d8a7abd31bedd7e794be78e8814b48/kiwisolver-1.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc1a26b8e53395a01c2c611e58602fa47461f136fba7cd5542e6db6d64be1839", size = 64071, upload-time = "2026-08-28T10:25:06.7Z" }, + { url = "https://files.pythonhosted.org/packages/89/00/05c2d0369ac322d22d5c05f84b5c4a6856fa6207fbae42869108a28f0383/kiwisolver-1.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:95a02752aa032eef4aed01cda6d9b687c669bd0396bf4519eef8bba22a286720", size = 1438206, upload-time = "2026-08-28T10:25:08.254Z" }, + { url = "https://files.pythonhosted.org/packages/c0/05/c941a139f27438c1910d630fdc3ccfdab7c8407c72052299ead12ece086e/kiwisolver-1.5.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:719a35fa1156db3640555f95ebb94f60a444e64d1c69626b0edef5df78eba225", size = 1248975, upload-time = "2026-08-28T10:25:10.053Z" }, + { url = "https://files.pythonhosted.org/packages/58/a1/2669ee5512e39b9d4de25faacaedf788c957f93730c5f7c63993ec4f5933/kiwisolver-1.5.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:febcce10f2bcdbb80b4ea919238a6a4ac13dbc4c7cadbe8d5d75c3682f8b5404", size = 1266301, upload-time = "2026-08-28T10:25:11.754Z" }, + { url = "https://files.pythonhosted.org/packages/28/b8/353f52f2c7f861a9e90cd2e8f90f85b3ad03060835f823e08298d094c463/kiwisolver-1.5.1-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1d852545c4d0e35a72728d072cbaa59e2fa7dd84bdf01e068d670dd0ceb58eb6", size = 1319708, upload-time = "2026-08-28T10:25:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/21/0e/14b83200eadc2c1d63b76bac01c1813bf072aecf567429f303e00b70258e/kiwisolver-1.5.1-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:2e10ae1bba1899188b33557c10d73affcc12033edd18adddb57d209039976a4c", size = 971720, upload-time = "2026-08-28T10:25:14.934Z" }, + { url = "https://files.pythonhosted.org/packages/86/91/9d43d84d23b1cbff72a142d387ead1ea03db0cba8ff86ed5335addad3cc9/kiwisolver-1.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b69602970994a2ed8bbfa78c2f0394a7435226c6040489702d9f0a0ad0c07052", size = 2200119, upload-time = "2026-08-28T10:25:16.636Z" }, + { url = "https://files.pythonhosted.org/packages/ff/7c/f2bd9616f27ffb5e17cecc0baa5d0bbcee7e55aeddc0ccc871d69e2fc3ee/kiwisolver-1.5.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d50de98e8d807dc31822fff96f50293163a62418eb65487a21b42713d72ed0b7", size = 2295005, upload-time = "2026-08-28T10:25:18.374Z" }, + { url = "https://files.pythonhosted.org/packages/ba/17/ee671b72bf8f46a08379d4392c65582541759a542428197562f2898294ad/kiwisolver-1.5.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3221f78211074f561c44ca42eac0619828171bec15a2c4cf6f7747d07df76e8e", size = 1960982, upload-time = "2026-08-28T10:25:19.893Z" }, + { url = "https://files.pythonhosted.org/packages/2e/f7/0e26b4c05bee3bdb0f048dfa305e4fe701999ea17b51e9c616ef91035bbe/kiwisolver-1.5.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0ba9527afc80ae3d7814ed98b6572d02bf85eaf48065678342c5f0c6dab7a8c7", size = 2464918, upload-time = "2026-08-28T10:25:21.65Z" }, + { url = "https://files.pythonhosted.org/packages/ae/62/6eb431133d30ce656ac1e5ff72fac70dd34d54c3984f4011b9ac8bf77d54/kiwisolver-1.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e12dfea7f5fc2a34a9080efbf79c4c44eb380ec5b9c6fea09407e08f0d1e941d", size = 2270967, upload-time = "2026-08-28T10:25:23.643Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9f/6f9e489c188200e6fb3193935501894811e8c97577c8ffe9033589bf3521/kiwisolver-1.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:1a7587dc335f2c0f5bd577fd0540bd16c66006bdb60f759a1059f025e6c4f071", size = 70744, upload-time = "2026-08-28T10:25:25.061Z" }, + { url = "https://files.pythonhosted.org/packages/c6/6d/dfc430d1d43957061599adea3f08ea982bb6f4ab601a8c974bedcf2ba850/kiwisolver-1.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:e4e4523d6f336708d732516e6cfca7796cf3d96c9474eb5aecf6165f2f1fefc3", size = 68404, upload-time = "2026-08-28T10:25:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9b/65b302742389c6f96f2956bef5decf26011309feb2fc5d79613af18adea4/kiwisolver-1.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:63fb7294b768f444eb4b068965f2662f28c2fd4161e23bd60fcf3ff27b74c046", size = 123876, upload-time = "2026-08-28T10:25:27.44Z" }, + { url = "https://files.pythonhosted.org/packages/71/74/c21f339956f6f691b2ed7e31d5f3ae767304df6c460192739fc830853051/kiwisolver-1.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0ebdef3eae5336568147c39a55be6a2036ffde53faa9ca2d978989ae7c2da12c", size = 66487, upload-time = "2026-08-28T10:25:28.728Z" }, + { url = "https://files.pythonhosted.org/packages/84/e5/bdb34e21523e01dceda064d63713f3bdec91388af24fba1eca7ea5e85864/kiwisolver-1.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1798e83840c3f627246104c4d8a9639c60fa068adf9ce92b61791781fa8a68c1", size = 64660, upload-time = "2026-08-28T10:25:30.071Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f4/dadfec469313c7f428efa7e84b4aba9732f813c13ea7131a24b7b008ef57/kiwisolver-1.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34633ecf50d16187ab8e5528b7a2530f2feb4e23f300db4672538b51cfc5cd38", size = 1477929, upload-time = "2026-08-28T10:25:31.495Z" }, + { url = "https://files.pythonhosted.org/packages/6f/35/09c58daac34e6f6ea5c6dee0094b422118e5a7c265586008a95fd135ac5f/kiwisolver-1.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d27c2123977cb9269c30a49ba45f03a4323017ef693e19db4ec9dbe1299a3002", size = 1278499, upload-time = "2026-08-28T10:25:33.375Z" }, + { url = "https://files.pythonhosted.org/packages/19/32/739765e24fbad29d13f83e546ea4abc215a78cea9d677ca09025b027724d/kiwisolver-1.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6a797a1cefc8b9c93170db580337e1fe3d011ad18b1299943231279406342048", size = 1296677, upload-time = "2026-08-28T10:25:35.059Z" }, + { url = "https://files.pythonhosted.org/packages/df/32/03304d1010e2cc45e5b3b52cef7e43fed3a2a5cd6c87a89b4a88e1d85b5d/kiwisolver-1.5.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2551cf9917af48ee7c4b29cc82320489508cf96fd26a51f6fc124de661cd44c7", size = 1346037, upload-time = "2026-08-28T10:25:36.705Z" }, + { url = "https://files.pythonhosted.org/packages/3e/57/4c49377bfd274450dd72ecaa13eaac32ea804a03363e4d1db0c5aa999ceb/kiwisolver-1.5.1-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:38f6e0deb4d0a4615efe0c4efc5990b06ae450ab50a0b321c0b078b6d238c083", size = 988248, upload-time = "2026-08-28T10:25:38.299Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c3/38df144a08b6c5d75ca4504e5cc3141bb3bfef64c04f4ef48204f42711b6/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bfd1de989b3330420e29de39352f5c049905c9e3ee67233a50d550e3d652c148", size = 2228722, upload-time = "2026-08-28T10:25:40.038Z" }, + { url = "https://files.pythonhosted.org/packages/e7/11/3221838a89cd64d9b386353e000cd8a296069a20fbe3584507fdfd5bebae/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1209042a623ddfda5497e4066c7b77651dde8e1d3a9dd97599dc7e97f3b9b78c", size = 2325216, upload-time = "2026-08-28T10:25:41.699Z" }, + { url = "https://files.pythonhosted.org/packages/83/d4/075c219230697bb5db910d37262b9bacf880f92b4811a02ab81ed073a253/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:26e8268480be5061d509e29669d59103c067a26377a56491630ece11762e3858", size = 1977689, upload-time = "2026-08-28T10:25:43.559Z" }, + { url = "https://files.pythonhosted.org/packages/bb/08/1d219c3c2dd960983d0d4da623d916e9de6385df2b0bab3d1af0e9b8fccc/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d79308fa689fac89cbcfbd4dbfc80b5f95c54c5a7fd4d194be221f9d33d026e6", size = 2491443, upload-time = "2026-08-28T10:25:45.242Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d3/024208ec1079d273f1047468d1bdffbf38bb75b7b268090fd3a0301b9d9a/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b03af77d77e50edba2030fd5f7c352ff209314b09030a3cba7c14edf9a09a444", size = 2295200, upload-time = "2026-08-28T10:25:46.984Z" }, + { url = "https://files.pythonhosted.org/packages/6e/7c/7b210498f9f92e1cd7855f260fa69ef056881087b199ee20c208f0e4189a/kiwisolver-1.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:06a6917674de9e0fe3f66f5430787f59a9f2ddb64af9b714eaec547e29ef5c19", size = 70748, upload-time = "2026-08-28T10:25:48.444Z" }, + { url = "https://files.pythonhosted.org/packages/94/61/ef0daa157c8bb23672f7423e0d14c39db1dc6ef8ed47e6bc54c9c1bef3bf/kiwisolver-1.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:ad8b9671348d7c8716715652ae11f85ed0eb99e265a2df2ca490577d69860b2c", size = 68324, upload-time = "2026-08-28T10:25:49.81Z" }, + { url = "https://files.pythonhosted.org/packages/08/c1/88018321d976f53c421e379c43bc6993e70ce0c8a3ec5edc4bfe102257f6/kiwisolver-1.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b6ae6a0328f0bc035741820fdeecdcd67bf4694eee03972e843663107122f450", size = 62272, upload-time = "2026-08-28T10:25:51.02Z" }, + { url = "https://files.pythonhosted.org/packages/85/d2/712bc17ea4f1d216034928069d612defbc6c95a471c55a7203a39faecb1a/kiwisolver-1.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:886fc26012f0e8b5f69d1cfe6d711f6b11f194621539bf8e6bb1c25c5dc82724", size = 64481, upload-time = "2026-08-28T10:25:52.22Z" }, + { url = "https://files.pythonhosted.org/packages/1b/e6/6c5380d676f43b6d918033962ea5e72360ca69e5a404154bc496b598ffdb/kiwisolver-1.5.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:aefe930d113798330e9462f7874542977869c0613cba3262e2de3a8d5dee8f3a", size = 66260, upload-time = "2026-08-28T10:25:53.387Z" }, + { url = "https://files.pythonhosted.org/packages/92/6a/7087f5822cc8bb272679641404b1966a42504dac9ee74e2b33840475a0aa/kiwisolver-1.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5ca5aebae78a0bc13c1943af4af615d4966c5b650b05d5aa83b50e427196fee", size = 123876, upload-time = "2026-08-28T10:25:54.644Z" }, + { url = "https://files.pythonhosted.org/packages/5c/4b/9f385087ca09ee5ab9c09c6832561a7d2f7c78d3e5661d511e669f70e439/kiwisolver-1.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1ed0f5e49d0ceff8b72190824d9e59c062fbbc02c231b853112c78474b3f5ec2", size = 66487, upload-time = "2026-08-28T10:25:55.899Z" }, + { url = "https://files.pythonhosted.org/packages/47/8b/40d33ffd2f378094ed462e9a9a0907e59d4de9845e65a59561272da350d4/kiwisolver-1.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:77a4c8187a5948d7f8795adb765a3c7b553d07d86d88e43038fc32fc1fb9a3f3", size = 64673, upload-time = "2026-08-28T10:25:57.048Z" }, + { url = "https://files.pythonhosted.org/packages/ab/43/86aacc027959108b4c66eeae8b73cedb057dfa6eb3a335d05ad65197081c/kiwisolver-1.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:74ad5c3dad54a4641b4c28cd15ded70899d04459c6c7aeacafea716be97cce6d", size = 1477992, upload-time = "2026-08-28T10:25:58.479Z" }, + { url = "https://files.pythonhosted.org/packages/ed/40/b1d0369048c79733a32c8abb0f2718532e6630641368e33a81384246e844/kiwisolver-1.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e46b23a2da695c364124817bc01d970effd5483147f8d66a6a7167e3f6b851", size = 1278821, upload-time = "2026-08-28T10:26:00.121Z" }, + { url = "https://files.pythonhosted.org/packages/61/a1/fa71c1792272ff9461432715ae60ffb7e11a4d7ac3bf68961b9cab6c60cf/kiwisolver-1.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75d9b1cf8258462dbdc1eeda718c96ea7f079324c09067f6daabfcf37712b7fe", size = 1296805, upload-time = "2026-08-28T10:26:01.868Z" }, + { url = "https://files.pythonhosted.org/packages/a3/49/3f0bd94af8e06ecc47eb834b195a06f05d48711ceb2352c56d6835160f0e/kiwisolver-1.5.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fca690b00c4c48f6c2a547b0160ed511357093a4e4c9b47e0fadf3128066d89", size = 1346109, upload-time = "2026-08-28T10:26:03.59Z" }, + { url = "https://files.pythonhosted.org/packages/a4/44/3afe6ef9cf06d61220953a8963e94eca978491be1d9547cb01d82a1efa08/kiwisolver-1.5.1-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:876bbfd276473d3daffe30e8c975df4ed9429967b41a6cb362dbb5155b6f13ad", size = 988252, upload-time = "2026-08-28T10:26:05.306Z" }, + { url = "https://files.pythonhosted.org/packages/e4/30/a12bd7a7285a211e1747c3eec77b8c614dbfcc1dad942f7611a1a6921ae5/kiwisolver-1.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f942903fde7363d1d879057ec5de01310efda2597161784d752fa9953a01a71a", size = 2228846, upload-time = "2026-08-28T10:26:07.312Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6b/233e2958abf0dab7b18d07e52f286e02e519d7651bfbbe97af9347564109/kiwisolver-1.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c90d3022d8a94778939cda8638c6c8da8fa757b8958dad7ec868ce29c87681b8", size = 2325583, upload-time = "2026-08-28T10:26:09.093Z" }, + { url = "https://files.pythonhosted.org/packages/42/8e/7673060a27b01405b580058510adef34687069d229800239f5e44682d4d0/kiwisolver-1.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8a34616dc2521cc8dc1d7d081734da63539f021ac0450ce950908340c6e7aa2f", size = 1978221, upload-time = "2026-08-28T10:26:11.127Z" }, + { url = "https://files.pythonhosted.org/packages/3f/7b/1b882fc1a8b4a0bb8084e7d1d85004116d08c92c0705d31f2928dec607f2/kiwisolver-1.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:8bf4df63592c2a66b4f8edc5df2544998c288aa02f96ce0acd880cd1de8c8127", size = 2491819, upload-time = "2026-08-28T10:26:13.348Z" }, + { url = "https://files.pythonhosted.org/packages/39/9c/426deb49e62c5f69464b64bbeca064d3b758a7506b8913d986ef34f4619c/kiwisolver-1.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d09037ca068d784ebc4aec290ef952ca27ac15dd9c0b5801a88c6e1096b83e6b", size = 2295520, upload-time = "2026-08-28T10:26:15.042Z" }, + { url = "https://files.pythonhosted.org/packages/f5/22/deabbb3ad6d918d74b7831b2d8ae7151b09d21c87974e5ee8a456f58c94c/kiwisolver-1.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:dc23390afe9f4ef9ac3bcc72a03a56eebbde03f4c571a32cb38f859cff9a6524", size = 70758, upload-time = "2026-08-28T10:26:16.504Z" }, + { url = "https://files.pythonhosted.org/packages/b9/71/02fa5c2fd92068bb8952847e70ee6c5cb280e7febe11653d17812acc53dd/kiwisolver-1.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:186884a58486651e3c217b6acea0a53eaa9498fdd472057c46f2f0fb5c25aad5", size = 68329, upload-time = "2026-08-28T10:26:17.658Z" }, + { url = "https://files.pythonhosted.org/packages/dd/87/2d5dfad0daf17dcc18d98c48ed2332fc3f051cf599e60be6182a30dd4cf1/kiwisolver-1.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0324cd2567259b7a095f6cf18a52b0ffc6f3de9e69528ff1bc0e7a37bd43ff1a", size = 62337, upload-time = "2026-08-28T10:26:18.778Z" }, + { url = "https://files.pythonhosted.org/packages/08/c8/83e1624f15d6262b470dbcc80b09979fd4d5b2ea3ddfc6b6e3327e235726/kiwisolver-1.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:74ea337e0ec3f6f342a36a4f1b5cd94dd9affddcd28ba9aae2905af932ee8c6b", size = 64513, upload-time = "2026-08-28T10:26:19.909Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2d/827ec30eb07f528c08d8459ffb318ae91a56d793ee8acbea8b491f0ff906/kiwisolver-1.5.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ee9df1f0d77b9c6e94f4ac0fec533fbddd5ea3a327807f18d7b069ae019ded80", size = 66287, upload-time = "2026-08-28T10:26:21.078Z" }, + { url = "https://files.pythonhosted.org/packages/53/11/5c43a562529dad8def4b81e5e1877c612a7e0298105a5939b3b409d2079c/kiwisolver-1.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fc271a6f0a2126958f4090e5507b9da5848927dae331f8f763bd4aa642b3d2cd", size = 123940, upload-time = "2026-08-28T10:26:22.475Z" }, + { url = "https://files.pythonhosted.org/packages/64/db/9bd6c505c95128c258a55236bfbb3a7a3fb6023f863316b6d7d9f3c69052/kiwisolver-1.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9b3092d8992a1d69b7a59c3e39f35e1b9be327a17f68a7c35fc17329e337d6f2", size = 66493, upload-time = "2026-08-28T10:26:23.743Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f4/b3007a3ed5c9be73f81161140684cf7d9bdb9c4b632f5f484d2a1c713fb9/kiwisolver-1.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c2306e8bb53601979fcb3fa09cc65e031876d9ae01eff2fcbcd7a84ef94d5bc1", size = 64720, upload-time = "2026-08-28T10:26:24.95Z" }, + { url = "https://files.pythonhosted.org/packages/a1/13/08188f0cafa3a800403e4ff62b9aad4e7a17f9c4c7e080dc8f18c64794cf/kiwisolver-1.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:18a0cfb124546a4c2e6087c5f3029c7f44b37c85b142e0ced71f73a7599ac208", size = 1475867, upload-time = "2026-08-28T10:26:26.393Z" }, + { url = "https://files.pythonhosted.org/packages/8a/3e/053bdc3c9abdb8f2606225eda398adca25c0c91ab90add8222a69db65ee0/kiwisolver-1.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34ec467940442c9943016fb2d4c81d1ba84351eeca2f1a78f8bc87f1ba0d414c", size = 1282865, upload-time = "2026-08-28T10:26:28.118Z" }, + { url = "https://files.pythonhosted.org/packages/5e/64/a44c341b36b610588cc2f1e89b3cae072a3119aa8be578e90987cd640751/kiwisolver-1.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a83ee7107df13abe42a54a6654670eef9bb39425cf2e27f65e0007465e1286ab", size = 1300865, upload-time = "2026-08-28T10:26:30.125Z" }, + { url = "https://files.pythonhosted.org/packages/60/5e/7e7d716dca38c714478b741257a5b4a321d9932b8d851551a136dcaf3984/kiwisolver-1.5.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bebb89489b279b2f5661bbbb2abcc87bcd4a46607bb4a5c966f04f1db6b8df9a", size = 1348071, upload-time = "2026-08-28T10:26:31.829Z" }, + { url = "https://files.pythonhosted.org/packages/10/1a/2b98fdda8bf45b7be317e48ed12393d44334394d315c74b81f4a14c0e31b/kiwisolver-1.5.1-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:509735237ae0d849e8a843551d423d2500d2e0a9ac1611a145658b29c0fb9f85", size = 992191, upload-time = "2026-08-28T10:26:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/d893f5ede0e50f9e3fcf01f6015f42ec7d9cd221e26772701fe4a98745f9/kiwisolver-1.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:824c3d763a05ea9e9003610145186b0e9848c7584a5575c79bac5a8e7cd80bad", size = 2233854, upload-time = "2026-08-28T10:26:35.282Z" }, + { url = "https://files.pythonhosted.org/packages/b1/82/f85f6279555a6ee1639fef7bfe83adb037a03e11a6fc9eaa54b8d0380339/kiwisolver-1.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1fff05e239575b1481b6ed1a782f6fad616efbf1f0b1f44e6e85c4dfe426e483", size = 2330621, upload-time = "2026-08-28T10:26:36.9Z" }, + { url = "https://files.pythonhosted.org/packages/56/31/e11aea078f66fc2fffcc179d38ca90d9da97652a241b64519169742ba46a/kiwisolver-1.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0627b9bceb9c3cdcf12b8a18655eedfed2692b038df27423383c120d0b7dc2d6", size = 1982848, upload-time = "2026-08-28T10:26:39.01Z" }, + { url = "https://files.pythonhosted.org/packages/af/ea/2956b63bf5140ca46aa2c2818e6aa03e2d5754dd2fa41db1c6b28922940c/kiwisolver-1.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8a708a47ade1fe19e8371d5da076bac0dd4b0a5a7985ad6c637f7f7e361b6baa", size = 2494850, upload-time = "2026-08-28T10:26:40.837Z" }, + { url = "https://files.pythonhosted.org/packages/11/d1/3829542258d8b3fc0898d221e7ef0e2c83eca0d348709bb8dbe54f3d4005/kiwisolver-1.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:007a5553dfc4f4e8d184f588a0200e2cd4b63a59cc8796df3c39909e679dc7a0", size = 2298067, upload-time = "2026-08-28T10:26:42.803Z" }, + { url = "https://files.pythonhosted.org/packages/4e/0e/49522e1ab5788cbaf63a26fbd3b851f9028616828c961b8a31b35cb96df8/kiwisolver-1.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:f4167e87b397f273dc2356fcf1eaf50a6bac51e6105f45103ef7129c8efb0255", size = 72282, upload-time = "2026-08-28T10:26:44.268Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b6/22e7ca5315d363e6f81c9f37c9472e12e7b298731e77c0428e6a911a2c39/kiwisolver-1.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5c490db2168a508088f59140dd392556a54b8bd1048fc6383c8baff13c359673", size = 69855, upload-time = "2026-08-28T10:26:45.725Z" }, + { url = "https://files.pythonhosted.org/packages/30/8c/03a9cfbe871964c8758a816eb03ac96c806da2795a9a7cd9bf9648bfb594/kiwisolver-1.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4d4ca09bf13cff792b1884f64b98ee6c2467930d632233be25c56b442d99f10e", size = 126289, upload-time = "2026-08-28T10:26:47.023Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e3/14ce3041ca79dff9c9d884ca00c7bf32374e76028a865a9ecd99b4f5a517/kiwisolver-1.5.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:44b8faef94f1857e77fa0238f3390ff1ac51d2ea20a487e2e452a59fd2b5f5ca", size = 67709, upload-time = "2026-08-28T10:26:48.268Z" }, + { url = "https://files.pythonhosted.org/packages/af/c4/45030471a66ec8ef042e9f96ffe1d522c9ab12da180186a0898966fc1385/kiwisolver-1.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2ae70bc59790d2af72a3f76f24b272403e135070340281108b447cb77ea70819", size = 65909, upload-time = "2026-08-28T10:26:49.523Z" }, + { url = "https://files.pythonhosted.org/packages/42/73/17dce073a6ae259bb32cf9d686c4079d2e538868bc45967462bf33df914a/kiwisolver-1.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:43844c1a7ad6d723d5b5b4c4fc7f5bd399c40e288120d16257c7c9e8765c6e85", size = 1584907, upload-time = "2026-08-28T10:26:50.933Z" }, + { url = "https://files.pythonhosted.org/packages/8c/84/ae3c75909f507283cbfcc7e916c7e822579ef962020b97e6882b27b4478f/kiwisolver-1.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22d5e5aaad6be121f2515765e3b1c444352cb8eb4c86510801db8f2e50757316", size = 1392474, upload-time = "2026-08-28T10:26:52.638Z" }, + { url = "https://files.pythonhosted.org/packages/34/31/8bcc83caad5bce8fa4577152389848bf6bc110e51e573a2b4e7c2aa34c89/kiwisolver-1.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3fa5855898f6d3d01b72ccd48a2d65cbdee301251603fefe34e2025bddba219c", size = 1405246, upload-time = "2026-08-28T10:26:54.248Z" }, + { url = "https://files.pythonhosted.org/packages/55/72/220345537d790cf4ae54f8acfff4b5cc2468e0702a384d651cf7a771c63e/kiwisolver-1.5.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d66a64dd5dec136040ec2ae94aa026a912ee60fdd45bc28d3db30037fd809e88", size = 1456099, upload-time = "2026-08-28T10:26:56.042Z" }, + { url = "https://files.pythonhosted.org/packages/cd/10/3725fd2398f66d18c34b4e0f81a8d03764cd4f4f089f58a527f0b4428086/kiwisolver-1.5.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:9e51c119992ea8820706871c30a4642ec76de20ae82f9b50b9a45517d8e9f810", size = 1073695, upload-time = "2026-08-28T10:26:57.658Z" }, + { url = "https://files.pythonhosted.org/packages/86/cb/28d6e09e66b93e4588b2e6b7d84d020ccefea09e2f4de788510a07efeab7/kiwisolver-1.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:70ed9a45c7484d2b30cdacf60d220f494a1763b9fec1ad03285c6553fa0889f2", size = 2335355, upload-time = "2026-08-28T10:26:59.202Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b3/a0f31d5e4e40af7dc97c36b8a74fdd3a36cf3c8bbd098da9a23466ff6a94/kiwisolver-1.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:98b208a7cc42c803445ef551d6753cc42a5ea13e9cab1ee66cd8b9cb70195330", size = 2426524, upload-time = "2026-08-28T10:27:01.181Z" }, + { url = "https://files.pythonhosted.org/packages/2f/c9/728f63bd58c72cafdc79fc306abeeac7391bec03b757a48dadeb30906521/kiwisolver-1.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c6834b92dd2428e2dd85ef3d85f723d3c12f20aaf43a2ddd4f944ca25d833408", size = 2063430, upload-time = "2026-08-28T10:27:03.06Z" }, + { url = "https://files.pythonhosted.org/packages/84/df/ce188b96f92f9a2c958231da140768918cba53c9713dc887b82f85462118/kiwisolver-1.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:5d142e352eb13facc7dd047489aebdff6ba78576c239f1ea04931979caaf0567", size = 2597513, upload-time = "2026-08-28T10:27:05.072Z" }, + { url = "https://files.pythonhosted.org/packages/69/d6/76947c8203768968382e5bd74d9cc95654746703a61ea53015f2c74a2e06/kiwisolver-1.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9b1c4900736e489a812c529100de4b8fb617d4db075e931e213c57424b83d9b", size = 2394488, upload-time = "2026-08-28T10:27:07.423Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d4/14b21e4eb203c4d15425e8b6a2c625a320b4a1f2f7557eead63ffc30ffb7/kiwisolver-1.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5978c3340f16a35c30f8ab2fa7bcf559973c55f1a5ef6970e1f621acf3c4db13", size = 75404, upload-time = "2026-08-28T10:27:08.892Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f5/53157899fc7f45f76421b77b99eb1639dd0f83f26ff9d76300c96bb4a3b0/kiwisolver-1.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ca307d6c259e5c98d3cb9ade55342b47a6839762caf2536f3d7b46ee660cc82e", size = 72946, upload-time = "2026-08-28T10:27:10.944Z" }, + { url = "https://files.pythonhosted.org/packages/75/62/f786c3a27f181fa339d851a77e266d208e776b9883cabc40a5b041a31b5a/kiwisolver-1.5.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:bb7c99f0673c03017a3ee01e54a5c2617a05468b11eabe513b0080e063ed95b1", size = 62420, upload-time = "2026-08-28T10:27:12.308Z" }, + { url = "https://files.pythonhosted.org/packages/02/cd/58a91ed25fbad0facdf503297b03768efab04bdf3141e5e3b49a34be7443/kiwisolver-1.5.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:0d8924877ce22e17326a99a418c3c82037da078df3c6a260b13eca677444e6e7", size = 64577, upload-time = "2026-08-28T10:27:13.502Z" }, + { url = "https://files.pythonhosted.org/packages/f6/5c/d501ef5a0958b226eac28306d24d5e5f114be0ace50e19cabae7b6b3b197/kiwisolver-1.5.1-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:534f02c1abb31ed6dbd3515545285c330b2f12d00fdb1fdb71658b9ca5a13a6a", size = 66284, upload-time = "2026-08-28T10:27:14.813Z" }, + { url = "https://files.pythonhosted.org/packages/a3/a6/8fbecaf4fc18c02f31f05e47a84c010a80e3ec391ed2f0bdade1d62b5954/kiwisolver-1.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:cea20da04494e662b83c872683bf4ff2345206043d036315ed0e924b652e7294", size = 124031, upload-time = "2026-08-28T10:27:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c6/bf3090d2983b4204347cbdbe952116e7c3b2abf62b4e33e50167a13e75ee/kiwisolver-1.5.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:7fd82debf43c6acd0a94359d232f6bb516ee13f269a7993736a9ac9f988bb5d9", size = 66489, upload-time = "2026-08-28T10:27:17.506Z" }, + { url = "https://files.pythonhosted.org/packages/85/de/562dddef55fdd7c291da8626d6619e72b5fc0870e6ccca0e149a5731e7f3/kiwisolver-1.5.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:18170a77ddfecf40ec60d0928268dc95880c881864e015a8f34094ed18b9b9ad", size = 64806, upload-time = "2026-08-28T10:27:18.673Z" }, + { url = "https://files.pythonhosted.org/packages/89/b1/ba7b9c0164ce1cf62bf2872db63b8483289cf0f3110d6f9390eb09e409ed/kiwisolver-1.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ca7f6fe0f37ca978a1e5eb7a3a68e6413f417e78e838324947ffd420202b198b", size = 1482211, upload-time = "2026-08-28T10:27:20.039Z" }, + { url = "https://files.pythonhosted.org/packages/13/dc/34da54efb4976616d45c20aae32d70e89d6e7395ed908029154d1609ef22/kiwisolver-1.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b973887ff782cfd6b67c9904ad8ca542e0bc5e4961503408b423b5a688b4d38", size = 1283739, upload-time = "2026-08-28T10:27:21.82Z" }, + { url = "https://files.pythonhosted.org/packages/5a/3a/30ffb62bee646e266e98a1b5cd276d9c75b6116fbfcb87c1190838c1b6df/kiwisolver-1.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f76fc85bd054c806960f917ec0f329e24e436f1712267d90588e4c39890caa63", size = 1301681, upload-time = "2026-08-28T10:27:23.876Z" }, + { url = "https://files.pythonhosted.org/packages/a9/8d/13c70be22a8506880b35fdc38dca36629613bc493405c79f4037f2cd2bb9/kiwisolver-1.5.1-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:828f75af2b0080c8a972e75f649ab46af008e92c6104a57a759157200b835b75", size = 1349159, upload-time = "2026-08-28T10:27:25.899Z" }, + { url = "https://files.pythonhosted.org/packages/82/0e/993972b8ec6767f47cd69818fb3a5ff14510557d29f7d1a839be7574fa1b/kiwisolver-1.5.1-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:431dc224a1a92a5c8f582d96e505196a3b5997a7271076678da2dfde67b77e9a", size = 997613, upload-time = "2026-08-28T10:27:27.507Z" }, + { url = "https://files.pythonhosted.org/packages/36/82/ca26eddd2eda2420dfc56693449c1f821f78b485da9cbde9904c03af3f93/kiwisolver-1.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:61e9a64c7635095a6bfe483e2ff055d437c59bd45f3617a228b37277f0185d62", size = 2235109, upload-time = "2026-08-28T10:27:30.113Z" }, + { url = "https://files.pythonhosted.org/packages/c7/84/97d920881e10840b8d7c7185620298e3e4c88820b05514e3a15a258b08a6/kiwisolver-1.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:3c24cd69455e1b00ddf770c13b6e2c33e07d6dc3f2d34add0bf9277c5c6bbd46", size = 2331207, upload-time = "2026-08-28T10:27:32.795Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ce/34d74b8f25acc58800f4c09268371e8d6159cf0f1206f1e4dc7835629b48/kiwisolver-1.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:27add358abe374ebaa3b8763ef380bc99051b5a4b18d94878366a9e4f59efef0", size = 1986696, upload-time = "2026-08-28T10:27:34.628Z" }, + { url = "https://files.pythonhosted.org/packages/0e/01/f892644014612527aef7031d3306a2ffc60b3cb044f802c1561f8e5e14f3/kiwisolver-1.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:255605693a483db7bd5c79f60437f7bf658f7f520d61aa42722e32257c941951", size = 2496400, upload-time = "2026-08-28T10:27:36.818Z" }, + { url = "https://files.pythonhosted.org/packages/32/6d/d8284e66e697026536e5f418b9cfe56567bffd3c775e3ecfbae373605854/kiwisolver-1.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:7d38b0c279c3032e8c9cc013b405c6df8e1668dbf15465779aa7f15f61201812", size = 2302967, upload-time = "2026-08-28T10:27:38.803Z" }, + { url = "https://files.pythonhosted.org/packages/46/0a/69a355e27f32ba50d5b6369949b6a1702e122f5277c89bc76d452b81c1c4/kiwisolver-1.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:958254518717542d02d0688d0d20cbf771da5e415e6f49543f92481c850a4540", size = 72283, upload-time = "2026-08-28T10:27:40.491Z" }, + { url = "https://files.pythonhosted.org/packages/ef/d8/7a95be90c33dcdd52204d4aa6384d731443225b887283bbd8b61e7931f6c/kiwisolver-1.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:da3275833be0edbaf4830fae08bae3dc7219f40ce0c37eaa6c25825957e06612", size = 69860, upload-time = "2026-08-28T10:27:41.835Z" }, + { url = "https://files.pythonhosted.org/packages/31/f8/9bc493e7f5707788ba7f621902c68f82dc3a7ba03c78fbd337b026cef1ed/kiwisolver-1.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:470d420f98d368d6f010633a20659b544c5fdfa5329e6b70219f2ef08fd4a7ef", size = 126336, upload-time = "2026-08-28T10:27:43.379Z" }, + { url = "https://files.pythonhosted.org/packages/d1/82/3aea86b3f99712db825e9ac5631bf99571e818a8b8961ff98cebd798413e/kiwisolver-1.5.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:83f78128fa28705fa85d01c59771c72fe81c11bd0e6155edbb9f818983a7d761", size = 67698, upload-time = "2026-08-28T10:27:44.613Z" }, + { url = "https://files.pythonhosted.org/packages/84/7d/8daafc5d2e7f9c47a4f78f8865d86d2a9cf399c2a85f86c44a993594410c/kiwisolver-1.5.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:9506e892bcc3b409831d363c6f53e5985e1c8d1f6f6b0256d00358684ff85378", size = 65945, upload-time = "2026-08-28T10:27:45.932Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c9/51dc974d9130da70a8c47a96160123443d387ffe1b6b833d6f91d9429339/kiwisolver-1.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cea90547bfd93807e0013a004dc76552be44fad3bc1cc2b38610a9e889ed098f", size = 1588030, upload-time = "2026-08-28T10:27:47.678Z" }, + { url = "https://files.pythonhosted.org/packages/e6/86/f3e1a730e7a995149d8d3ff9e313b6d8a17b2cf1d98a8eff139dc30463fb/kiwisolver-1.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8e4d953faaded9ec7ede36824e9814082d22d4c7b1eafbfa079ecba8cd0d076", size = 1390760, upload-time = "2026-08-28T10:27:49.676Z" }, + { url = "https://files.pythonhosted.org/packages/4f/3b/8ba25a2b5a0d2375e046f1b72de5179513f0be95aba6e7b094c89303929f/kiwisolver-1.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e9c01d3dd7ceba4d1d436cc021d40d592466e40b9bc7f5d83dc4e98a5c9cd8c", size = 1403279, upload-time = "2026-08-28T10:27:51.242Z" }, + { url = "https://files.pythonhosted.org/packages/18/d0/278d5cb8be812740027d5ca0a7eda0c375488a88d6dce0fa60fcc2591ad2/kiwisolver-1.5.1-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37f801b5d7cc0e5a548921308e059fd2b057bb42972b591cfa3049f95423c4ed", size = 1454429, upload-time = "2026-08-28T10:27:53.213Z" }, + { url = "https://files.pythonhosted.org/packages/94/37/bcbab41063ec284c1d200efe5087cf087798c2f8916960aa8a20dd303290/kiwisolver-1.5.1-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:e68e151428b5384f766cd25739bf77c7e4a3dc93b5ded7a12118d9fbfdf78ab6", size = 1073225, upload-time = "2026-08-28T10:27:55.089Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c5/ab79dcdf5ae28909a51210ae0a1c579e97ff997b3466414f0d04c0994583/kiwisolver-1.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:8f8fddb8e323bd6eee4e54e69a39243beab22689070f4c66b472c4cc88bb89d8", size = 2334335, upload-time = "2026-08-28T10:27:56.78Z" }, + { url = "https://files.pythonhosted.org/packages/8d/8e/71d047468a189041d9c93f3b76844b924f9793b188c44bd149fa258912da/kiwisolver-1.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:3cc210010fd2f438a3ed430b45f1b501fd13a8618bf984dc2c5ce5b69b78752e", size = 2424982, upload-time = "2026-08-28T10:27:58.639Z" }, + { url = "https://files.pythonhosted.org/packages/7d/37/1347461bbea6d0e1f0580b94ef603b18e72c2be5f667fa1653867361a00b/kiwisolver-1.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:b5664603a253efd3a75716d793d1d3a6a82723b61dc6db767b2460bbbeec4c0f", size = 2062857, upload-time = "2026-08-28T10:28:00.407Z" }, + { url = "https://files.pythonhosted.org/packages/1f/87/c7f0976c9cd0d127643351bc0c9929e0b8899d7f49d4ec238cd909e39c42/kiwisolver-1.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a7b85b2cc6ea45e5f7e8c9a30bc9fabd47cda09106cbb4b967335c3e6c43b69d", size = 2596022, upload-time = "2026-08-28T10:28:02.183Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9a/59a6f6ae6f938c15076be2c21b6cedea973d71bb1349ec84fa485fab82cf/kiwisolver-1.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ab620eb663952455271ac37f9aaad86b73c969c02f11f53cea405b38e96a4300", size = 2395634, upload-time = "2026-08-28T10:28:03.977Z" }, + { url = "https://files.pythonhosted.org/packages/e9/2d/8982c1fb7926da5bb7ed60318c3665b5c3f941447271ac982960a11b8637/kiwisolver-1.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:cb6fae641357ed2f6e533c0d3c6504a4a5703621a50c89459e46051d56b61140", size = 75379, upload-time = "2026-08-28T10:28:06.307Z" }, + { url = "https://files.pythonhosted.org/packages/07/78/ba7b6dfa1708b82b373ac056928a30c545d5c1a627df9839dcec3c6c1881/kiwisolver-1.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:b390aec180a7c054919c04898835e1c77bced23ea8383eb2c570213bf25d1a86", size = 73011, upload-time = "2026-08-28T10:28:07.578Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c4/1407df7512a5b36cc79840e01710dc575733c461b13ab866cae77eaf87f3/kiwisolver-1.5.1-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:482676e5bd48d70ac99d9fc78863469845421e01184fa83f1f9366dc49f7e974", size = 134002, upload-time = "2026-08-28T10:28:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/16/45/c37a21ad5c0ab581a93c55ad544721aaa1f0ae94edb29c6a678a23d013e6/kiwisolver-1.5.1-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:072bdb15a3c19a5b5dbc8f8fb1f4e1884bf4f3507eeb4cc6334401274d37a5c0", size = 194292, upload-time = "2026-08-28T10:28:11.06Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c1/69f00d627949580e43d57af0aa465df46868d7c29801c137a55374101294/kiwisolver-1.5.1-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:a5a00665d1a0e26763a7338d7e911d4598fbc1d50dd0d6b7919b7dc6c5d6569f", size = 73362, upload-time = "2026-08-28T10:28:12.449Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1d/59ba570b1774e95e97fde3a0981b2e22118a7a495f73bf74cedc538566a0/kiwisolver-1.5.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:416ba7ff9f233b7036689bb5a3783537e838ad483f63558d2a800f75afe738b1", size = 59450, upload-time = "2026-08-28T10:28:20.383Z" }, + { url = "https://files.pythonhosted.org/packages/22/98/a6849f04dc18b5400e8b98affa2cd8fd86ed583085f036e57b32e571f4fa/kiwisolver-1.5.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:8af9b142ad719ae3a911ebf616bc4b78b32bbab84d6a40d3ad2f129670509957", size = 57400, upload-time = "2026-08-28T10:28:21.632Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ce/a7dc71353dd06a4cbe02222773f52d4a28c81e5a452a75797f8ed113dc99/kiwisolver-1.5.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5daa1f19e097050b9c4d9a78fcc9263cb96c9dfae08037ddc1b7c4ad1889f2a2", size = 79891, upload-time = "2026-08-28T10:28:22.936Z" }, + { url = "https://files.pythonhosted.org/packages/10/b1/d61c61a84ff85d1a36a99df2c152b59ffedb1d356c598902aba44abcdb60/kiwisolver-1.5.1-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cdaeeb6c350106df6bf9d873395973e5f066a9713200b72cd64f55d0a3eafab6", size = 77605, upload-time = "2026-08-28T10:28:24.322Z" }, + { url = "https://files.pythonhosted.org/packages/d3/52/5aef56f21a460a6e43ab3cdfc7697d59d7b87deb0ec97a0f7b91aa4a521b/kiwisolver-1.5.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:17851e5dad4484be0cbccbde3b15331deae036de9aebd45eed964487802b172f", size = 98465, upload-time = "2026-08-28T10:28:25.696Z" }, +] + [[package]] name = "lark" version = "1.3.1" @@ -1751,6 +2138,79 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "matplotlib" +version = "3.11.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "contourpy", version = "1.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/c8/9aa712a0afb882649424dd8de8ad9aa6235e796e84c6052e8f6dc1598d0d/matplotlib-3.11.2.tar.gz", hash = "sha256:cec596316640f2b394b8f0daa0ea61a8eae82d017b620b9f202befb972a59ea4", size = 32660610, upload-time = "2026-09-11T19:05:31.214Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/a9/a7fc5a63c427aeb9a4b99d6a0449f891be0952d3aee2b85229099f7b9f54/matplotlib-3.11.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:5e1e923a3fc3326b99ec0a6ff1ab1338ac6c6cc62ad9d8a9c944197c7f8c6221", size = 9461046, upload-time = "2026-09-11T19:02:59.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d7/59d8ee63e4dee845d953515c50d67e6b8cefa953b453c6b803f197ae556e/matplotlib-3.11.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c27e577ece613ea12a0790e00b4eb80d901c59a3e16cad474f31d8b1529690b9", size = 9290872, upload-time = "2026-09-11T19:03:02.295Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a4/1ca765c63a60e3096ae2ad132eb25f6f7eab0377418cd0472808befd9665/matplotlib-3.11.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:07d9b9fa60cd4c393692f50d0bb03123242ddf61c99bb0e95e75feb354e7c1a8", size = 9854405, upload-time = "2026-09-11T19:03:04.796Z" }, + { url = "https://files.pythonhosted.org/packages/d7/3d/f28ca971a4ad0ed83ebdd60230b09d99de8b91aba47964fac3a81eb2e2ba/matplotlib-3.11.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30ec15d7eefee71de16b7c689b42ba49715a4644650b76a6c7c70d79daf24e91", size = 10660507, upload-time = "2026-09-11T19:03:07.402Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0c/a743556bc64a9b57d2246b14d91b713078adfd2df4199f542df1861737a9/matplotlib-3.11.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:116cdb0eb0eb5644fc98eb2975d4b4dd4ad35e5c8e6b851c22e6976f371f7ab5", size = 10801141, upload-time = "2026-09-11T19:03:11.357Z" }, + { url = "https://files.pythonhosted.org/packages/66/54/91fe3fdfa29100b4668cbf9721577a10acde6e009694618230e4e7a97527/matplotlib-3.11.2-cp311-cp311-win_amd64.whl", hash = "sha256:a24d5fd36e4f0e742c3851dcd20810e56a95633a342e4bf6cb591c678e8fe61f", size = 9329982, upload-time = "2026-09-11T19:03:14.25Z" }, + { url = "https://files.pythonhosted.org/packages/49/c1/13b70871c98d7e93f0d2ed71f226b8d6f873f1372124046068a6b4902fb5/matplotlib-3.11.2-cp311-cp311-win_arm64.whl", hash = "sha256:57b9ea60a835937c2012861923cbb91f47db8565775d326d1c42fc926aa10351", size = 9023542, upload-time = "2026-09-11T19:03:16.878Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ce/1bfcc4873b121597791ad74032943b123218c0613af0b97e8dd05e916fb1/matplotlib-3.11.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ef752769cd962f39ea0b6ffc82d1ea43a0012c5a6157c7a075212fa509cfcff2", size = 9476466, upload-time = "2026-09-11T19:03:19.45Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c4/7f5f3601ee69baf072c0c7d3ce60c03e0618621c5a56c34a62a460e29d11/matplotlib-3.11.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ef31985c4dedb5f1424e1aec6849a47dd37689cb7fa3c20b1b82187f26806261", size = 9305583, upload-time = "2026-09-11T19:03:22.39Z" }, + { url = "https://files.pythonhosted.org/packages/b8/90/2b3fd67ee273163faeda6d514be70b5596eaa0fd77b60ffc294ad0b34f5f/matplotlib-3.11.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df4f7784aca81a94f254c0a2767d592ee25f407e488f5fa7203e51093fb6ca27", size = 9860353, upload-time = "2026-09-11T19:03:25.049Z" }, + { url = "https://files.pythonhosted.org/packages/f4/84/32549e7a462dc311aed2ab62e5d2538028840b5c53a8be0195c789937c3d/matplotlib-3.11.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b9a7ad579856284135e401ecc918c5f8a017ee30539298862a109f51b971710", size = 10670348, upload-time = "2026-09-11T19:03:28.108Z" }, + { url = "https://files.pythonhosted.org/packages/84/39/02e21b74f7439bd643d717ea846006d46e309e6d29b632b57751265311d6/matplotlib-3.11.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3aa4b8516fd26659e4363abbf317c703d9116496c5db2e9d0609a2866dd39dd2", size = 10810580, upload-time = "2026-09-11T19:03:31.402Z" }, + { url = "https://files.pythonhosted.org/packages/04/93/0d239bde12b308262265c2d98909b2f0ecad2b5deeae96241642e822e9c7/matplotlib-3.11.2-cp312-cp312-win_amd64.whl", hash = "sha256:c5c1c68ee401fc98271263410f0e5ce88285abacf7627132914e8adf3d70ff43", size = 9349409, upload-time = "2026-09-11T19:03:34.195Z" }, + { url = "https://files.pythonhosted.org/packages/49/a8/06baf901c02246c8a222b655cc4540ef9c15e1549a04e07927f9cde716c3/matplotlib-3.11.2-cp312-cp312-win_arm64.whl", hash = "sha256:643ff850d8e0f5b8319337f87ed3cb59506afb3df3cc48de777d85871233be7b", size = 9028084, upload-time = "2026-09-11T19:03:37.032Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ce/fc58855a3d17f6cb8a87b6f9ccd100f65432a3a7ffed57309cbf14c78987/matplotlib-3.11.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d43ff8cebb50840648cb6429b2228621dbd709010f3117b3740abb20abf21c0", size = 9476987, upload-time = "2026-09-11T19:03:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/a9/29/2a47b700545aca050e0ed73c89ba21e08407e11f5f42a567421b220de6c1/matplotlib-3.11.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2a8285cea8ef4d92aa041d1c33788bcae82248503300f93f1ca2136b9049452f", size = 9306050, upload-time = "2026-09-11T19:03:43.103Z" }, + { url = "https://files.pythonhosted.org/packages/a9/df/e7c10292f348cf55178a5b3c0092a38ea68891a3945f199ae8b7b8ac9dc1/matplotlib-3.11.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1944895967f87c84c9b4bad29a707b31f5b36df4a3a2339ea1f8ea3ce5105539", size = 9861060, upload-time = "2026-09-11T19:03:46.044Z" }, + { url = "https://files.pythonhosted.org/packages/e4/b9/b978de6d43d47f1d4d8e653fac32c16d8d2e5f46bbbb822e26d7cb1adece/matplotlib-3.11.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af2661f6ac6bbd1d081996f54fdd9385715c625ace8cec0869055c0cfbf38981", size = 10670885, upload-time = "2026-09-11T19:03:48.942Z" }, + { url = "https://files.pythonhosted.org/packages/a3/80/d72f61bb2631ac15d17fada1f17e0f99fecba69d8376d34f17cf93b7eddd/matplotlib-3.11.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7e5a90f8a707ebb6004a713b1cff091a40cc5df4c7c1cb165e5a505ebc11c292", size = 10811222, upload-time = "2026-09-11T19:03:51.665Z" }, + { url = "https://files.pythonhosted.org/packages/94/2e/6d1d33f242c705b5c6c6fb3f0fc141add51cb1abb62912e59de0835d28c4/matplotlib-3.11.2-cp313-cp313-win_amd64.whl", hash = "sha256:bc067c462a86f0e57bf52fc6e058d90171a5420007dac00c46e90153050c69a7", size = 9349539, upload-time = "2026-09-11T19:03:54.534Z" }, + { url = "https://files.pythonhosted.org/packages/bb/bb/c0aa86a7f4c0e62f1c3c0711e361f6f23f87eb9018732a5e11994bd1b7ec/matplotlib-3.11.2-cp313-cp313-win_arm64.whl", hash = "sha256:3e8576f7c47e02fd4f21f44171302d2d1d58d4471d46da3d71fe8899d19539d9", size = 9028295, upload-time = "2026-09-11T19:03:57.261Z" }, + { url = "https://files.pythonhosted.org/packages/54/b4/4facdb700bc236e4c676495ca3798d8134ab8668cbfe0f6b2e9ebf962a38/matplotlib-3.11.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c9721f81275499da1feeb36a2cf8192ea086283b3bd16b7dc4c9d7aedb7396d6", size = 9478976, upload-time = "2026-09-11T19:04:00.12Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a0/bba05f0a25bfeb0475c2f2a87958b31103aa6b7a5386017afb1a96212825/matplotlib-3.11.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a3040b209f3968b4e84161df7b174f07a9fad33b0f2d7e48ea3bbd3075e2863", size = 9307932, upload-time = "2026-09-11T19:04:02.976Z" }, + { url = "https://files.pythonhosted.org/packages/d4/42/81d5cba4bb39b41b0998efc880e129f2d7cdb94256ca49678fdcd9c21be5/matplotlib-3.11.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf3fe71fbfb8ec0e310e0bc8537c3405a01f38f25f9394ed2135e6202fed542b", size = 10674370, upload-time = "2026-09-11T19:04:06.912Z" }, + { url = "https://files.pythonhosted.org/packages/29/5e/52f56f93d5b20815ee0ae352f7a38eff25580edb180b4fa7df87cfeba68f/matplotlib-3.11.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8c8255de28f986d935a64c9ca71c0ec2d2f41d355691f5ea684725dc91413f71", size = 10954597, upload-time = "2026-09-11T19:04:09.948Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ae/f8918a1b564db49c53cd603eb3c8748c5767e4ce0fb2cf1d7c38653e4c5d/matplotlib-3.11.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a8756cc73d9af9a7fe0deb54ea2e75ef73b01d9e575877e72acad5458e660943", size = 10812583, upload-time = "2026-09-11T19:04:12.887Z" }, + { url = "https://files.pythonhosted.org/packages/ea/61/4315ef0d63937fe56f451887b4a0be3b8e8b22cf1d92a8d157caac54570d/matplotlib-3.11.2-cp314-cp314-win_amd64.whl", hash = "sha256:ecea603dd2fbf8242fd31a305a8b12a4ece2de28096870c65fdd0d1e35b8d9a6", size = 9505972, upload-time = "2026-09-11T19:04:15.788Z" }, + { url = "https://files.pythonhosted.org/packages/d5/00/02398b0a1a62ef73d9af0a957fe1773363eec9a6ca7e433c72ae2790fbce/matplotlib-3.11.2-cp314-cp314-win_arm64.whl", hash = "sha256:01dc8eaaab5a9fce9ff615eca82345728f289e4715b186ee10c6d85272fc26bb", size = 9184534, upload-time = "2026-09-11T19:04:18.691Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c5/1e6ca10baa7e04f113c37856aadb4adc3dbb1c55fc7da6dc830bba1292d7/matplotlib-3.11.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79a258f58253dfa025af80a9e9bb228d75fced007f0e93ae7423fefbde81a74d", size = 9526415, upload-time = "2026-09-11T19:04:21.39Z" }, + { url = "https://files.pythonhosted.org/packages/e0/93/7561727af07ccc84747953c6a33eee3981cc436d04f465032e707ca0e429/matplotlib-3.11.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c0b83f044ce10a98027b105b3931548719a6e8c7ef986b4362651e0b5367c8dc", size = 9361064, upload-time = "2026-09-11T19:04:24.107Z" }, + { url = "https://files.pythonhosted.org/packages/c1/40/ce270ef2a6794d94a409fe28b8dabedffc07c07f9e5dbb89e0f605bd157d/matplotlib-3.11.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e43b188f0a5b75447bcc197728258166aa64365770ed1caa36595a5e1ca4bbba", size = 10684626, upload-time = "2026-09-11T19:04:27.201Z" }, + { url = "https://files.pythonhosted.org/packages/45/46/396e0307dbf1c1f2d0a3654caa6dbc780dd5379f5f11257369cf8d21d9fe/matplotlib-3.11.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3304eb5a59442a8867f6920d484591c0fa09ffc29e9260be2feec3351e25869", size = 10960602, upload-time = "2026-09-11T19:04:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/1c/97/4defb84695477340aa88b27c9482cc1ca4de11f36f66b6c8c0ddd685def1/matplotlib-3.11.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:02329432ae5c6af87cf208ee575b701d698bdf0b1a3bb28cc6d53c36e967e575", size = 10822603, upload-time = "2026-09-11T19:04:32.964Z" }, + { url = "https://files.pythonhosted.org/packages/7d/e7/eab54551c1ec6b72dcd678a078b6c6da8d4c85ec1ff0016e81fe52866304/matplotlib-3.11.2-cp314-cp314t-win_amd64.whl", hash = "sha256:f2ac30cf5eb5dff1b584627ae0b0e1186551a4f69ae3c75073911da497a29170", size = 9551206, upload-time = "2026-09-11T19:04:35.811Z" }, + { url = "https://files.pythonhosted.org/packages/4e/0e/04698032cb4d8fb1e30d8ffec5ded20f6f582b03c8c58b2385c95245f158/matplotlib-3.11.2-cp314-cp314t-win_arm64.whl", hash = "sha256:cf41ecd1b0c0b6f7177ed965a54c2afbe888715c7cf6054dc12d53bc1494002c", size = 9234977, upload-time = "2026-09-11T19:04:38.972Z" }, + { url = "https://files.pythonhosted.org/packages/44/a9/ca827c184b87364378964e36e7c0ae23651d146fdbc4000635205490cf82/matplotlib-3.11.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:894a9cbbecbe30ae6787d464df2e8fc7a8d475cfc68f87c3029f7c11152899b3", size = 9478997, upload-time = "2026-09-11T19:04:41.914Z" }, + { url = "https://files.pythonhosted.org/packages/48/60/01aaafc9c3159f3fae9a65bbcddaa4a6d4d72b0aeffdd636d7f6d00a5c8d/matplotlib-3.11.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:bbf1062991d826ed27e2144f3afa4461afbb8ff56e8f703043e191a8163b1ee9", size = 9307947, upload-time = "2026-09-11T19:04:44.834Z" }, + { url = "https://files.pythonhosted.org/packages/1e/a0/6aa72c7cd7cf4dfac652744d7a3310007cec3ce8e5ab9be607bec7096471/matplotlib-3.11.2-cp315-cp315-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:399fef672f7046ef7d6a57572b2a6f9845f3f5afcff04e7b2df7a363a9f42190", size = 10674370, upload-time = "2026-09-11T19:04:47.745Z" }, + { url = "https://files.pythonhosted.org/packages/a1/97/d7e90225ac145046787a2ee0fa4cdc7e4d5cb6772ea232130b10f8a4095a/matplotlib-3.11.2-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc82dde2a0d3e3ad472edce04897ad7146b8d8bfd1df8a32992eebb81af18fdc", size = 10954581, upload-time = "2026-09-11T19:04:50.712Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/89cf056a9d2cae01f4b8baf01c7e809d613244fcdfa2a2acf28c1e0d79fa/matplotlib-3.11.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:930efb28f59fda124e39265d177bab302625297bb147d2910de725a2fc2aef54", size = 10812605, upload-time = "2026-09-11T19:04:53.552Z" }, + { url = "https://files.pythonhosted.org/packages/9d/4d/c61b9f4c34af802d4d84e58803e009013d626b5f468bafe1416131863b5b/matplotlib-3.11.2-cp315-cp315-win_amd64.whl", hash = "sha256:3da3bc0cbf7245e7db72cc6d29d12c5abef72cb73059c73b945f14e3545f3eb2", size = 9506068, upload-time = "2026-09-11T19:04:56.519Z" }, + { url = "https://files.pythonhosted.org/packages/03/7b/047f2fa8708d47430d8e7fa45e9fffb0aee5d4e300f978e087be2ee11fbc/matplotlib-3.11.2-cp315-cp315-win_arm64.whl", hash = "sha256:f25446b2981717dca9786bac841cb3fd7efb568e3e3c755dd980481c5cb9228d", size = 9184576, upload-time = "2026-09-11T19:04:59.318Z" }, + { url = "https://files.pythonhosted.org/packages/03/6e/ad9c5989950785a98642667df265a47c6637e924cb69b5dcf68c3299ae7c/matplotlib-3.11.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:fea03cf56568cc1cba08b470be6a0559e71c3a5b688d54b7179bb35ba23d0821", size = 9525988, upload-time = "2026-09-11T19:05:02.304Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c3/25c61701fb655dd412428b15eee2591ce554cdd593e39a045a153373f428/matplotlib-3.11.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:254d4ddb2fa8df3b4c689c0c306063aee10521df82cfb438185e499c75fe37c1", size = 9360774, upload-time = "2026-09-11T19:05:05.092Z" }, + { url = "https://files.pythonhosted.org/packages/58/5a/4552878fce8f9d249a5e264c16f5c267c27eac08fb039fa3e207a06feff5/matplotlib-3.11.2-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e60cf3047a51edecdc4535a9196bbd6a732936b8e9f8184aabf4d16165884aaf", size = 10685892, upload-time = "2026-09-11T19:05:07.686Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/b3b19ec0124fc4aafd8a4b3bae859c562a7ff0fd8ae3b2099947f78bde19/matplotlib-3.11.2-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eb3712dc9b464793de0a4e42a7313d50293c751f94bddaf7332a1bc71bccdda9", size = 10964946, upload-time = "2026-09-11T19:05:10.943Z" }, + { url = "https://files.pythonhosted.org/packages/7b/30/687ccce22f66a57fed85942450e88062773bcbf1a42914e70c7148ec71dc/matplotlib-3.11.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:75b6d88402770e181b5d06a67dda4129c31da7d05004d63a21c310ec78d1b83c", size = 10824960, upload-time = "2026-09-11T19:05:13.9Z" }, + { url = "https://files.pythonhosted.org/packages/d3/34/117fe35a8d05054b4b34d2d29287099927c24c3841d36e06d9fa2f20c1cf/matplotlib-3.11.2-cp315-cp315t-win_amd64.whl", hash = "sha256:a6939df7567114b6bac7f4c5e06c84a67f197c1b2f2e4b234d4eecb3bfec9482", size = 9551204, upload-time = "2026-09-11T19:05:16.48Z" }, + { url = "https://files.pythonhosted.org/packages/63/5a/9f440e7bec8b80d0af2a65d1165b2f4e718982dad785531f62255d9ff19b/matplotlib-3.11.2-cp315-cp315t-win_arm64.whl", hash = "sha256:d480038c83691532ed52ff3147db51fa902fc78cb2d8349993a1cdb684435bff", size = 9233946, upload-time = "2026-09-11T19:05:19.063Z" }, + { url = "https://files.pythonhosted.org/packages/0f/d9/a1218a31fa2d8082eb893548990ad09a2005ebda1b2b89dad88a8308e51a/matplotlib-3.11.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:854df8d7dfe9fdffcbaa6f39e44a6c24b409cb4d7561fbc09213b157d833f6a6", size = 9457702, upload-time = "2026-09-11T19:05:22.086Z" }, + { url = "https://files.pythonhosted.org/packages/68/d7/a9fde05d87c3c853f233ec20c5b5c3a1c86035e1ed4bfa397cd54d46ec90/matplotlib-3.11.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:eac4b07d4e3743b172451e122ea964f72f152879d4f9adcf3f3d33e518f12ead", size = 9287753, upload-time = "2026-09-11T19:05:24.677Z" }, + { url = "https://files.pythonhosted.org/packages/ec/79/ea2ae4eab84f011c1dc8145864f78e5a63adda8efb95557ef36cc38e6da2/matplotlib-3.11.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ef7a53b66780e5d942923724f08577fbc5be1f7322da0f0f3f9dcaa45dd803d", size = 9848259, upload-time = "2026-09-11T19:05:27.455Z" }, +] + [[package]] name = "matplotlib-inline" version = "0.2.2" @@ -2868,6 +3328,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, ] +[[package]] +name = "pyparsing" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/11/b213bebff182584360cb8d17c72c1677fec5c5c228de439e63bcf8ab1c8f/pyparsing-3.3.3.tar.gz", hash = "sha256:928ae7e20211f3b6f3915a72f06a0cfd29ab9d24279dd6346b6b1a7146397d36", size = 1050487, upload-time = "2026-09-20T20:59:05.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl", hash = "sha256:ece8c00a69cf01b45d0b1dedabb469c90d8caf996d4fda40f147627a122849a4", size = 126420, upload-time = "2026-09-20T20:59:04.025Z" }, +] + [[package]] name = "pyreadline3" version = "3.5.6" From ce55cbac6b6d1f9660706e49654b9a829811969a Mon Sep 17 00:00:00 2001 From: Josh Taillon Date: Fri, 9 Oct 2026 07:38:53 -0400 Subject: [PATCH 02/10] fix(py): bound a reply's plot bytes and isolate a pyplot whose close 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. --- pkg-py/src/commons/_execution/_protocol.py | 18 +++++++++ .../src/commons/_execution/_runtime/_plots.py | 23 ++++++++++-- pkg-py/tests/test_execution_plots.py | 37 +++++++++++++++++++ pkg-py/tests/test_execution_protocol.py | 10 +++++ 4 files changed, 84 insertions(+), 4 deletions(-) diff --git a/pkg-py/src/commons/_execution/_protocol.py b/pkg-py/src/commons/_execution/_protocol.py index d22fb54a..9b626282 100644 --- a/pkg-py/src/commons/_execution/_protocol.py +++ b/pkg-py/src/commons/_execution/_protocol.py @@ -37,6 +37,7 @@ __all__ = [ "FRAME_BYTES_LIMIT", + "PLOT_BYTES_LIMIT", "PLOT_LIMIT", "STREAM_LIMIT", "Call", @@ -96,6 +97,10 @@ # starts at the same count. PLOT_LIMIT = 20 +# Most PNG bytes one reply's plots may total, before base64. A quarter of the +# line leaves the rest for the value and the printed output. +PLOT_BYTES_LIMIT = STREAM_LIMIT // 4 + _PLOT_DROP_NOTE = "\n[commons: the plots were dropped; they exceeded the channel limit]" _PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" @@ -855,6 +860,10 @@ def encode_message(message: Message) -> bytes: megabytes of code, say — because a line no reader can consume would wedge the channel. """ + if _plot_bytes(message) > PLOT_BYTES_LIMIT: + # Dropped before encoding, so oversized plots are never copied into + # base64 only to be thrown away. + message = _drop_plots(message) line = _encode_line(message) if len(line) <= STREAM_LIMIT: return line @@ -947,6 +956,15 @@ def _shrink_text(message: Message) -> Message: return message +def _plot_bytes(message: Message) -> int: + """The PNG bytes ``message``'s plots total, before base64.""" + match message: + case Result() | Error(): + return sum(len(p.png) + len(p.display_png) for p in message.plots) + case _: + return 0 + + def _drop_plots(message: Message) -> Message: """The same message without its plots, saying so where the model reads it.""" match message: diff --git a/pkg-py/src/commons/_execution/_runtime/_plots.py b/pkg-py/src/commons/_execution/_runtime/_plots.py index 674e8625..fb800770 100644 --- a/pkg-py/src/commons/_execution/_runtime/_plots.py +++ b/pkg-py/src/commons/_execution/_runtime/_plots.py @@ -13,7 +13,6 @@ from __future__ import annotations -import contextlib import io import sys from typing import Any @@ -61,13 +60,25 @@ def collect() -> tuple[tuple[_protocol.Plot, ...], str]: f"first {_protocol.PLOT_LIMIT} came back]" ) plots = [] + size = 0 for index, figure in enumerate(figures[: _protocol.PLOT_LIMIT], start=1): try: - plots.append(_render(figure)) + plot = _render(figure) except KeyboardInterrupt: raise except BaseException as exc: # noqa: BLE001 - one figure, not the call notes.append(f"[commons: figure {index} could not be rendered: {exc!r}]") + continue + size += len(plot.png) + len(plot.display_png) + if size > _protocol.PLOT_BYTES_LIMIT: + # Checked as each figure renders, so at most one figure past the + # budget is ever held in memory. + notes.append( + f"[commons: figures {index} onward were dropped; their images " + "exceeded the channel's room for plots]" + ) + break + plots.append(plot) discard() return tuple(plots), "".join(note + "\n" for note in notes) @@ -77,9 +88,13 @@ def discard() -> None: pyplot = sys.modules.get("matplotlib.pyplot") if pyplot is None: return - # Model code can break pyplot; a figure left open costs nothing more. - with contextlib.suppress(Exception): + try: pyplot.close("all") + except KeyboardInterrupt: + raise + except BaseException: # noqa: BLE001, S110 - model code can break pyplot + # A figure left open costs nothing more than the figure. + pass def _render(figure: Any) -> _protocol.Plot: diff --git a/pkg-py/tests/test_execution_plots.py b/pkg-py/tests/test_execution_plots.py index 98f6c8a9..4ca4b2ff 100644 --- a/pkg-py/tests/test_execution_plots.py +++ b/pkg-py/tests/test_execution_plots.py @@ -193,3 +193,40 @@ async def test_a_broken_pyplot_costs_the_plots_and_not_the_session(): follow_up = await worker.run("x + 1") assert isinstance(follow_up, Result) assert follow_up.value == 42 + + +async def test_figures_too_large_for_the_channel_are_dropped_with_a_note(): + # Noise does not compress, so each display image is tens of megabytes. + code = """ +import numpy as np +import matplotlib.pyplot as plt +for _ in range(2): + plt.figure(figsize=(16, 16), dpi=98) + plt.imshow(np.random.default_rng(0).random((1600, 1600, 3))) + plt.axis("off") +x = 42 +""" + async with make_worker() as worker: + reply = await worker.run(code) + assert isinstance(reply, Result), reply + assert reply.plots == () + assert "figures 1 onward were dropped" in reply.stderr + follow_up = await worker.run("x") + assert isinstance(follow_up, Result) + assert follow_up.value == 42 + + +async def test_a_pyplot_whose_close_raises_systemexit_keeps_the_session(): + code = """ +import matplotlib.pyplot as plt +import sys +x = 41 +plt.figure() +plt.close = lambda *args: sys.exit(1) +""" + async with make_worker() as worker: + reply = await worker.run(code) + assert isinstance(reply, Result), reply + follow_up = await worker.run("x + 1") + assert isinstance(follow_up, Result) + assert follow_up.value == 42 diff --git a/pkg-py/tests/test_execution_protocol.py b/pkg-py/tests/test_execution_protocol.py index e94f6c36..87dee232 100644 --- a/pkg-py/tests/test_execution_protocol.py +++ b/pkg-py/tests/test_execution_protocol.py @@ -19,6 +19,7 @@ from commons._execution import _protocol from commons._execution._env import worker_command, worker_env from commons._execution._protocol import ( + PLOT_BYTES_LIMIT, PLOT_LIMIT, STREAM_LIMIT, Call, @@ -1411,3 +1412,12 @@ def test_plots_too_large_for_the_channel_are_dropped_with_a_note(): assert crossed.value == 42 assert crossed.plots == () assert "plot" in crossed.stderr + + +def test_plots_past_their_byte_budget_are_dropped_before_encoding(): + big = _png(2, 2) + b"\x00" * (PLOT_BYTES_LIMIT // 2) + result = Result(id="c1", value=42, plots=(Plot(png=big, display_png=big),)) + crossed = decode_message(encode_message(result)) + assert isinstance(crossed, Result) + assert crossed.plots == () + assert "plots were dropped" in crossed.stderr From 32ce85748fc7d71e27e97dd5bf5f29a1ac37345f Mon Sep 17 00:00:00 2001 From: Josh Taillon Date: Fri, 9 Oct 2026 07:40:34 -0400 Subject: [PATCH 03/10] fix(py): keep the dropped-plots note when the output is also clipped The early plot drop now clips the printed output before appending its note, so the clip that follows cannot cut the note off. --- pkg-py/src/commons/_execution/_protocol.py | 5 +++-- pkg-py/tests/test_execution_protocol.py | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/pkg-py/src/commons/_execution/_protocol.py b/pkg-py/src/commons/_execution/_protocol.py index 9b626282..83ffc4a7 100644 --- a/pkg-py/src/commons/_execution/_protocol.py +++ b/pkg-py/src/commons/_execution/_protocol.py @@ -862,8 +862,9 @@ def encode_message(message: Message) -> bytes: """ if _plot_bytes(message) > PLOT_BYTES_LIMIT: # Dropped before encoding, so oversized plots are never copied into - # base64 only to be thrown away. - message = _drop_plots(message) + # base64 only to be thrown away. The text is clipped first, so a + # later clip cannot cut off the note saying the plots were dropped. + message = _drop_plots(_shrink_text(message)) line = _encode_line(message) if len(line) <= STREAM_LIMIT: return line diff --git a/pkg-py/tests/test_execution_protocol.py b/pkg-py/tests/test_execution_protocol.py index 87dee232..b06f8794 100644 --- a/pkg-py/tests/test_execution_protocol.py +++ b/pkg-py/tests/test_execution_protocol.py @@ -1421,3 +1421,17 @@ def test_plots_past_their_byte_budget_are_dropped_before_encoding(): assert isinstance(crossed, Result) assert crossed.plots == () assert "plots were dropped" in crossed.stderr + + +def test_the_note_about_dropped_plots_survives_clipped_output(): + big = _png(2, 2) + b"\x00" * (PLOT_BYTES_LIMIT // 2) + result = Result( + id="c1", + stderr="e" * (STREAM_LIMIT // 2), + stdout="o" * (STREAM_LIMIT // 2), + plots=(Plot(png=big, display_png=big),), + ) + crossed = decode_message(encode_message(result)) + assert isinstance(crossed, Result) + assert crossed.plots == () + assert crossed.stderr.endswith("plots were dropped; they exceeded the channel limit]") From 5cd1514f8e198a8229c4f49a2b1b886d5511b8b1 Mon Sep 17 00:00:00 2001 From: Josh Taillon Date: Fri, 9 Oct 2026 07:41:51 -0400 Subject: [PATCH 04/10] fix(py): clip output only when the reply still overflows after dropping 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. --- pkg-py/src/commons/_execution/_protocol.py | 16 +++++++++++----- pkg-py/tests/test_execution_protocol.py | 9 +++++++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/pkg-py/src/commons/_execution/_protocol.py b/pkg-py/src/commons/_execution/_protocol.py index 83ffc4a7..e6bfc594 100644 --- a/pkg-py/src/commons/_execution/_protocol.py +++ b/pkg-py/src/commons/_execution/_protocol.py @@ -862,9 +862,8 @@ def encode_message(message: Message) -> bytes: """ if _plot_bytes(message) > PLOT_BYTES_LIMIT: # Dropped before encoding, so oversized plots are never copied into - # base64 only to be thrown away. The text is clipped first, so a - # later clip cannot cut off the note saying the plots were dropped. - message = _drop_plots(_shrink_text(message)) + # base64 only to be thrown away. + message = _drop_plots(message) line = _encode_line(message) if len(line) <= STREAM_LIMIT: return line @@ -945,13 +944,13 @@ def _shrink_text(message: Message) -> Message: return replace( message, stdout=_clip(message.stdout), - stderr=_clip(message.stderr), + stderr=_clip_keeping_note(message.stderr), ) case Error(): return replace( message, message=_clip(message.message), - traceback=_clip(message.traceback), + traceback=_clip_keeping_note(message.traceback), ) case _: return message @@ -1007,6 +1006,13 @@ def _as_opaque(value: Any) -> Any: return OpaqueValue(type_name=type(value).__name__, text=_safe_repr(value)) +def _clip_keeping_note(text: str) -> str: + """``text`` clipped, keeping a trailing note that the plots were dropped.""" + if text.endswith(_PLOT_DROP_NOTE): + return _clip(text[: -len(_PLOT_DROP_NOTE)]) + _PLOT_DROP_NOTE + return _clip(text) + + def _clip(text: str) -> str: """``text`` clipped to ``_TEXT_CLIP_LIMIT``, with a truncation note appended.""" if len(text) <= _TEXT_CLIP_LIMIT: diff --git a/pkg-py/tests/test_execution_protocol.py b/pkg-py/tests/test_execution_protocol.py index b06f8794..eb12e527 100644 --- a/pkg-py/tests/test_execution_protocol.py +++ b/pkg-py/tests/test_execution_protocol.py @@ -1435,3 +1435,12 @@ def test_the_note_about_dropped_plots_survives_clipped_output(): assert isinstance(crossed, Result) assert crossed.plots == () assert crossed.stderr.endswith("plots were dropped; they exceeded the channel limit]") + + +def test_dropping_plots_leaves_output_that_fits_unclipped(): + big = _png(2, 2) + b"\x00" * (PLOT_BYTES_LIMIT // 2) + printed = "e" * (2 * 1024 * 1024) + result = Result(id="c1", stderr=printed, plots=(Plot(png=big, display_png=big),)) + crossed = decode_message(encode_message(result)) + assert isinstance(crossed, Result) + assert crossed.stderr.startswith(printed) From ac5a978abe262c216c360d16887efadd45dc24f3 Mon Sep 17 00:00:00 2001 From: Josh Taillon Date: Fri, 9 Oct 2026 07:54:42 -0400 Subject: [PATCH 05/10] fix(py): keep every plot failure inside its figure, and vouch for plot 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. --- pkg-py/src/commons/_execution/_protocol.py | 50 ++++++- .../src/commons/_execution/_runtime/_plots.py | 40 +++++- pkg-py/tests/test_execution_plots.py | 134 ++++++++++++++++-- pkg-py/tests/test_execution_protocol.py | 59 ++++++++ 4 files changed, 259 insertions(+), 24 deletions(-) diff --git a/pkg-py/src/commons/_execution/_protocol.py b/pkg-py/src/commons/_execution/_protocol.py index e6bfc594..e45fa133 100644 --- a/pkg-py/src/commons/_execution/_protocol.py +++ b/pkg-py/src/commons/_execution/_protocol.py @@ -38,6 +38,7 @@ __all__ = [ "FRAME_BYTES_LIMIT", "PLOT_BYTES_LIMIT", + "PLOT_EDGE_LIMIT", "PLOT_LIMIT", "STREAM_LIMIT", "Call", @@ -53,6 +54,7 @@ "decode_value", "encode_message", "encode_value", + "png_size", "read_message", "write_message", ] @@ -101,6 +103,11 @@ # line leaves the rest for the value and the printed output. PLOT_BYTES_LIMIT = STREAM_LIMIT // 4 +# Longest edge either image of a plot may have. The worker's own images stay +# well inside it; the bound is for an image the worker did not make, which +# a provider or a browser would otherwise be asked to decode. +PLOT_EDGE_LIMIT = 4096 + _PLOT_DROP_NOTE = "\n[commons: the plots were dropped; they exceeded the channel limit]" _PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" @@ -1119,14 +1126,46 @@ def _decode_handles(body: dict[str, Any], max_frame_bytes: int) -> dict[str, Any } +def png_size(data: bytes) -> tuple[int, int]: + """The ``(width, height)`` a PNG's header declares, refusing anything else. + + Raises ``ValueError`` unless ``data`` starts with the PNG signature and a + well-formed IHDR chunk whose edges are between 1 and ``PLOT_EDGE_LIMIT``. + The pixel data is not decoded; the bound on the declared size is what + keeps a later decode from allocating more than a plot's worth. + """ + if ( + len(data) < 33 + or not data.startswith(_PNG_SIGNATURE) + or data[8:16] != b"\x00\x00\x00\x0dIHDR" + ): + raise ValueError("not a PNG image") + width = int.from_bytes(data[16:20], "big") + height = int.from_bytes(data[20:24], "big") + if not (1 <= width <= PLOT_EDGE_LIMIT and 1 <= height <= PLOT_EDGE_LIMIT): + raise ValueError( + f"a {width}x{height} image is outside 1..{PLOT_EDGE_LIMIT} pixels a side" + ) + return width, height + + def _decode_plots(body: dict[str, Any], kind: str) -> tuple[Plot, ...]: - """The ``plots`` of a result or error body, each image checked to be a PNG.""" + """The ``plots`` of a result or error body, each image checked to be a PNG. + + The count and byte bounds are the worker's own, enforced again here + because the worker runs model code and cannot be relied on to keep them. + """ raw = body.get("plots", []) if not isinstance(raw, list) or len(raw) > PLOT_LIMIT: raise ProtocolError( f"malformed {kind} message: plots must be a list of at most {PLOT_LIMIT}" ) - return tuple(_decode_plot(item) for item in raw) + plots = tuple(_decode_plot(item) for item in raw) + if sum(len(p.png) + len(p.display_png) for p in plots) > PLOT_BYTES_LIMIT: + raise ProtocolError( + f"malformed {kind} message: plots exceed {PLOT_BYTES_LIMIT:,} bytes" + ) + return plots def _decode_plot(item: Any) -> Plot: @@ -1142,9 +1181,10 @@ def _decode_plot(item: Any) -> Plot: data = base64.b64decode(text, validate=True) except ValueError as error: raise ProtocolError(f"malformed plot: {name} is not base64") from error - # The signature, then the IHDR chunk the size is read from. - if not data.startswith(_PNG_SIGNATURE) or data[12:16] != b"IHDR": - raise ProtocolError(f"malformed plot: {name} is not a PNG image") + try: + png_size(data) + except ValueError as error: + raise ProtocolError(f"malformed plot: {name}: {error}") from error images.append(data) return Plot(png=images[0], display_png=images[1]) diff --git a/pkg-py/src/commons/_execution/_runtime/_plots.py b/pkg-py/src/commons/_execution/_runtime/_plots.py index fb800770..5fbf9403 100644 --- a/pkg-py/src/commons/_execution/_runtime/_plots.py +++ b/pkg-py/src/commons/_execution/_runtime/_plots.py @@ -52,7 +52,7 @@ def collect() -> tuple[tuple[_protocol.Plot, ...], str]: raise except BaseException as exc: # noqa: BLE001 - model code can break pyplot discard() - return (), f"[commons: the figures could not be collected: {exc!r}]\n" + return (), f"[commons: the figures could not be collected: {_describe(exc)}]\n" notes = [] if len(figures) > _protocol.PLOT_LIMIT: notes.append( @@ -67,7 +67,9 @@ def collect() -> tuple[tuple[_protocol.Plot, ...], str]: except KeyboardInterrupt: raise except BaseException as exc: # noqa: BLE001 - one figure, not the call - notes.append(f"[commons: figure {index} could not be rendered: {exc!r}]") + notes.append( + f"[commons: figure {index} could not be rendered: {_describe(exc)}]" + ) continue size += len(plot.png) + len(plot.display_png) if size > _protocol.PLOT_BYTES_LIMIT: @@ -97,16 +99,44 @@ def discard() -> None: pass +def _describe(exc: BaseException) -> str: + """``exc``'s repr, or its type's name when model code made the repr fail.""" + try: + return str(repr(exc)) + except KeyboardInterrupt: + raise + except BaseException: # noqa: BLE001 - the fallback is the type name + try: + return str(type(exc).__name__) + except BaseException: # noqa: BLE001 - the fallback is a fixed text + return "an exception" + + def _render(figure: Any) -> _protocol.Plot: - """``figure`` as a model PNG at its own size, scaled down to fit, and a display PNG.""" + """``figure`` as a model PNG at its own size, scaled down to fit, and a display PNG. + + Both images are checked before they are kept: model code can replace + ``savefig``, and a reply carrying something that is not a plot would + cost the session rather than the figure. + """ width, height = figure.get_size_inches() dpi = min(float(figure.dpi), _MODEL_LONG_EDGE / max(width, height)) - return _protocol.Plot( + plot = _protocol.Plot( png=_png(figure, dpi), display_png=_png(figure, dpi * _DISPLAY_SCALE) ) + _protocol.png_size(plot.png) + _protocol.png_size(plot.display_png) + return plot def _png(figure: Any, dpi: float) -> bytes: + """``figure`` saved as a PNG at ``dpi``, at exactly its own size. + + A tight bounding box or padding from rcParams would let the image grow + past the size ``dpi`` was chosen for, so both are reset for the save. + """ + matplotlib = sys.modules["matplotlib"] buffer = io.BytesIO() - figure.savefig(buffer, format="png", dpi=dpi) + with matplotlib.rc_context({"savefig.bbox": "standard"}): + figure.savefig(buffer, format="png", dpi=dpi) return buffer.getvalue() diff --git a/pkg-py/tests/test_execution_plots.py b/pkg-py/tests/test_execution_plots.py index 4ca4b2ff..38a56a8e 100644 --- a/pkg-py/tests/test_execution_plots.py +++ b/pkg-py/tests/test_execution_plots.py @@ -11,7 +11,7 @@ import pytest -from commons._execution._driver import Worker +from commons._execution._driver import Failure, Worker from commons._execution._protocol import PLOT_LIMIT, Error, Result from commons._execution._sandbox import protection_mode @@ -161,14 +161,41 @@ def draw(self, renderer): async def test_an_interrupted_call_leaves_no_figure_for_the_next_one(): - async with make_worker() as worker: + async with make_worker(call_timeout=3) as worker: # Pay the font cache in a call of its own, so the timeout below # interrupts the loop rather than the import. - await worker.run("import matplotlib.pyplot as plt") - worker._call_timeout = 1 - await worker.run(PLOT + "while True:\n pass\n") - reply = await worker.run("1") + await worker.run("import matplotlib.pyplot as plt\nx = 5") + reply = await worker.run(PLOT + "while True:\n pass\n") + assert isinstance(reply, Failure) + assert "interrupted" in reply.message + reply = await worker.run("x") assert isinstance(reply, Result), reply + assert reply.value == 5 + assert reply.plots == () + + +async def test_a_figure_that_draws_forever_can_be_interrupted(): + # Drawing runs model code, so it has to stay inside the window the + # driver's interrupt can reach. + code = """ +import matplotlib.pyplot as plt +from matplotlib.artist import Artist + +class Endless(Artist): + def draw(self, renderer): + while True: + pass + +plt.figure().add_artist(Endless()) +""" + async with make_worker(call_timeout=3) as worker: + await worker.run("import matplotlib.pyplot as plt\nx = 5") + reply = await worker.run(code) + assert isinstance(reply, Failure) + assert "interrupted" in reply.message + reply = await worker.run("x") + assert isinstance(reply, Result), reply + assert reply.value == 5 assert reply.plots == () @@ -195,22 +222,22 @@ async def test_a_broken_pyplot_costs_the_plots_and_not_the_session(): assert follow_up.value == 42 -async def test_figures_too_large_for_the_channel_are_dropped_with_a_note(): - # Noise does not compress, so each display image is tens of megabytes. +async def test_figures_past_the_byte_budget_are_dropped_with_a_note(): + # Noise does not compress: drawn pixel for pixel at the display size, + # the second figure's display image alone is past the budget. code = """ import numpy as np import matplotlib.pyplot as plt -for _ in range(2): - plt.figure(figsize=(16, 16), dpi=98) - plt.imshow(np.random.default_rng(0).random((1600, 1600, 3))) - plt.axis("off") +plt.figure() +plt.figure(figsize=(16, 16), dpi=98) +plt.figimage(np.random.default_rng(0).random((3136, 3136, 3)), resize=False) x = 42 """ async with make_worker() as worker: reply = await worker.run(code) assert isinstance(reply, Result), reply - assert reply.plots == () - assert "figures 1 onward were dropped" in reply.stderr + assert len(reply.plots) == 1 + assert "figures 2 onward were dropped" in reply.stderr follow_up = await worker.run("x") assert isinstance(follow_up, Result) assert follow_up.value == 42 @@ -230,3 +257,82 @@ async def test_a_pyplot_whose_close_raises_systemexit_keeps_the_session(): follow_up = await worker.run("x + 1") assert isinstance(follow_up, Result) assert follow_up.value == 42 + + +BROKEN_DRAW = """ +import matplotlib.pyplot as plt +from matplotlib.artist import Artist +x = 41 + +class Broken(Artist): + def draw(self, renderer): + {body} + +plt.figure().add_artist(Broken()) +plt.figure() +""" + + +@pytest.mark.parametrize( + "body", + [ + "raise SystemExit(3)", + # An exception whose repr fails cannot be named in the note. + ( + "class E(Exception):\n def __repr__(self): raise RuntimeError\n" + " raise E()" + ), + ], + ids=["systemexit", "unrepresentable"], +) +async def test_a_figure_whose_draw_fails_badly_costs_only_that_figure(body): + async with make_worker() as worker: + reply = await worker.run(BROKEN_DRAW.format(body=body)) + assert isinstance(reply, Result), reply + assert len(reply.plots) == 1 + assert "figure 1 could not be rendered" in reply.stderr + follow_up = await worker.run("x + 1") + assert isinstance(follow_up, Result) + assert follow_up.value == 42 + + +async def test_a_savefig_that_writes_no_png_costs_only_that_figure(): + code = """ +import matplotlib.pyplot as plt +x = 41 +fig = plt.figure() +fig.savefig = lambda target, **kwargs: target.write(b"junk") +plt.figure() +""" + async with make_worker() as worker: + reply = await worker.run(code) + assert isinstance(reply, Result), reply + assert len(reply.plots) == 1 + assert "figure 1 could not be rendered" in reply.stderr + follow_up = await worker.run("x + 1") + assert isinstance(follow_up, Result) + assert follow_up.value == 42 + + +async def test_a_tight_bounding_box_cannot_grow_the_model_image(): + code = """ +import matplotlib.pyplot as plt +plt.rcParams["savefig.bbox"] = "tight" +plt.rcParams["savefig.pad_inches"] = 30 +fig, ax = plt.subplots() +ax.text(40, 0.5, "far", transform=ax.transAxes) +""" + async with make_worker() as worker: + reply = await worker.run(code) + assert isinstance(reply, Result), reply + [plot] = reply.plots + assert (plot.width, plot.height) == (640, 480) + + +async def test_a_call_that_raises_notes_a_dropped_figure_in_its_traceback(): + code = BROKEN_DRAW.format(body="raise RuntimeError('no')") + "1 / 0\n" + async with make_worker() as worker: + reply = await worker.run(code) + assert isinstance(reply, Error), reply + assert len(reply.plots) == 1 + assert "figure 1 could not be rendered" in reply.traceback diff --git a/pkg-py/tests/test_execution_protocol.py b/pkg-py/tests/test_execution_protocol.py index eb12e527..fc6489a4 100644 --- a/pkg-py/tests/test_execution_protocol.py +++ b/pkg-py/tests/test_execution_protocol.py @@ -20,6 +20,7 @@ from commons._execution._env import worker_command, worker_env from commons._execution._protocol import ( PLOT_BYTES_LIMIT, + PLOT_EDGE_LIMIT, PLOT_LIMIT, STREAM_LIMIT, Call, @@ -1444,3 +1445,61 @@ def test_dropping_plots_leaves_output_that_fits_unclipped(): crossed = decode_message(encode_message(result)) assert isinstance(crossed, Result) assert crossed.stderr.startswith(printed) + + + +def test_an_error_whose_plots_are_dropped_says_so_in_its_traceback(): + big = _png(2, 2) + b"\x00" * (PLOT_BYTES_LIMIT // 2) + error = Error( + id="c1", + message="ValueError: x", + traceback="t" * (STREAM_LIMIT // 2), + plots=(Plot(png=big, display_png=big),), + ) + crossed = decode_message(encode_message(error)) + assert isinstance(crossed, Error) + assert crossed.plots == () + assert crossed.traceback.endswith("plots were dropped; they exceeded the channel limit]") + + +def test_plots_within_budget_give_way_to_a_value_that_would_overflow(): + # The answer is worth more than the pictures, even when the pictures + # alone would have fit. + pd = pytest.importorskip("pandas") + plot_bytes = _png(2, 2) + os.urandom(PLOT_BYTES_LIMIT // 2 - 64) + plot = Plot(png=plot_bytes, display_png=plot_bytes) + # About 37 MB: it fits the line alone, and not alongside the plots. + frame = pd.DataFrame({"b": [os.urandom(1024).hex() for _ in range(18_000)]}) + crossed = decode_message(encode_message(Result(id="c1", value=frame, plots=(plot,)))) + assert isinstance(crossed, Result) + assert crossed.plots == () + pd.testing.assert_frame_equal(crossed.value, frame) + + +@pytest.mark.parametrize("kind", ["result", "error"]) +@pytest.mark.parametrize( + "png", + [ + "iVBORw0KGgo=", + base64.b64encode(_png(0, 10)).decode(), + base64.b64encode(_png(PLOT_EDGE_LIMIT + 1, 10)).decode(), + 12, + ], + ids=["signature-only", "zero-width", "too-wide", "not-a-string"], +) +def test_a_plot_image_the_protocol_cannot_vouch_for_is_refused(kind, png): + body = {"type": kind, "id": "c1", "plots": [{"png": png, "display_png": png}]} + if kind == "error": + body["message"] = "x" + with pytest.raises(ProtocolError, match="malformed plot"): + decode_message(json.dumps(body).encode() + b"\n") + + +def test_plots_past_the_byte_budget_are_refused_at_decode(): + # The worker keeps to the budget, but it runs model code. + image = _b64(_png(2, 2) + b"\x00" * (PLOT_BYTES_LIMIT // 2)) + line = json.dumps( + {"type": "result", "id": "c1", "plots": [{"png": image, "display_png": image}]} + ).encode() + b"\n" + with pytest.raises(ProtocolError, match="plots exceed"): + decode_message(line) From 1e98c6c4e5d182ca5cc1db74474a3e472453b50f Mon Sep 17 00:00:00 2001 From: Josh Taillon Date: Fri, 9 Oct 2026 12:10:32 -0400 Subject: [PATCH 06/10] feat(py): return a call's text and plots as one ordered output 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. --- pkg-py/src/commons/_execution/_protocol.py | 194 ++++++++----- .../_execution/_runtime/_figure_backend.py | 16 +- .../src/commons/_execution/_runtime/_plots.py | 125 +++++--- .../src/commons/_execution/_runtime/_repl.py | 175 ++++++++---- .../commons/_execution/_runtime/_worker.py | 71 +++-- pkg-py/tests/test_execution_driver.py | 8 +- pkg-py/tests/test_execution_guardrails.py | 2 +- pkg-py/tests/test_execution_plots.py | 142 ++++++++-- pkg-py/tests/test_execution_protocol.py | 266 +++++++++++------- pkg-py/tests/test_execution_repl.py | 112 ++++++-- 10 files changed, 758 insertions(+), 353 deletions(-) diff --git a/pkg-py/src/commons/_execution/_protocol.py b/pkg-py/src/commons/_execution/_protocol.py index e45fa133..760129cb 100644 --- a/pkg-py/src/commons/_execution/_protocol.py +++ b/pkg-py/src/commons/_execution/_protocol.py @@ -33,10 +33,11 @@ import sys from collections.abc import Iterator from dataclasses import dataclass, field, replace -from typing import Any +from typing import Any, Literal __all__ = [ "FRAME_BYTES_LIMIT", + "OUTPUT_LIMIT", "PLOT_BYTES_LIMIT", "PLOT_EDGE_LIMIT", "PLOT_LIMIT", @@ -50,6 +51,8 @@ "ProtocolError", "Ready", "Result", + "Segment", + "Text", "decode_message", "decode_value", "encode_message", @@ -108,7 +111,12 @@ # a provider or a browser would otherwise be asked to decode. PLOT_EDGE_LIMIT = 4096 -_PLOT_DROP_NOTE = "\n[commons: the plots were dropped; they exceeded the channel limit]" +# Most segments one reply's output may have, plots included. The worker's +# capture starts a text segment only when the stream changes, and refuses new +# ones well before this. +OUTPUT_LIMIT = 10_000 + +_PLOT_DROP_NOTE = "[commons: the plots were dropped; they exceeded the channel limit]\n" _PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" @@ -189,6 +197,19 @@ def height(self) -> int: return int.from_bytes(self.png[20:24], "big") +@dataclass(frozen=True, kw_only=True) +class Text: + """Text the call wrote to one of its streams, as a run of consecutive writes.""" + + stream: Literal["stdout", "stderr"] + text: str + + +# One piece of a call's output. A reply's output lists them in the order they +# happened, so a plot appears between the text written before and after it. +Segment = Text | Plot + + @dataclass(frozen=True, kw_only=True) class Ready: """Sent by the worker to the driver once the sandbox is up and running.""" @@ -205,7 +226,7 @@ class Call: @dataclass(frozen=True, kw_only=True) class Result: - """Sent by the worker to the driver: what the code evaluated to, and what it printed. + """Sent by the worker to the driver: what the code evaluated to, and what it output. ``value`` is ``None`` both when the code ended in a statement and when it ended in an expression that evaluated to ``None``. The two are equivalent @@ -214,22 +235,20 @@ class Result: id: str value: Any = None - stdout: str = "" - stderr: str = "" - plots: tuple[Plot, ...] = () + output: tuple[Segment, ...] = () @dataclass(frozen=True, kw_only=True) class Error: """Sent by the worker to the driver: the code raised, and this is what it said. - ``plots`` are the figures the code drew before it raised. + ``output`` is what the code wrote and drew before it raised. """ id: str message: str traceback: str = "" - plots: tuple[Plot, ...] = () + output: tuple[Segment, ...] = () Message = Ready | Call | Result | Error @@ -860,8 +879,9 @@ def _arrow_refusals(pyarrow: Any) -> tuple[type[BaseException], ...]: def encode_message(message: Message) -> bytes: """Render ``message`` as the single line that carries it. - A message that would exceed ``STREAM_LIMIT`` is shrunk first: printed - output is clipped, then plots are dropped with a note, then values are + Plots past ``PLOT_BYTES_LIMIT`` are dropped with a note before anything + is encoded. A message that would still exceed ``STREAM_LIMIT`` is shrunk: + text is clipped, then plots are dropped with a note, then values are replaced by their reprs. Raises ``ProtocolError`` when nothing is left to shrink — a ``Call`` carrying megabytes of code, say — because a line no reader can consume would @@ -918,9 +938,7 @@ def _message_body(message: Message) -> dict[str, Any]: "type": "result", "id": message.id, "value": encode_value(message.value), - "stdout": message.stdout, - "stderr": message.stderr, - "plots": [_plot_body(plot) for plot in message.plots], + "output": [_segment_body(segment) for segment in message.output], } case Error(): return { @@ -928,7 +946,7 @@ def _message_body(message: Message) -> dict[str, Any]: "id": message.id, "message": message.message, "traceback": message.traceback, - "plots": [_plot_body(plot) for plot in message.plots], + "output": [_segment_body(segment) for segment in message.output], } case _: raise ProtocolError( @@ -936,51 +954,82 @@ def _message_body(message: Message) -> dict[str, Any]: ) -def _plot_body(plot: Plot) -> dict[str, str]: - """The JSON object for ``plot``, its images in base64.""" - return { - "png": base64.b64encode(plot.png).decode("ascii"), - "display_png": base64.b64encode(plot.display_png).decode("ascii"), - } +def _segment_body(segment: Segment) -> dict[str, str]: + """The JSON object for one output segment, a plot's images in base64.""" + match segment: + case Text(): + return {"type": "text", "stream": segment.stream, "text": segment.text} + case Plot(): + return { + "type": "plot", + "png": base64.b64encode(segment.png).decode("ascii"), + "display_png": base64.b64encode(segment.display_png).decode("ascii"), + } def _shrink_text(message: Message) -> Message: - """The same message with its printed output and traceback clipped.""" + """The same message with its output, error message, and traceback clipped.""" match message: case Result(): - return replace( - message, - stdout=_clip(message.stdout), - stderr=_clip_keeping_note(message.stderr), - ) + return replace(message, output=_clip_output(message.output)) case Error(): return replace( message, message=_clip(message.message), - traceback=_clip_keeping_note(message.traceback), + traceback=_clip(message.traceback), + output=_clip_output(message.output), ) case _: return message -def _plot_bytes(message: Message) -> int: - """The PNG bytes ``message``'s plots total, before base64.""" +def _clip_output(output: tuple[Segment, ...]) -> tuple[Segment, ...]: + """``output`` with each stream's text clipped to ``_TEXT_CLIP_LIMIT`` in total. + + The stream's text is cut where it crosses the limit, and its later + segments are dropped. Plots and the note that plots were dropped are + kept where they are. + """ + used = {"stdout": 0, "stderr": 0} + clipped: set[str] = set() + kept: list[Segment] = [] + for segment in output: + if not isinstance(segment, Text) or segment.text == _PLOT_DROP_NOTE: + kept.append(segment) + continue + if segment.stream in clipped: + continue + room = _TEXT_CLIP_LIMIT - used[segment.stream] + if len(segment.text) <= room: + used[segment.stream] += len(segment.text) + kept.append(segment) + continue + kept.append(replace(segment, text=segment.text[:room] + _TRUNCATION_NOTE)) + clipped.add(segment.stream) + return tuple(kept) + + +def _plots_of(message: Message) -> list[Plot]: + """The plots in ``message``'s output, in order.""" match message: case Result() | Error(): - return sum(len(p.png) + len(p.display_png) for p in message.plots) + return [segment for segment in message.output if isinstance(segment, Plot)] case _: - return 0 + return [] + + +def _plot_bytes(message: Message) -> int: + """The PNG bytes ``message``'s plots total, before base64.""" + return sum(len(p.png) + len(p.display_png) for p in _plots_of(message)) def _drop_plots(message: Message) -> Message: - """The same message without its plots, saying so where the model reads it.""" + """The same message without its plots, saying so at the end of its output.""" match message: - case Result() if message.plots: - return replace(message, plots=(), stderr=message.stderr + _PLOT_DROP_NOTE) - case Error() if message.plots: - return replace( - message, plots=(), traceback=message.traceback + _PLOT_DROP_NOTE - ) + case Result() | Error() if _plots_of(message): + output = tuple(s for s in message.output if not isinstance(s, Plot)) + note = Text(stream="stderr", text=_PLOT_DROP_NOTE) + return replace(message, output=(*output, note)) case _: return message @@ -1013,13 +1062,6 @@ def _as_opaque(value: Any) -> Any: return OpaqueValue(type_name=type(value).__name__, text=_safe_repr(value)) -def _clip_keeping_note(text: str) -> str: - """``text`` clipped, keeping a trailing note that the plots were dropped.""" - if text.endswith(_PLOT_DROP_NOTE): - return _clip(text[: -len(_PLOT_DROP_NOTE)]) + _PLOT_DROP_NOTE - return _clip(text) - - def _clip(text: str) -> str: """``text`` clipped to ``_TEXT_CLIP_LIMIT``, with a truncation note appended.""" if len(text) <= _TEXT_CLIP_LIMIT: @@ -1062,25 +1104,21 @@ def decode_message(line: bytes | str, *, max_frame_bytes: int = FRAME_BYTES_LIMI handles=_decode_handles(body, max_frame_bytes), ) case "result": - _refuse_unknown_fields( - body, "result", {"type", "id", "value", "stdout", "stderr", "plots"} - ) + _refuse_unknown_fields(body, "result", {"type", "id", "value", "output"}) return Result( id=_required_text(body, "id", "result"), value=_decode_result_value(body, max_frame_bytes), - stdout=_optional_text(body, "stdout", "result"), - stderr=_optional_text(body, "stderr", "result"), - plots=_decode_plots(body, "result"), + output=_decode_output(body, "result"), ) case "error": _refuse_unknown_fields( - body, "error", {"type", "id", "message", "traceback", "plots"} + body, "error", {"type", "id", "message", "traceback", "output"} ) return Error( id=_required_text(body, "id", "error"), message=_required_text(body, "message", "error"), traceback=_optional_text(body, "traceback", "error"), - plots=_decode_plots(body, "error"), + output=_decode_output(body, "error"), ) case _: raise ProtocolError(f"unknown message type: {kind!r}") @@ -1149,28 +1187,50 @@ def png_size(data: bytes) -> tuple[int, int]: return width, height -def _decode_plots(body: dict[str, Any], kind: str) -> tuple[Plot, ...]: - """The ``plots`` of a result or error body, each image checked to be a PNG. +def _decode_output(body: dict[str, Any], kind: str) -> tuple[Segment, ...]: + """The ``output`` of a result or error body, each segment checked. - The count and byte bounds are the worker's own, enforced again here - because the worker runs model code and cannot be relied on to keep them. + The segment, plot, and plot-byte bounds are the worker's own, enforced + again here because the worker runs model code and cannot be relied on + to keep them. """ - raw = body.get("plots", []) - if not isinstance(raw, list) or len(raw) > PLOT_LIMIT: + raw = body.get("output", []) + if not isinstance(raw, list) or len(raw) > OUTPUT_LIMIT: raise ProtocolError( - f"malformed {kind} message: plots must be a list of at most {PLOT_LIMIT}" + f"malformed {kind} message: output must be a list of at most " + f"{OUTPUT_LIMIT} segments" + ) + output = tuple(_decode_segment(item) for item in raw) + plots = [segment for segment in output if isinstance(segment, Plot)] + if len(plots) > PLOT_LIMIT: + raise ProtocolError( + f"malformed {kind} message: more than {PLOT_LIMIT} plots" ) - plots = tuple(_decode_plot(item) for item in raw) if sum(len(p.png) + len(p.display_png) for p in plots) > PLOT_BYTES_LIMIT: raise ProtocolError( f"malformed {kind} message: plots exceed {PLOT_BYTES_LIMIT:,} bytes" ) - return plots - - -def _decode_plot(item: Any) -> Plot: - """One plot of a reply, refusing anything but two base64 PNGs.""" - if not isinstance(item, dict) or set(item) != {"png", "display_png"}: + return output + + +def _decode_segment(item: Any) -> Segment: + """One segment of a reply's output: a run of text, or a plot.""" + kind = item.get("type") if isinstance(item, dict) else None + if kind == "text": + if set(item) != {"type", "stream", "text"}: + raise ProtocolError("malformed text segment: expected stream and text") + stream, text = item["stream"], item["text"] + if stream not in ("stdout", "stderr") or not isinstance(text, str): + raise ProtocolError("malformed text segment: bad stream or text") + return Text(stream=stream, text=text) + if kind == "plot": + return _decode_plot(item) + raise ProtocolError(f"malformed output segment: unknown type {kind!r}") + + +def _decode_plot(item: dict[str, Any]) -> Plot: + """One plot segment, refusing anything but two base64 PNGs.""" + if set(item) != {"type", "png", "display_png"}: raise ProtocolError("malformed plot: expected exactly png and display_png") images = [] for name in ("png", "display_png"): diff --git a/pkg-py/src/commons/_execution/_runtime/_figure_backend.py b/pkg-py/src/commons/_execution/_runtime/_figure_backend.py index 0fd97c95..4cd00de7 100644 --- a/pkg-py/src/commons/_execution/_runtime/_figure_backend.py +++ b/pkg-py/src/commons/_execution/_runtime/_figure_backend.py @@ -1,18 +1,20 @@ -"""The matplotlib backend the worker selects: Agg rendering, and a quiet ``show()``. +"""The matplotlib backend the worker selects: Agg rendering, and an inline ``show()``. -The worker collects every open figure after each call (see ``_plots``), so -``show()`` has nothing to do. Under plain Agg it would warn that the backend -is non-interactive, and that warning would reach the model on every script -that ends the usual way. matplotlib imports this module by name, through -``MPLBACKEND``, the first time pyplot needs a backend. +``show()`` puts the open figures into the call's output at that point, as +Jupyter's inline backend does (see ``_plots``). Under plain Agg it would +warn that the backend is non-interactive instead. matplotlib imports this +module by name, through ``MPLBACKEND``, the first time pyplot needs a +backend. """ from __future__ import annotations +import _plots # pyrefly: ignore[missing-import] from matplotlib.backends.backend_agg import FigureCanvasAgg FigureCanvas = FigureCanvasAgg def show(*args: object, **kwargs: object) -> None: - """Do nothing: the figures come back with the call's reply.""" + """Put the open figures into the call's output here, and close them.""" + _plots.flush() diff --git a/pkg-py/src/commons/_execution/_runtime/_plots.py b/pkg-py/src/commons/_execution/_runtime/_plots.py index 5fbf9403..da7c2a4d 100644 --- a/pkg-py/src/commons/_execution/_runtime/_plots.py +++ b/pkg-py/src/commons/_execution/_runtime/_plots.py @@ -1,11 +1,13 @@ -"""Plot capture: the figures a call leaves open, rendered as PNGs and closed. +"""Plot capture: the figures a call shows or leaves open, rendered as PNGs and closed. -matplotlib keeps every figure until it is closed, so after each call the -worker renders the figures pyplot is tracking and then closes them all, as -Jupyter's inline backend does. Each figure is rendered twice: once for the -model, at its own size, and once at twice the pixels for display. Figures -built without pyplot (``matplotlib.figure.Figure()``) are not tracked and -do not come back. +matplotlib keeps every figure until it is closed. ``flush`` renders the +figures pyplot is tracking into the call's transcript and closes them, as +Jupyter's inline backend does. ``plt.show()`` flushes at that point in the +output, and the worker flushes once more when the call ends, so a plot sits +between the text written before and after it. Each figure is rendered twice: +once for the model, at its own size, and once at twice the pixels for +display. Figures built without pyplot (``matplotlib.figure.Figure()``) are +not tracked and do not come back. Nothing here imports matplotlib. A call that never imported pyplot has no figures, and the worker does not pay for the import on its behalf. @@ -19,7 +21,7 @@ import _protocol # pyrefly: ignore[missing-import] -__all__ = ["BACKEND", "collect", "discard"] +__all__ = ["BACKEND", "begin", "end", "flush"] # The value for MPLBACKEND, naming the sibling module by its import name. BACKEND = "module://_figure_backend" @@ -32,61 +34,100 @@ _DISPLAY_SCALE = 2 -def collect() -> tuple[tuple[_protocol.Plot, ...], str]: - """Render and close every open figure, and say what did not come back. +class _Call: + """The plot state of the call in flight: where plots go, and what they used.""" - Returns the plots in the order the figures were made, and a note for the - call's stderr naming any figure that failed to render or fell past - ``PLOT_LIMIT``. Model code can run inside ``savefig`` (an artist's - ``draw``) and can replace pyplot's own functions, so no failure here - may escape: a broken figure costs that figure, and a broken pyplot - costs the plots, never the session. - ``KeyboardInterrupt`` propagates; the caller then discards the figures. + def __init__(self, transcript: Any) -> None: + self.transcript = transcript + self.figures = 0 + self.plots = 0 + self.size = 0 + self.full = False + + +_call: _Call | None = None + + +def begin(transcript: Any) -> None: + """Send the next call's plots, and notes about them, to ``transcript``.""" + global _call + _call = _Call(transcript) + + +def end() -> None: + """Close whatever figures remain, so none carries into the next call.""" + global _call + _call = None + _close_all() + + +def flush() -> None: + """Render every open figure into the call's transcript, then close them all. + + Model code can run inside ``savefig`` (an artist's ``draw``) and can + replace pyplot's own functions, so no failure here may escape: a broken + figure costs that figure, and a broken pyplot costs the plots, never the + session. Each failure, and the point where the call ran past + ``PLOT_LIMIT`` or ``PLOT_BYTES_LIMIT``, is noted on stderr where it + happened. ``KeyboardInterrupt`` propagates; the worker then discards the + figures. """ + call = _call pyplot = sys.modules.get("matplotlib.pyplot") - if pyplot is None: - return (), "" + if call is None or pyplot is None: + return try: figures = [pyplot.figure(number) for number in pyplot.get_fignums()] except KeyboardInterrupt: raise except BaseException as exc: # noqa: BLE001 - model code can break pyplot - discard() - return (), f"[commons: the figures could not be collected: {_describe(exc)}]\n" - notes = [] - if len(figures) > _protocol.PLOT_LIMIT: - notes.append( - f"[commons: the call left {len(figures)} figures open; only the " - f"first {_protocol.PLOT_LIMIT} came back]" - ) - plots = [] - size = 0 - for index, figure in enumerate(figures[: _protocol.PLOT_LIMIT], start=1): + _note(call, f"the figures could not be collected: {_describe(exc)}") + _close_all() + return + for figure in figures: + call.figures += 1 + if call.full: + break + if call.plots >= _protocol.PLOT_LIMIT: + _note( + call, + f"figures {call.figures} onward were dropped; a call returns " + f"at most {_protocol.PLOT_LIMIT}", + ) + call.full = True + break try: plot = _render(figure) except KeyboardInterrupt: raise except BaseException as exc: # noqa: BLE001 - one figure, not the call - notes.append( - f"[commons: figure {index} could not be rendered: {_describe(exc)}]" + _note( + call, f"figure {call.figures} could not be rendered: {_describe(exc)}" ) continue - size += len(plot.png) + len(plot.display_png) - if size > _protocol.PLOT_BYTES_LIMIT: + size = len(plot.png) + len(plot.display_png) + if call.size + size > _protocol.PLOT_BYTES_LIMIT: # Checked as each figure renders, so at most one figure past the # budget is ever held in memory. - notes.append( - f"[commons: figures {index} onward were dropped; their images " - "exceeded the channel's room for plots]" + _note( + call, + f"figures {call.figures} onward were dropped; their images " + "exceeded the channel's room for plots", ) + call.full = True break - plots.append(plot) - discard() - return tuple(plots), "".join(note + "\n" for note in notes) + call.size += size + call.plots += 1 + call.transcript.insert(plot) + _close_all() + + +def _note(call: _Call, text: str) -> None: + call.transcript.note("stderr", f"[commons: {text}]\n") -def discard() -> None: - """Close every open figure, so none carries into the next call.""" +def _close_all() -> None: + """Close every open figure.""" pyplot = sys.modules.get("matplotlib.pyplot") if pyplot is None: return diff --git a/pkg-py/src/commons/_execution/_runtime/_repl.py b/pkg-py/src/commons/_execution/_runtime/_repl.py index b7865feb..e2891021 100644 --- a/pkg-py/src/commons/_execution/_runtime/_repl.py +++ b/pkg-py/src/commons/_execution/_runtime/_repl.py @@ -14,6 +14,10 @@ - stdout and stderr are captured for the duration of the call. The worker's protocol with its parent uses the process's stdout, so a ``print()`` from model code would corrupt the channel. + +Both streams are captured into one ``Transcript``, which keeps their writes +in order, so the reply can show a warning between the two prints it came +between. The worker inserts plots into the same transcript. """ from __future__ import annotations @@ -27,7 +31,7 @@ from dataclasses import dataclass from typing import Any -__all__ = ["Evaluation", "run"] +__all__ = ["Evaluation", "Transcript", "run"] # The filename tracebacks show for a frame of model-written code, named for # the tool the model calls. @@ -40,6 +44,13 @@ _CAPTURE_LIMIT = 1024 * 1024 _TRUNCATION_NOTE = "\n[truncated by commons: the output exceeded the channel limit]" +# The most text segments one call's output may have. A segment starts only +# when the output switches streams, so this is reached only by output that +# alternates thousands of times. `commons._execution._protocol.OUTPUT_LIMIT` +# leaves room past it for plots and the worker's notes. +_SEGMENT_LIMIT = 9_000 +_SEGMENT_NOTE = "\n[truncated by commons: the output switched streams too often]" + # The compiler flags a `from __future__ import ...` can set, which persist # across calls as they do in codeop.CommandCompiler. _FEATURES = [getattr(__future__, name) for name in __future__.all_feature_names] @@ -51,30 +62,91 @@ _FLAGS_KEY = "__commons_future_flags__" -class _BoundedCapture: - """An append-only, bounded stand-in for ``sys.stdout`` during a call. +class Transcript: + """A call's output in order: runs of text tagged by stream, and inserted items. - The class wraps a list of chunks and subclasses no stream type, so no - base-class method (``io.StringIO.write(sys.stdout, ...)``) can write past - the bound. It has no ``seek``, as a pipe has none. + Consecutive writes to one stream join into one run. Each stream keeps at + most ``_CAPTURE_LIMIT`` characters, note included, so a runaway print + loop cannot exhaust the worker's memory; writes past it succeed and are + discarded, and the stream's text ends with a truncation note. Memory + that model code allocates directly is bounded by the worker's rlimits. - Writes past the limit succeed and are discarded, and the output ends - with a truncation note. ``close()`` does nothing, because the worker - reads the buffer after the call. ``fileno()`` raises - ``io.UnsupportedOperation``, as ``io.StringIO`` and ipykernel's stream do, - so libraries that probe for a descriptor fall back cleanly. + ``insert`` places an item (a plot) between the runs, and ``note`` adds + the worker's own text past both bounds, so a note is never lost. + """ - Memory that model code allocates directly is bounded by the worker's - rlimits. + def __init__(self) -> None: + # Each entry is [stream, chunks] for a run of text, or ["item", item]. + self._entries: list[list[Any]] = [] + self._sizes = {"stdout": 0, "stderr": 0} + self._truncated: set[str] = set() + self._segments = 0 + self._full = False + + def write(self, stream: str, text: str) -> None: + """Record ``text`` written to ``stream``, within the stream's bound.""" + if not text or stream in self._truncated or self._full: + return + room = _CAPTURE_LIMIT - len(_TRUNCATION_NOTE) - self._sizes[stream] + if room >= len(text): + self._append(stream, text) + self._sizes[stream] += len(text) + return + if room > 0: + self._append(stream, text[:room]) + self._append(stream, _TRUNCATION_NOTE) + self._sizes[stream] = _CAPTURE_LIMIT + self._truncated.add(stream) + + def note(self, stream: str, text: str) -> None: + """Record the worker's own ``text`` on ``stream``, past both bounds.""" + self._append(stream, text, bounded=False) + + def insert(self, item: Any) -> None: + """Place ``item`` after everything recorded so far.""" + self._entries.append(["item", item]) + + def entries(self) -> tuple[tuple[str, Any], ...]: + """The output in order: ``(stream, text)`` runs and ``("item", item)``.""" + return tuple( + (kind, body if kind == "item" else "".join(body)) + for kind, body in self._entries + ) + + def _append(self, stream: str, text: str, *, bounded: bool = True) -> None: + last = self._entries[-1] if self._entries else None + if last is not None and last[0] == stream: + last[1].append(text) + return + if bounded and self._segments >= _SEGMENT_LIMIT: + if not self._full: + self._entries.append([stream, [_SEGMENT_NOTE]]) + self._full = True + return + self._entries.append([stream, [text]]) + self._segments += 1 + + +class _StreamView: + """An append-only stand-in for ``sys.stdout`` or ``sys.stderr`` during a call. + + Writes go to the call's ``Transcript``, tagged with this view's stream. + The class subclasses no stream type, so no base-class method + (``io.StringIO.write(sys.stdout, ...)``) can write around the + transcript's bound. It has no ``seek``, as a pipe has none. + + ``close()`` does nothing, because the worker reads the transcript after + the call. ``fileno()`` raises ``io.UnsupportedOperation``, as + ``io.StringIO`` and ipykernel's stream do, so libraries that probe for a + descriptor fall back cleanly. """ encoding = "utf-8" errors = "strict" - def __init__(self) -> None: - self._chunks: list[str] = [] - self._size = 0 - self._truncated = False + def __init__(self, transcript: Transcript, stream: str) -> None: + self._transcript = transcript + self._stream = stream @property def closed(self) -> bool: @@ -115,49 +187,34 @@ def write(self, text: str) -> int: # true contents into an exact str, where a plain slice would call # the subclass's own __getitem__. text = str.__getitem__(text, slice(None)) - if not text: - # Skipped so that empty writes cannot grow the chunk list. - return 0 - if self._truncated: - return len(text) - room = _CAPTURE_LIMIT - len(_TRUNCATION_NOTE) - self._size - if room >= len(text): - self._chunks.append(text) - self._size += len(text) - return len(text) - if room > 0: - self._chunks.append(text[:room]) - self._chunks.append(_TRUNCATION_NOTE) - self._size = _CAPTURE_LIMIT - self._truncated = True + self._transcript.write(self._stream, text) return len(text) - def getvalue(self) -> str: - """The captured output; the worker's read-back after the call.""" - return "".join(self._chunks) - @dataclass(frozen=True, kw_only=True) class Evaluation: - """The outcome of one call: what it evaluated to, what it printed, and how it failed. + """The outcome of one call: what it evaluated to, what it output, and how it failed. ``value`` is ``None`` both when the code ended in a statement and when it ended in an expression that evaluated to ``None``; the model sees no difference between the two. + ``output`` is the transcript's entries (see ``Transcript.entries``). ``error`` and ``traceback`` are empty when the call succeeded. On a - failure, ``stdout`` and ``stderr`` contain what was printed before the - exception, and the worker loop decides whether to relay them. + failure, ``output`` is what was written before the exception. """ value: Any = None - stdout: str = "" - stderr: str = "" + output: tuple[tuple[str, Any], ...] = () error: str = "" traceback: str = "" -def run(code: str, namespace: dict[str, Any] | None = None) -> Evaluation: +def run( + code: str, + namespace: dict[str, Any] | None = None, + transcript: Transcript | None = None, +) -> Evaluation: """Run ``code`` in ``namespace`` the way a REPL would, and report the outcome. The namespace is the session: the worker passes the same mapping on every @@ -170,14 +227,21 @@ def run(code: str, namespace: dict[str, Any] | None = None) -> Evaluation: driver's interrupt escalation can stop a long computation. The capture replaces ``sys.stdout``, ``sys.stderr``, and their - ``__stdout__``/``__stderr__`` names. Writes to file descriptor 1, or - through a stream reference saved before the call, still reach the real - stream; the worker loop owns that channel and guards it. + ``__stdout__``/``__stderr__`` names with views onto ``transcript``, + which the caller passes when it inserts into the output itself. Writes + to file descriptor 1, or through a stream reference saved before the + call, still reach the real stream; the worker loop owns that channel and + guards it. """ if namespace is None: namespace = {} - stdout = _BoundedCapture() - stderr = _BoundedCapture() + if transcript is None: + transcript = Transcript() + # Bound before model code runs, which can reach the transcript through + # the views and replace its methods. + read_back = transcript.entries + stdout = _StreamView(transcript, "stdout") + stderr = _StreamView(transcript, "stderr") value: Any = None error = "" tb = "" @@ -193,22 +257,15 @@ def run(code: str, namespace: dict[str, Any] | None = None) -> Evaluation: error, tb = _render_error(exc) finally: sys.__stdout__, sys.__stderr__ = real_dunder - # Model code had access to the capture objects, so the read-back may - # fail; that loses the output and keeps the answer. + # Model code had access to the transcript, so the read-back may fail; + # that loses the output and keeps the answer. try: - captured_out = stdout.getvalue() - captured_err = stderr.getvalue() + output = read_back() except KeyboardInterrupt: raise except BaseException: # noqa: BLE001 - lost output is not a lost answer - captured_out, captured_err = "", "" - return Evaluation( - value=value, - stdout=captured_out, - stderr=captured_err, - error=error, - traceback=tb, - ) + output = () + return Evaluation(value=value, output=output, error=error, traceback=tb) def _render_error(exc: BaseException) -> tuple[str, str]: diff --git a/pkg-py/src/commons/_execution/_runtime/_worker.py b/pkg-py/src/commons/_execution/_runtime/_worker.py index 4d1193f9..0c6844ab 100644 --- a/pkg-py/src/commons/_execution/_runtime/_worker.py +++ b/pkg-py/src/commons/_execution/_runtime/_worker.py @@ -290,17 +290,22 @@ def guard(default): def _execute(call: _protocol.Call, namespace: dict) -> _protocol.Message: """Run one call in the session namespace and render its reply. - The call's figures are rendered inside the interruptible window, because - drawing a figure runs model code and can take as long as the call did. + The figures still open when the code finishes are flushed inside the + interruptible window, because drawing a figure runs model code and can + take as long as the call did. """ global _in_call namespace.update(call.handles) + transcript = _repl.Transcript() + # Bound before model code runs, which can reach the transcript and the + # plot module and replace their methods. + read_back, flush = transcript.entries, _plots.flush + _plots.begin(transcript) evaluation = None - plots, plot_notes = (), "" _in_call = True try: - evaluation = _repl.run(call.code, namespace) - plots, plot_notes = _plots.collect() + evaluation = _repl.run(call.code, namespace, transcript) + _guarded(flush) except KeyboardInterrupt: # The driver's SIGINT broke the call out of its computation. The # session and its variables survive; the driver reports the @@ -308,25 +313,57 @@ def _execute(call: _protocol.Call, namespace: dict) -> _protocol.Message: evaluation = None finally: _in_call = False + # Outside the interruptible window, so a second SIGINT cannot escape + # here. Figures still open, half-drawn after an interrupt, go with it. + _guarded(_plots.end) if evaluation is None: - # Outside the interruptible window, so a second SIGINT cannot - # escape here. The half-drawn figures go with the call. - _plots.discard() return _protocol.Error(id=call.id, message="KeyboardInterrupt") + output = _segments(_guarded(read_back) or ()) if evaluation.error: return _protocol.Error( id=call.id, message=evaluation.error, - traceback=evaluation.traceback + plot_notes, - plots=plots, + traceback=evaluation.traceback, + output=output, ) - return _protocol.Result( - id=call.id, - value=evaluation.value, - stdout=evaluation.stdout, - stderr=evaluation.stderr + plot_notes, - plots=plots, - ) + return _protocol.Result(id=call.id, value=evaluation.value, output=output) + + +def _guarded(step): + """``step()``'s result, or ``None`` when model code made it fail.""" + try: + return step() + except KeyboardInterrupt: + raise + except BaseException: # noqa: BLE001 - a broken step costs its output only + return None + + +def _segments(entries) -> tuple: + """The transcript's entries as protocol segments, skipping any that are not.""" + segments = [] + for entry in entries: + try: + kind, body = entry + except (TypeError, ValueError): + continue + if kind in ("stdout", "stderr") and type(body) is str: + segments.append(_protocol.Text(stream=kind, text=body)) + elif kind == "item" and _is_plot(body): + segments.append(body) + return tuple(segments) + + +def _is_plot(item) -> bool: + """Whether ``item`` is a plot whose images the driver will accept.""" + if type(item) is not _protocol.Plot: + return False + try: + _protocol.png_size(item.png) + _protocol.png_size(item.display_png) + except (TypeError, ValueError): + return False + return True def _call_id(line: bytes) -> str | None: diff --git a/pkg-py/tests/test_execution_driver.py b/pkg-py/tests/test_execution_driver.py index 91e6abd7..3e7795cf 100644 --- a/pkg-py/tests/test_execution_driver.py +++ b/pkg-py/tests/test_execution_driver.py @@ -22,7 +22,7 @@ from commons import measure, semantic_layer from commons._execution._backend import WORKER_SCRIPT, LocalBackend, LocalSession from commons._execution._driver import Failure, Worker -from commons._execution._protocol import Error, Result +from commons._execution._protocol import Error, Result, Text from commons._execution._sandbox import protection_mode from commons._handles import HandleStore from commons._measures import Injected @@ -155,7 +155,7 @@ async def test_printed_output_comes_back_with_the_result(): async with make_worker() as worker: reply = await worker.run("print('hello')") assert isinstance(reply, Result) - assert reply.stdout == "hello\n" + assert reply.output == (Text(stream="stdout", text="hello\n"),) async def test_calls_are_serialized_one_at_a_time(): @@ -812,7 +812,7 @@ async def test_a_worker_that_died_between_calls_loses_its_scratch_directory(): # Model code can reach the worker's own module, and with it the protocol # channel; these tests use that to put a bad line on the channel on demand. WORKER_MODULE = "import sys, os; w = sys.modules['__main__']\n" -BOGUS_REPLY = "w._send(w._protocol.Result(id='bogus', value=1, stdout='', stderr=''))\n" +BOGUS_REPLY = "w._send(w._protocol.Result(id='bogus', value=1))\n" async def test_a_reply_that_does_not_parse_restarts_the_worker(): @@ -926,7 +926,7 @@ async def test_a_sync_answered_with_garbage_restarts_the_worker(): async def test_a_sync_answered_out_of_turn_restarts_the_worker(): store = HandleStore() async with make_worker() as worker: - bogus = "return w._protocol.Result(id='bogus', value=1, stdout='', stderr='')" + bogus = "return w._protocol.Result(id='bogus', value=1)" await worker.run(patch_sync(bogus)) store.register(41) reply = await worker.run("r1", handles=store) diff --git a/pkg-py/tests/test_execution_guardrails.py b/pkg-py/tests/test_execution_guardrails.py index 197cd398..d72d182a 100644 --- a/pkg-py/tests/test_execution_guardrails.py +++ b/pkg-py/tests/test_execution_guardrails.py @@ -188,4 +188,4 @@ async def test_a_plot_comes_back_under_guardrails(guarded): "import matplotlib.pyplot as plt\nplt.plot([1, 2, 3])\n" ) assert isinstance(reply, Result), reply - assert len(reply.plots) == 1 + assert len(reply.output) == 1 diff --git a/pkg-py/tests/test_execution_plots.py b/pkg-py/tests/test_execution_plots.py index 38a56a8e..61b995b1 100644 --- a/pkg-py/tests/test_execution_plots.py +++ b/pkg-py/tests/test_execution_plots.py @@ -12,7 +12,7 @@ import pytest from commons._execution._driver import Failure, Worker -from commons._execution._protocol import PLOT_LIMIT, Error, Result +from commons._execution._protocol import PLOT_LIMIT, Error, Plot, Result, Text from commons._execution._sandbox import protection_mode pytest.importorskip("matplotlib") @@ -43,6 +43,21 @@ def make_worker(**kwargs) -> Worker: return Worker(**kwargs) +def plots(reply) -> list[Plot]: + return [segment for segment in reply.output if isinstance(segment, Plot)] + + +def printed(reply, stream: str = "stdout") -> str: + return "".join( + s.text for s in reply.output if isinstance(s, Text) and s.stream == stream + ) + + +def shape(reply) -> list[str]: + """The reply's output as a list of ``"plot"`` and the text of each run.""" + return ["plot" if isinstance(s, Plot) else s.text for s in reply.output] + + PLOT = """ import matplotlib.pyplot as plt plt.plot([1, 2, 3], [1, 4, 9]) @@ -53,7 +68,7 @@ async def test_a_figure_the_call_draws_comes_back_as_a_plot(): async with make_worker() as worker: reply = await worker.run(PLOT) assert isinstance(reply, Result), reply - [plot] = reply.plots + [plot] = plots(reply) assert plot.png.startswith(PNG_SIGNATURE) assert plot.display_png.startswith(PNG_SIGNATURE) # matplotlib's default figure is 6.4 x 4.8 inches at 100 dpi. @@ -64,7 +79,7 @@ async def test_the_display_image_has_twice_the_pixels_of_the_model_image(): async with make_worker() as worker: reply = await worker.run(PLOT) assert isinstance(reply, Result), reply - [plot] = reply.plots + [plot] = plots(reply) display_width = int.from_bytes(plot.display_png[16:20], "big") display_height = int.from_bytes(plot.display_png[20:24], "big") assert (display_width, display_height) == (2 * plot.width, 2 * plot.height) @@ -76,8 +91,8 @@ async def test_showing_a_figure_is_quiet_and_still_returns_it(): async with make_worker() as worker: reply = await worker.run(PLOT + "plt.show()\n") assert isinstance(reply, Result), reply - assert len(reply.plots) == 1 - assert reply.stderr == "" + assert len(plots(reply)) == 1 + assert printed(reply, "stderr") == "" async def test_a_figure_comes_back_once_and_not_with_the_next_call(): @@ -85,7 +100,7 @@ async def test_a_figure_comes_back_once_and_not_with_the_next_call(): await worker.run(PLOT) reply = await worker.run("1 + 1") assert isinstance(reply, Result), reply - assert reply.plots == () + assert plots(reply) == [] async def test_several_figures_come_back_in_the_order_they_were_made(): @@ -97,17 +112,17 @@ async def test_several_figures_come_back_in_the_order_they_were_made(): async with make_worker() as worker: reply = await worker.run(code) assert isinstance(reply, Result), reply - assert [plot.width for plot in reply.plots] == [200, 300] + assert [plot.width for plot in plots(reply)] == [200, 300] async def test_a_call_that_raises_still_returns_what_it_drew(): async with make_worker() as worker: reply = await worker.run(PLOT + "raise ValueError('after the plot')\n") assert isinstance(reply, Error), reply - assert len(reply.plots) == 1 + assert len(plots(reply)) == 1 follow_up = await worker.run("1") assert isinstance(follow_up, Result) - assert follow_up.plots == () + assert plots(follow_up) == [] async def test_figures_past_the_limit_are_dropped_with_a_note(): @@ -119,8 +134,8 @@ async def test_figures_past_the_limit_are_dropped_with_a_note(): async with make_worker() as worker: reply = await worker.run(code) assert isinstance(reply, Result), reply - assert len(reply.plots) == PLOT_LIMIT - assert f"{PLOT_LIMIT + 3} figures" in reply.stderr + assert len(plots(reply)) == PLOT_LIMIT + assert f"figures {PLOT_LIMIT + 1} onward" in printed(reply, "stderr") async def test_a_huge_figure_is_scaled_down_for_the_model(): @@ -133,7 +148,7 @@ async def test_a_huge_figure_is_scaled_down_for_the_model(): async with make_worker() as worker: reply = await worker.run(code) assert isinstance(reply, Result), reply - [plot] = reply.plots + [plot] = plots(reply) assert plot.width == 1568 assert plot.height == 392 @@ -153,11 +168,11 @@ def draw(self, renderer): async with make_worker() as worker: reply = await worker.run(code) assert isinstance(reply, Result), reply - assert len(reply.plots) == 1 - assert "cannot draw this" in reply.stderr + assert len(plots(reply)) == 1 + assert "cannot draw this" in printed(reply, "stderr") follow_up = await worker.run("1") assert isinstance(follow_up, Result) - assert follow_up.plots == () + assert plots(follow_up) == [] async def test_an_interrupted_call_leaves_no_figure_for_the_next_one(): @@ -171,7 +186,7 @@ async def test_an_interrupted_call_leaves_no_figure_for_the_next_one(): reply = await worker.run("x") assert isinstance(reply, Result), reply assert reply.value == 5 - assert reply.plots == () + assert plots(reply) == [] async def test_a_figure_that_draws_forever_can_be_interrupted(): @@ -196,7 +211,7 @@ def draw(self, renderer): reply = await worker.run("x") assert isinstance(reply, Result), reply assert reply.value == 5 - assert reply.plots == () + assert plots(reply) == [] async def test_code_that_never_plots_does_not_load_matplotlib(): @@ -215,8 +230,8 @@ async def test_a_broken_pyplot_costs_the_plots_and_not_the_session(): async with make_worker() as worker: reply = await worker.run(code) assert isinstance(reply, Result), reply - assert reply.plots == () - assert "ZeroDivisionError" in reply.stderr + assert plots(reply) == [] + assert "ZeroDivisionError" in printed(reply, "stderr") follow_up = await worker.run("x + 1") assert isinstance(follow_up, Result) assert follow_up.value == 42 @@ -236,8 +251,8 @@ async def test_figures_past_the_byte_budget_are_dropped_with_a_note(): async with make_worker() as worker: reply = await worker.run(code) assert isinstance(reply, Result), reply - assert len(reply.plots) == 1 - assert "figures 2 onward were dropped" in reply.stderr + assert len(plots(reply)) == 1 + assert "figures 2 onward were dropped" in printed(reply, "stderr") follow_up = await worker.run("x") assert isinstance(follow_up, Result) assert follow_up.value == 42 @@ -289,8 +304,8 @@ async def test_a_figure_whose_draw_fails_badly_costs_only_that_figure(body): async with make_worker() as worker: reply = await worker.run(BROKEN_DRAW.format(body=body)) assert isinstance(reply, Result), reply - assert len(reply.plots) == 1 - assert "figure 1 could not be rendered" in reply.stderr + assert len(plots(reply)) == 1 + assert "figure 1 could not be rendered" in printed(reply, "stderr") follow_up = await worker.run("x + 1") assert isinstance(follow_up, Result) assert follow_up.value == 42 @@ -307,8 +322,8 @@ async def test_a_savefig_that_writes_no_png_costs_only_that_figure(): async with make_worker() as worker: reply = await worker.run(code) assert isinstance(reply, Result), reply - assert len(reply.plots) == 1 - assert "figure 1 could not be rendered" in reply.stderr + assert len(plots(reply)) == 1 + assert "figure 1 could not be rendered" in printed(reply, "stderr") follow_up = await worker.run("x + 1") assert isinstance(follow_up, Result) assert follow_up.value == 42 @@ -325,14 +340,83 @@ async def test_a_tight_bounding_box_cannot_grow_the_model_image(): async with make_worker() as worker: reply = await worker.run(code) assert isinstance(reply, Result), reply - [plot] = reply.plots + [plot] = plots(reply) assert (plot.width, plot.height) == (640, 480) -async def test_a_call_that_raises_notes_a_dropped_figure_in_its_traceback(): +async def test_a_call_that_raises_notes_a_dropped_figure_in_its_output(): code = BROKEN_DRAW.format(body="raise RuntimeError('no')") + "1 / 0\n" async with make_worker() as worker: reply = await worker.run(code) assert isinstance(reply, Error), reply - assert len(reply.plots) == 1 - assert "figure 1 could not be rendered" in reply.traceback + assert len(plots(reply)) == 1 + assert "figure 1 could not be rendered" in printed(reply, "stderr") + + +async def test_show_puts_the_figure_between_the_text_around_it(): + code = """ +import matplotlib.pyplot as plt +print("before") +plt.plot([1, 2]) +plt.show() +print("after") +""" + async with make_worker() as worker: + reply = await worker.run(code) + assert isinstance(reply, Result), reply + assert shape(reply) == ["before\n", "plot", "after\n"] + + +async def test_a_figure_never_shown_comes_after_all_the_text(): + code = """ +import matplotlib.pyplot as plt +print("before") +plt.plot([1, 2]) +print("after") +""" + async with make_worker() as worker: + reply = await worker.run(code) + assert isinstance(reply, Result), reply + assert shape(reply) == ["before\nafter\n", "plot"] + + +async def test_each_show_places_the_figures_drawn_since_the_last(): + code = """ +import matplotlib.pyplot as plt +plt.figure(figsize=(2, 1)) +plt.show() +print("between") +plt.figure(figsize=(3, 1)) +plt.show() +""" + async with make_worker() as worker: + reply = await worker.run(code) + assert isinstance(reply, Result), reply + assert shape(reply) == ["plot", "between\n", "plot"] + assert [plot.width for plot in plots(reply)] == [200, 300] + + +async def test_a_note_about_a_figure_sits_where_the_figure_would_have(): + code = BROKEN_DRAW.format(body="raise RuntimeError('no')") + ( + "plt.show()\nprint('after')\n" + ) + async with make_worker() as worker: + reply = await worker.run(code) + assert isinstance(reply, Result), reply + note, plot, after = shape(reply) + assert "figure 1 could not be rendered" in note + assert (plot, after) == ("plot", "after\n") + + +async def test_figures_shown_before_an_error_keep_their_place(): + code = """ +import matplotlib.pyplot as plt +plt.plot([1, 2]) +plt.show() +print("then") +1 / 0 +""" + async with make_worker() as worker: + reply = await worker.run(code) + assert isinstance(reply, Error), reply + assert shape(reply) == ["plot", "then\n"] diff --git a/pkg-py/tests/test_execution_protocol.py b/pkg-py/tests/test_execution_protocol.py index fc6489a4..a5ec047f 100644 --- a/pkg-py/tests/test_execution_protocol.py +++ b/pkg-py/tests/test_execution_protocol.py @@ -19,6 +19,7 @@ from commons._execution import _protocol from commons._execution._env import worker_command, worker_env from commons._execution._protocol import ( + OUTPUT_LIMIT, PLOT_BYTES_LIMIT, PLOT_EDGE_LIMIT, PLOT_LIMIT, @@ -31,6 +32,7 @@ ProtocolError, Ready, Result, + Text, decode_message, decode_value, encode_message, @@ -44,6 +46,25 @@ def round_trip(message): return decode_message(encode_message(message)) +def out(text: str) -> Text: + return Text(stream="stdout", text=text) + + +def err(text: str) -> Text: + return Text(stream="stderr", text=text) + + +def printed(message, stream: str = "stdout") -> str: + """Everything ``message``'s output wrote to ``stream``.""" + return "".join( + s.text for s in message.output if isinstance(s, Text) and s.stream == stream + ) + + +def plots(message) -> list: + return [s for s in message.output if isinstance(s, Plot)] + + def test_a_call_carries_its_code_and_id(): call = Call(id="c1", code="x = 1") assert round_trip(call) == call @@ -62,7 +83,7 @@ def test_a_ready_announcement_round_trips(): def test_a_result_carries_what_the_call_printed(): - result = Result(id="c1", value=42, stdout="hello\n", stderr="oops\n") + result = Result(id="c1", value=42, output=(out("hello\n"), err("oops\n"))) assert round_trip(result) == result @@ -442,11 +463,11 @@ def test_a_frame_comes_back_as_the_result_of_a_call(): def test_a_frame_too_large_for_the_channel_crosses_as_its_repr(): pd = pytest.importorskip("pandas") frame = pd.DataFrame({"n": range(10_000_000)}) # ~80 MB as Arrow - crossed = decode_message(encode_message(Result(id="c1", value=frame, stdout="kept\n"))) + crossed = decode_message(encode_message(Result(id="c1", value=frame, output=(out("kept\n"),)))) assert isinstance(crossed, Result) assert isinstance(crossed.value, OpaqueValue) assert crossed.value.type_name == "DataFrame" - assert crossed.stdout == "kept\n" + assert printed(crossed) == "kept\n" def test_a_frame_that_cannot_fit_the_line_is_not_encoded_for_it(): @@ -459,24 +480,24 @@ def test_a_frame_that_cannot_fit_the_line_is_not_encoded_for_it(): pd = pytest.importorskip("pandas") frame = pd.DataFrame({"n": range(6_200_000)}) # ~47 MiB as Arrow assert encode_value(frame)["encoding"] == "repr" - printed = "o" * (_protocol._TEXT_CLIP_LIMIT + 1000) - line = encode_message(Result(id="c1", value=frame, stdout=printed)) + text = "o" * (_protocol._TEXT_CLIP_LIMIT + 1000) + line = encode_message(Result(id="c1", value=frame, output=(out(text),))) assert len(line) <= STREAM_LIMIT crossed = decode_message(line) assert isinstance(crossed, Result) - assert crossed.stdout == printed + assert printed(crossed) == text def test_output_too_large_for_the_channel_is_clipped(): # stdout holds unbounded model output, so this is reachable without any # adversary. The value survives; the output is clipped, and says so. - result = Result(id="c1", value=42, stdout="x" * (STREAM_LIMIT + 1000)) + result = Result(id="c1", value=42, output=(out("x" * (STREAM_LIMIT + 1000)),)) line = encode_message(result) assert len(line) <= STREAM_LIMIT crossed = decode_message(line) assert isinstance(crossed, Result) assert crossed.value == 42 - assert crossed.stdout.endswith("channel limit]") + assert printed(crossed).endswith("channel limit]") def test_a_handle_too_large_for_the_channel_crosses_as_its_repr(): @@ -1332,7 +1353,7 @@ def test_a_malformed_arrow_payload_is_refused(data): decode_value({"encoding": "arrow", "library": "pandas", "data": data}) -# --- plots ------------------------------------------------------------------ +# --- output ----------------------------------------------------------------- def _png(width: int, height: int) -> bytes: @@ -1346,120 +1367,188 @@ def _png(width: int, height: int) -> bytes: return b"\x89PNG\r\n\x1a\n" + len(ihdr[4:]).to_bytes(4, "big") + ihdr + b"\x00" * 4 -def _plot_line(**fields) -> bytes: - body = {"type": "result", "id": "c1", "plots": [fields]} +def _b64(data: bytes) -> str: + return base64.b64encode(data).decode("ascii") + + +def _output_line(*segments, kind: str = "result") -> bytes: + body = {"type": kind, "id": "c1", "output": list(segments)} + if kind == "error": + body["message"] = "x" return json.dumps(body).encode() + b"\n" -def _b64(data: bytes) -> str: - return base64.b64encode(data).decode("ascii") +def _plot_segment(png=None, display_png=None, **extra) -> dict: + image = _b64(_png(2, 2)) + return { + "type": "plot", + "png": image if png is None else png, + "display_png": image if display_png is None else display_png, + **extra, + } -def test_a_result_carries_its_plots(): - plot = Plot(png=_png(640, 480), display_png=_png(1280, 960)) - result = Result(id="c1", plots=(plot,)) +SMALL_PLOT = Plot(png=_png(640, 480), display_png=_png(1280, 960)) + + +def _big_plot(size: int) -> Plot: + data = _png(2, 2) + b"\x00" * size + return Plot(png=data, display_png=data) + + +def test_output_keeps_text_and_plots_in_the_order_they_happened(): + result = Result( + id="c1", output=(out("before\n"), SMALL_PLOT, err("warn\n"), out("after\n")) + ) assert round_trip(result) == result -def test_an_error_carries_the_plots_drawn_before_it(): - # What was drawn before the exception is still worth showing, as in R. - plot = Plot(png=_png(640, 480), display_png=_png(1280, 960)) - error = Error(id="c1", message="ValueError: late", plots=(plot,)) +def test_an_error_carries_the_output_from_before_it(): + # What was written and drawn before the exception is still worth + # showing, as in R. + error = Error(id="c1", message="ValueError: late", output=(out("x\n"), SMALL_PLOT)) assert round_trip(error) == error def test_a_plot_reports_the_size_of_its_model_image(): - plot = Plot(png=_png(640, 480), display_png=_png(1280, 960)) - assert (plot.width, plot.height) == (640, 480) + assert (SMALL_PLOT.width, SMALL_PLOT.height) == (640, 480) -def test_a_plot_that_is_not_a_png_is_refused(): - with pytest.raises(ProtocolError, match="malformed plot"): - decode_message(_plot_line(png=_b64(b""), display_png=_b64(_png(2, 2)))) +@pytest.mark.parametrize("kind", ["result", "error"]) +@pytest.mark.parametrize( + "segment", + [ + {"type": "text", "stream": "stdin", "text": "x"}, + {"type": "text", "stream": "stdout", "text": 3}, + {"type": "text", "stream": "stdout", "text": "x", "extra": 1}, + {"type": "image"}, + "text", + ], + ids=["bad-stream", "bad-text", "extra-field", "unknown-type", "not-a-mapping"], +) +def test_an_output_segment_of_the_wrong_shape_is_refused(kind, segment): + with pytest.raises(ProtocolError, match="malformed"): + decode_message(_output_line(segment, kind=kind)) -def test_a_plot_whose_data_is_not_base64_is_refused(): - with pytest.raises(ProtocolError, match="malformed plot"): - decode_message(_plot_line(png="not base64!", display_png=_b64(_png(2, 2)))) +def test_output_that_is_not_a_list_is_refused(): + line = b'{"type": "result", "id": "c1", "output": "no"}\n' + with pytest.raises(ProtocolError, match="malformed result message"): + decode_message(line) -def test_a_plot_with_a_field_it_does_not_have_is_refused(): +def test_more_output_segments_than_a_reply_may_have_are_refused(): + segment = {"type": "text", "stream": "stdout", "text": "x"} + with pytest.raises(ProtocolError, match="malformed result message"): + decode_message(_output_line(*[segment] * (OUTPUT_LIMIT + 1))) + + +def test_more_plots_than_a_call_may_draw_are_refused(): + with pytest.raises(ProtocolError, match="more than"): + decode_message(_output_line(*[_plot_segment()] * (PLOT_LIMIT + 1))) + + +@pytest.mark.parametrize("kind", ["result", "error"]) +@pytest.mark.parametrize( + "png", + [ + "iVBORw0KGgo=", + _b64(b""), + "not base64!", + _b64(_png(0, 10)), + _b64(_png(PLOT_EDGE_LIMIT + 1, 10)), + 12, + ], + ids=[ + "signature-only", + "not-a-png", + "not-base64", + "zero-width", + "too-wide", + "not-a-string", + ], +) +def test_a_plot_image_the_protocol_cannot_vouch_for_is_refused(kind, png): with pytest.raises(ProtocolError, match="malformed plot"): - decode_message( - _plot_line(png=_b64(_png(2, 2)), display_png=_b64(_png(2, 2)), alt="hi") - ) + decode_message(_output_line(_plot_segment(png=png), kind=kind)) -def test_plots_that_are_not_a_list_are_refused(): - line = b'{"type": "result", "id": "c1", "plots": "no"}\n' - with pytest.raises(ProtocolError, match="malformed result message"): - decode_message(line) +def test_a_plot_with_a_field_it_does_not_have_is_refused(): + with pytest.raises(ProtocolError, match="malformed plot"): + decode_message(_output_line(_plot_segment(alt="hi"))) -def test_more_plots_than_a_call_may_draw_are_refused(): - plot = {"png": _b64(_png(2, 2)), "display_png": _b64(_png(2, 2))} - body = {"type": "result", "id": "c1", "plots": [plot] * (PLOT_LIMIT + 1)} - with pytest.raises(ProtocolError, match="malformed result message"): - decode_message(json.dumps(body).encode() + b"\n") +def test_plots_past_the_byte_budget_are_refused_at_decode(): + # The worker keeps to the budget, but it runs model code. + image = _b64(_png(2, 2) + b"\x00" * (PLOT_BYTES_LIMIT // 2)) + with pytest.raises(ProtocolError, match="plots exceed"): + decode_message(_output_line(_plot_segment(png=image, display_png=image))) -def test_plots_too_large_for_the_channel_are_dropped_with_a_note(): - # The answer is worth more than the pictures: a result whose plots would - # overrun the line keeps its value and says what it lost. - big = _png(2, 2) + os.urandom(STREAM_LIMIT // 2) - result = Result(id="c1", value=42, plots=(Plot(png=big, display_png=big),)) +def test_plots_past_their_byte_budget_are_dropped_before_encoding(): + # The answer is worth more than the pictures: the value and the text + # stay, in order, and a note at the end says what was lost. + result = Result( + id="c1", + value=42, + output=(out("a\n"), _big_plot(PLOT_BYTES_LIMIT // 2), out("b\n")), + ) crossed = decode_message(encode_message(result)) assert isinstance(crossed, Result) assert crossed.value == 42 - assert crossed.plots == () - assert "plot" in crossed.stderr + assert crossed.output[:2] == (out("a\n"), out("b\n")) + assert crossed.output[-1] == err(_protocol._PLOT_DROP_NOTE) -def test_plots_past_their_byte_budget_are_dropped_before_encoding(): - big = _png(2, 2) + b"\x00" * (PLOT_BYTES_LIMIT // 2) - result = Result(id="c1", value=42, plots=(Plot(png=big, display_png=big),)) +def test_dropping_plots_leaves_output_that_fits_unclipped(): + text = "e" * (2 * 1024 * 1024) + result = Result(id="c1", output=(err(text), _big_plot(PLOT_BYTES_LIMIT // 2))) crossed = decode_message(encode_message(result)) - assert isinstance(crossed, Result) - assert crossed.plots == () - assert "plots were dropped" in crossed.stderr + assert printed(crossed, "stderr").startswith(text) -def test_the_note_about_dropped_plots_survives_clipped_output(): - big = _png(2, 2) + b"\x00" * (PLOT_BYTES_LIMIT // 2) +def test_each_stream_is_clipped_on_its_own_and_in_place(): + # Clipping cuts a stream where it crosses the limit and drops its later + # text; the other stream, and what came after, keep their places. + big = "o" * (STREAM_LIMIT // 2) result = Result( id="c1", - stderr="e" * (STREAM_LIMIT // 2), - stdout="o" * (STREAM_LIMIT // 2), - plots=(Plot(png=big, display_png=big),), + output=(out(big), err("warn\n"), out(big), SMALL_PLOT, err("late\n")), ) crossed = decode_message(encode_message(result)) assert isinstance(crossed, Result) - assert crossed.plots == () - assert crossed.stderr.endswith("plots were dropped; they exceeded the channel limit]") + first, warn, plot, late = crossed.output + assert isinstance(first, Text) + assert first.text.endswith(_protocol._TRUNCATION_NOTE) + assert len(first.text) == _protocol._TEXT_CLIP_LIMIT + len(_protocol._TRUNCATION_NOTE) + assert (warn, plot, late) == (err("warn\n"), SMALL_PLOT, err("late\n")) -def test_dropping_plots_leaves_output_that_fits_unclipped(): - big = _png(2, 2) + b"\x00" * (PLOT_BYTES_LIMIT // 2) - printed = "e" * (2 * 1024 * 1024) - result = Result(id="c1", stderr=printed, plots=(Plot(png=big, display_png=big),)) +def test_the_note_about_dropped_plots_survives_clipped_output(): + result = Result( + id="c1", + output=( + err("e" * (STREAM_LIMIT // 2)), + out("o" * (STREAM_LIMIT // 2)), + _big_plot(PLOT_BYTES_LIMIT // 2), + ), + ) crossed = decode_message(encode_message(result)) assert isinstance(crossed, Result) - assert crossed.stderr.startswith(printed) + assert plots(crossed) == [] + assert crossed.output[-1] == err(_protocol._PLOT_DROP_NOTE) - -def test_an_error_whose_plots_are_dropped_says_so_in_its_traceback(): - big = _png(2, 2) + b"\x00" * (PLOT_BYTES_LIMIT // 2) +def test_an_error_whose_plots_are_dropped_says_so_in_its_output(): error = Error( id="c1", message="ValueError: x", traceback="t" * (STREAM_LIMIT // 2), - plots=(Plot(png=big, display_png=big),), + output=(_big_plot(PLOT_BYTES_LIMIT // 2),), ) crossed = decode_message(encode_message(error)) assert isinstance(crossed, Error) - assert crossed.plots == () - assert crossed.traceback.endswith("plots were dropped; they exceeded the channel limit]") + assert crossed.output == (err(_protocol._PLOT_DROP_NOTE),) def test_plots_within_budget_give_way_to_a_value_that_would_overflow(): @@ -1470,36 +1559,7 @@ def test_plots_within_budget_give_way_to_a_value_that_would_overflow(): plot = Plot(png=plot_bytes, display_png=plot_bytes) # About 37 MB: it fits the line alone, and not alongside the plots. frame = pd.DataFrame({"b": [os.urandom(1024).hex() for _ in range(18_000)]}) - crossed = decode_message(encode_message(Result(id="c1", value=frame, plots=(plot,)))) + crossed = decode_message(encode_message(Result(id="c1", value=frame, output=(plot,)))) assert isinstance(crossed, Result) - assert crossed.plots == () + assert plots(crossed) == [] pd.testing.assert_frame_equal(crossed.value, frame) - - -@pytest.mark.parametrize("kind", ["result", "error"]) -@pytest.mark.parametrize( - "png", - [ - "iVBORw0KGgo=", - base64.b64encode(_png(0, 10)).decode(), - base64.b64encode(_png(PLOT_EDGE_LIMIT + 1, 10)).decode(), - 12, - ], - ids=["signature-only", "zero-width", "too-wide", "not-a-string"], -) -def test_a_plot_image_the_protocol_cannot_vouch_for_is_refused(kind, png): - body = {"type": kind, "id": "c1", "plots": [{"png": png, "display_png": png}]} - if kind == "error": - body["message"] = "x" - with pytest.raises(ProtocolError, match="malformed plot"): - decode_message(json.dumps(body).encode() + b"\n") - - -def test_plots_past_the_byte_budget_are_refused_at_decode(): - # The worker keeps to the budget, but it runs model code. - image = _b64(_png(2, 2) + b"\x00" * (PLOT_BYTES_LIMIT // 2)) - line = json.dumps( - {"type": "result", "id": "c1", "plots": [{"png": image, "display_png": image}]} - ).encode() + b"\n" - with pytest.raises(ProtocolError, match="plots exceed"): - decode_message(line) diff --git a/pkg-py/tests/test_execution_repl.py b/pkg-py/tests/test_execution_repl.py index d0a06e47..3d12e526 100644 --- a/pkg-py/tests/test_execution_repl.py +++ b/pkg-py/tests/test_execution_repl.py @@ -28,6 +28,11 @@ def run(code: str, namespace: dict | None = None) -> _repl.Evaluation: return _repl.run(code, namespace) +def printed(evaluation: _repl.Evaluation, stream: str = "stdout") -> str: + """Everything ``evaluation`` wrote to ``stream``.""" + return "".join(text for kind, text in evaluation.output if kind == stream) + + class TestResultValue: def test_trailing_expression_is_the_result(self): assert run("x = 1 + 2\nx").value == 3 @@ -135,40 +140,40 @@ def test_the_modules_own_annotations_import_does_not_leak(self): class TestOutputCapture: def test_stdout_is_captured_not_printed(self, capsys): evaluation = run("print('hello')") - assert evaluation.stdout == "hello\n" + assert printed(evaluation) == "hello\n" assert capsys.readouterr().out == "" def test_stderr_is_captured_not_printed(self, capsys): evaluation = run("import sys\nprint('oops', file=sys.stderr)") - assert evaluation.stderr == "oops\n" + assert printed(evaluation, "stderr") == "oops\n" assert capsys.readouterr().err == "" def test_trailing_expression_prints_too(self, capsys): evaluation = run("print('working')\n40 + 2") - assert evaluation.stdout == "working\n" + assert printed(evaluation) == "working\n" assert evaluation.value == 42 assert capsys.readouterr().out == "" def test_output_before_an_error_is_still_captured(self): evaluation = run("print('before')\n1 / 0") - assert evaluation.stdout == "before\n" + assert printed(evaluation) == "before\n" assert "ZeroDivisionError" in evaluation.error def test_output_is_bounded(self): # A runaway print loop stops at the channel limit, with a note. evaluation = run(f"print('x' * {2 * _repl._CAPTURE_LIMIT})") - assert len(evaluation.stdout) <= _repl._CAPTURE_LIMIT - assert evaluation.stdout.endswith("exceeded the channel limit]") + assert len(printed(evaluation)) <= _repl._CAPTURE_LIMIT + assert printed(evaluation).endswith("exceeded the channel limit]") def test_the_bound_is_exact(self): # A write that fills the capacity exactly is kept whole, note and # all; one byte more clips to exactly the limit. exact = _repl._CAPTURE_LIMIT - len(_repl._TRUNCATION_NOTE) evaluation = run(f"import sys\nsys.stdout.write('x' * {exact})") - assert evaluation.stdout == "x" * exact + assert printed(evaluation) == "x" * exact evaluation = run(f"import sys\nsys.stdout.write('x' * {exact + 1})") - assert len(evaluation.stdout) == _repl._CAPTURE_LIMIT - assert evaluation.stdout.endswith(_repl._TRUNCATION_NOTE) + assert len(printed(evaluation)) == _repl._CAPTURE_LIMIT + assert printed(evaluation).endswith(_repl._TRUNCATION_NOTE) def test_discarded_writes_do_not_fail(self): evaluation = run( @@ -179,7 +184,7 @@ def test_discarded_writes_do_not_fail(self): def test_closing_a_capture_stream_changes_nothing(self): evaluation = run("import sys\nsys.stdout.close()\nprint('still here')") assert evaluation.error == "" - assert evaluation.stdout == "still here\n" + assert printed(evaluation) == "still here\n" def test_base_class_methods_cannot_bypass_the_bound(self): # The capture wraps its buffer instead of subclassing StringIO, so @@ -190,7 +195,7 @@ def test_base_class_methods_cannot_bypass_the_bound(self): ) evaluation = run(code) assert "TypeError" in evaluation.error - assert len(evaluation.stdout) <= _repl._CAPTURE_LIMIT + assert len(printed(evaluation)) <= _repl._CAPTURE_LIMIT def test_a_lying_str_subclass_cannot_bypass_the_bound(self): # write() accounts by len(text), so a subclass reporting zero is @@ -204,26 +209,79 @@ def test_a_lying_str_subclass_cannot_bypass_the_bound(self): f"sys.stdout.write(S('y' * {2 * _repl._CAPTURE_LIMIT}))" ) evaluation = run(code) - assert len(evaluation.stdout) <= _repl._CAPTURE_LIMIT - assert evaluation.stdout.endswith("exceeded the channel limit]") + assert len(printed(evaluation)) <= _repl._CAPTURE_LIMIT + assert printed(evaluation).endswith("exceeded the channel limit]") def test_empty_writes_do_not_accumulate(self): code = "for _ in range(100_000):\n print(end='')\n40 + 2" evaluation = run(code) assert evaluation.value == 42 - assert evaluation.stdout == "" + assert printed(evaluation) == "" def test_a_sabotaged_capture_costs_output_not_the_answer(self): - code = "import sys\nsys.stdout._chunks = None\n40 + 2" + code = "import sys\nsys.stdout._transcript._entries = None\n40 + 2" evaluation = run(code) assert evaluation.value == 42 - assert evaluation.stdout == "" + assert printed(evaluation) == "" + + def test_the_streams_keep_the_order_they_were_written_in(self): + code = ( + "import sys\n" + "print('one')\n" + "print('two')\n" + "print('warn', file=sys.stderr)\n" + "print('three')" + ) + assert run(code).output == ( + ("stdout", "one\ntwo\n"), + ("stderr", "warn\n"), + ("stdout", "three\n"), + ) + + def test_an_inserted_item_sits_between_the_writes_around_it(self): + transcript = _repl.Transcript() + namespace = {"insert": transcript.insert} + evaluation = _repl.run( + "print('before')\ninsert('plot')\nprint('after')", namespace, transcript + ) + assert evaluation.output == ( + ("stdout", "before\n"), + ("item", "plot"), + ("stdout", "after\n"), + ) + + def test_switching_streams_endlessly_is_bounded_with_a_note(self): + code = ( + "import sys\n" + f"for _ in range({_repl._SEGMENT_LIMIT}):\n" + " sys.stdout.write('o')\n" + " sys.stderr.write('e')\n" + "40 + 2" + ) + evaluation = run(code) + assert evaluation.value == 42 + assert len(evaluation.output) == _repl._SEGMENT_LIMIT + 1 + assert evaluation.output[-1][1] == _repl._SEGMENT_NOTE + + def test_a_note_is_kept_past_both_bounds(self): + transcript = _repl.Transcript() + transcript.write("stdout", "x" * (2 * _repl._CAPTURE_LIMIT)) + transcript.note("stdout", "[note]") + [(_, text)] = transcript.entries() + assert text.endswith(_repl._TRUNCATION_NOTE + "[note]") + + def test_the_segment_bound_leaves_room_in_the_protocol(self): + # Past the text segments, a reply still needs room for every plot + # and a note for each, plus the notes about limits. + assert _repl._SEGMENT_LIMIT + 2 * _protocol.PLOT_LIMIT + 10 <= ( + _protocol.OUTPUT_LIMIT + ) def test_dunder_stdout_is_redirected_too(self, capsys): # sys.__stdout__ names the real stream directly; it stands in for # the capture like sys.stdout does. evaluation = run("import sys\nsys.__stdout__.write('x\\n')") - assert evaluation.stdout == "x\n" + assert printed(evaluation) == "x\n" assert capsys.readouterr().out == "" def test_dunder_streams_are_restored_after_the_call(self): @@ -368,19 +426,25 @@ def test_generator_exit_is_the_calls_answer(self): def test_a_sabotaged_readback_raising_generator_exit_keeps_the_answer(self): code = ( "import sys\n" - "sys.stdout.getvalue = lambda: (_ for _ in ()).throw(GeneratorExit)\n" + "class Entries(list):\n" + " def __iter__(self):\n" + " raise GeneratorExit\n" + "sys.stdout._transcript._entries = Entries()\n" "40 + 2" ) evaluation = run(code) assert evaluation.value == 42 - assert evaluation.stdout == "" + assert printed(evaluation) == "" def test_an_interrupt_during_readback_still_propagates(self): - # A sabotaged capture can raise from getvalue(); a KeyboardInterrupt - # there is an interrupt, not lost output. + # A sabotaged transcript can raise from its read-back; a + # KeyboardInterrupt there is an interrupt, not lost output. code = ( "import sys\n" - "sys.stdout.getvalue = lambda: (_ for _ in ()).throw(KeyboardInterrupt)\n" + "class Entries(list):\n" + " def __iter__(self):\n" + " raise KeyboardInterrupt\n" + "sys.stdout._transcript._entries = Entries()\n" ) with pytest.raises(KeyboardInterrupt): run(code) @@ -397,7 +461,7 @@ def test_the_module_stands_alone(): ( "import _repl, json, sys; " "e = _repl.run('print(1)\\n40 + 2'); " - "json.dump({'value': e.value, 'stdout': e.stdout}, sys.stdout)" + "json.dump({'value': e.value, 'output': e.output}, sys.stdout)" ), ], env={**os.environ, "PYTHONPATH": RUNTIME_DIR}, @@ -405,4 +469,4 @@ def test_the_module_stands_alone(): text=True, check=True, ) - assert json.loads(completed.stdout) == {"value": 42, "stdout": "1\n"} + assert json.loads(completed.stdout) == {"value": 42, "output": [["stdout", "1\n"]]} From 72134fcbf19ed10bbc40b444bb8a665eeab508f1 Mon Sep 17 00:00:00 2001 From: Josh Taillon Date: Fri, 9 Oct 2026 12:22:44 -0400 Subject: [PATCH 07/10] fix(py): bound the worker's plot notes and survive a close that raises 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. --- .../src/commons/_execution/_runtime/_plots.py | 11 ++++++-- .../commons/_execution/_runtime/_worker.py | 7 ++++- pkg-py/tests/test_execution_plots.py | 26 +++++++++++++++++++ 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/pkg-py/src/commons/_execution/_runtime/_plots.py b/pkg-py/src/commons/_execution/_runtime/_plots.py index da7c2a4d..2a3c6a4d 100644 --- a/pkg-py/src/commons/_execution/_runtime/_plots.py +++ b/pkg-py/src/commons/_execution/_runtime/_plots.py @@ -33,6 +33,10 @@ # How many times the display image's pixels exceed the model image's. _DISPLAY_SCALE = 2 +# The most of an exception's repr a note keeps. Notes bypass the capture's +# bound so they are never lost, which makes them bounded here instead. +_DESCRIPTION_LIMIT = 500 + class _Call: """The plot state of the call in flight: where plots go, and what they used.""" @@ -141,9 +145,12 @@ def _close_all() -> None: def _describe(exc: BaseException) -> str: - """``exc``'s repr, or its type's name when model code made the repr fail.""" + """``exc``'s repr, clipped, or its type's name when model code made it fail.""" try: - return str(repr(exc)) + text = str(repr(exc)) + if len(text) > _DESCRIPTION_LIMIT: + return text[:_DESCRIPTION_LIMIT] + "…" + return text except KeyboardInterrupt: raise except BaseException: # noqa: BLE001 - the fallback is the type name diff --git a/pkg-py/src/commons/_execution/_runtime/_worker.py b/pkg-py/src/commons/_execution/_runtime/_worker.py index 0c6844ab..6aabc226 100644 --- a/pkg-py/src/commons/_execution/_runtime/_worker.py +++ b/pkg-py/src/commons/_execution/_runtime/_worker.py @@ -315,7 +315,12 @@ def _execute(call: _protocol.Call, namespace: dict) -> _protocol.Message: _in_call = False # Outside the interruptible window, so a second SIGINT cannot escape # here. Figures still open, half-drawn after an interrupt, go with it. - _guarded(_plots.end) + # Only model code can raise here, a KeyboardInterrupt included, and none + # of it may end the session. + try: + _plots.end() + except BaseException: # noqa: BLE001, S110 - a figure left open costs nothing more + pass if evaluation is None: return _protocol.Error(id=call.id, message="KeyboardInterrupt") output = _segments(_guarded(read_back) or ()) diff --git a/pkg-py/tests/test_execution_plots.py b/pkg-py/tests/test_execution_plots.py index 61b995b1..f118afbe 100644 --- a/pkg-py/tests/test_execution_plots.py +++ b/pkg-py/tests/test_execution_plots.py @@ -420,3 +420,29 @@ async def test_figures_shown_before_an_error_keep_their_place(): reply = await worker.run(code) assert isinstance(reply, Error), reply assert shape(reply) == ["plot", "then\n"] + + +async def test_a_note_about_a_figure_is_bounded(): + # Notes are kept past the capture's bound, so their own size is capped. + code = BROKEN_DRAW.format(body="raise ValueError('x' * 3_000_000)") + async with make_worker() as worker: + reply = await worker.run(code) + assert isinstance(reply, Result), reply + assert len(printed(reply, "stderr")) < 1000 + + +async def test_a_close_raising_keyboardinterrupt_keeps_the_session(): + code = """ +import matplotlib.pyplot as plt +x = 41 +def close(*args): + raise KeyboardInterrupt +plt.figure() +plt.close = close +""" + async with make_worker() as worker: + await worker.run(code) + # The session, and x with it, survived; this call mends pyplot. + follow_up = await worker.run("del plt.close\nx + 1") + assert isinstance(follow_up, Result), follow_up + assert follow_up.value == 42 From b8bdd533cc3f928fb22c312a15d42296539808c1 Mon Sep 17 00:00:00 2001 From: Josh Taillon Date: Fri, 9 Oct 2026 12:27:42 -0400 Subject: [PATCH 08/10] fix(py): destroy figures through pyplot's registry when close is broken 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. --- .../src/commons/_execution/_runtime/_plots.py | 35 +++++++++++++------ pkg-py/tests/test_execution_plots.py | 32 +++++++++++++++++ 2 files changed, 56 insertions(+), 11 deletions(-) diff --git a/pkg-py/src/commons/_execution/_runtime/_plots.py b/pkg-py/src/commons/_execution/_runtime/_plots.py index 2a3c6a4d..5fbf4b1b 100644 --- a/pkg-py/src/commons/_execution/_runtime/_plots.py +++ b/pkg-py/src/commons/_execution/_runtime/_plots.py @@ -131,33 +131,46 @@ def _note(call: _Call, text: str) -> None: def _close_all() -> None: - """Close every open figure.""" + """Close every open figure, through pyplot's registry if pyplot is broken. + + A figure left open would come back with the next call, so when model + code has broken ``pyplot.close`` the figures are destroyed in + ``Gcf``, the registry pyplot keeps them in. + """ pyplot = sys.modules.get("matplotlib.pyplot") if pyplot is None: return try: pyplot.close("all") - except KeyboardInterrupt: - raise - except BaseException: # noqa: BLE001, S110 - model code can break pyplot - # A figure left open costs nothing more than the figure. + except BaseException as exc: # model code can break pyplot + # An interrupt still propagates, once the figures are gone. + _destroy_all() + if isinstance(exc, KeyboardInterrupt): + raise + + +def _destroy_all() -> None: + """Destroy every figure in pyplot's registry, ignoring any failure.""" + try: + sys.modules["matplotlib._pylab_helpers"].Gcf.destroy_all() + except BaseException: # noqa: BLE001, S110 - both broken costs the figures pass def _describe(exc: BaseException) -> str: - """``exc``'s repr, clipped, or its type's name when model code made it fail.""" + """``exc``'s repr, or its type's name when model code made it fail, clipped.""" try: text = str(repr(exc)) - if len(text) > _DESCRIPTION_LIMIT: - return text[:_DESCRIPTION_LIMIT] + "…" - return text except KeyboardInterrupt: raise except BaseException: # noqa: BLE001 - the fallback is the type name try: - return str(type(exc).__name__) + text = str(type(exc).__name__) except BaseException: # noqa: BLE001 - the fallback is a fixed text - return "an exception" + text = "an exception" + if len(text) > _DESCRIPTION_LIMIT: + return text[:_DESCRIPTION_LIMIT] + "…" + return text def _render(figure: Any) -> _protocol.Plot: diff --git a/pkg-py/tests/test_execution_plots.py b/pkg-py/tests/test_execution_plots.py index f118afbe..a01a67f3 100644 --- a/pkg-py/tests/test_execution_plots.py +++ b/pkg-py/tests/test_execution_plots.py @@ -446,3 +446,35 @@ def close(*args): follow_up = await worker.run("del plt.close\nx + 1") assert isinstance(follow_up, Result), follow_up assert follow_up.value == 42 + # The figure was destroyed despite the broken close. + assert plots(follow_up) == [] + + +async def test_a_broken_close_leaves_no_figure_for_the_next_call(): + code = """ +import matplotlib.pyplot as plt +plt.figure() +def close(*args): + raise RuntimeError("no") +plt.close = close +""" + async with make_worker() as worker: + reply = await worker.run(code) + assert isinstance(reply, Result), reply + assert len(plots(reply)) == 1 + follow_up = await worker.run("del plt.close\n1") + assert isinstance(follow_up, Result), follow_up + assert plots(follow_up) == [] + + +async def test_a_note_naming_an_unrepresentable_exception_is_bounded(): + code = BROKEN_DRAW.format( + body=( + "E = type('E' * 3_000_000, (Exception,), " + "{'__repr__': lambda self: 1 / 0})\n raise E()" + ) + ) + async with make_worker() as worker: + reply = await worker.run(code) + assert isinstance(reply, Result), reply + assert len(printed(reply, "stderr")) < 1000 From d969ff2811c361df53a36d4a0dafdf33ad26d9d8 Mon Sep 17 00:00:00 2001 From: Josh Taillon Date: Fri, 9 Oct 2026 21:11:27 -0400 Subject: [PATCH 09/10] docs(py): rewrite jargony docstrings in plain language --- pkg-py/src/commons/_execution/_protocol.py | 25 +++++++++---------- .../src/commons/_execution/_runtime/_plots.py | 20 ++++++++------- .../src/commons/_execution/_runtime/_repl.py | 8 +++--- .../commons/_execution/_runtime/_worker.py | 8 +++--- 4 files changed, 32 insertions(+), 29 deletions(-) diff --git a/pkg-py/src/commons/_execution/_protocol.py b/pkg-py/src/commons/_execution/_protocol.py index 760129cb..377d65be 100644 --- a/pkg-py/src/commons/_execution/_protocol.py +++ b/pkg-py/src/commons/_execution/_protocol.py @@ -877,15 +877,14 @@ def _arrow_refusals(pyarrow: Any) -> tuple[type[BaseException], ...]: def encode_message(message: Message) -> bytes: - """Render ``message`` as the single line that carries it. + """Render ``message`` as the single line that contains it. Plots past ``PLOT_BYTES_LIMIT`` are dropped with a note before anything - is encoded. A message that would still exceed ``STREAM_LIMIT`` is shrunk: - text is clipped, then plots are dropped with a note, then values are - replaced by their reprs. Raises - ``ProtocolError`` when nothing is left to shrink — a ``Call`` carrying - megabytes of code, say — because a line no reader can consume would - wedge the channel. + is encoded. A message that would still exceed ``STREAM_LIMIT`` is + shrunk: text is clipped, then plots are dropped with a note, then + values are replaced by their reprs. Raises ``ProtocolError`` when + nothing is left to shrink (a ``Call`` with megabytes of code, say), + because a line no reader can consume would block the channel. """ if _plot_bytes(message) > PLOT_BYTES_LIMIT: # Dropped before encoding, so oversized plots are never copied into @@ -955,7 +954,7 @@ def _message_body(message: Message) -> dict[str, Any]: def _segment_body(segment: Segment) -> dict[str, str]: - """The JSON object for one output segment, a plot's images in base64.""" + """The JSON object for one output segment, with a plot's images in base64.""" match segment: case Text(): return {"type": "text", "stream": segment.stream, "text": segment.text} @@ -986,7 +985,7 @@ def _shrink_text(message: Message) -> Message: def _clip_output(output: tuple[Segment, ...]) -> tuple[Segment, ...]: """``output`` with each stream's text clipped to ``_TEXT_CLIP_LIMIT`` in total. - The stream's text is cut where it crosses the limit, and its later + The stream's text is cut where it exceeds the limit, and its later segments are dropped. Plots and the note that plots were dropped are kept where they are. """ @@ -1170,7 +1169,7 @@ def png_size(data: bytes) -> tuple[int, int]: Raises ``ValueError`` unless ``data`` starts with the PNG signature and a well-formed IHDR chunk whose edges are between 1 and ``PLOT_EDGE_LIMIT``. The pixel data is not decoded; the bound on the declared size is what - keeps a later decode from allocating more than a plot's worth. + keeps a later decode from allocating more memory than a plot should need. """ if ( len(data) < 33 @@ -1190,9 +1189,9 @@ def png_size(data: bytes) -> tuple[int, int]: def _decode_output(body: dict[str, Any], kind: str) -> tuple[Segment, ...]: """The ``output`` of a result or error body, each segment checked. - The segment, plot, and plot-byte bounds are the worker's own, enforced - again here because the worker runs model code and cannot be relied on - to keep them. + The worker enforces these same bounds on segments, plots, and plot + bytes. They are enforced again here because the worker runs model code + and cannot be relied on to enforce them. """ raw = body.get("output", []) if not isinstance(raw, list) or len(raw) > OUTPUT_LIMIT: diff --git a/pkg-py/src/commons/_execution/_runtime/_plots.py b/pkg-py/src/commons/_execution/_runtime/_plots.py index 5fbf4b1b..496a75bf 100644 --- a/pkg-py/src/commons/_execution/_runtime/_plots.py +++ b/pkg-py/src/commons/_execution/_runtime/_plots.py @@ -39,7 +39,8 @@ class _Call: - """The plot state of the call in flight: where plots go, and what they used.""" + """The plot state of the running call: where its plots go, and how much of + the plot limits it has used.""" def __init__(self, transcript: Any) -> None: self.transcript = transcript @@ -59,7 +60,7 @@ def begin(transcript: Any) -> None: def end() -> None: - """Close whatever figures remain, so none carries into the next call.""" + """Close whatever figures remain, so none survives into the next call.""" global _call _call = None _close_all() @@ -70,8 +71,8 @@ def flush() -> None: Model code can run inside ``savefig`` (an artist's ``draw``) and can replace pyplot's own functions, so no failure here may escape: a broken - figure costs that figure, and a broken pyplot costs the plots, never the - session. Each failure, and the point where the call ran past + figure loses only that figure, and a broken pyplot loses the plots, never + the session. Each failure, and the point where the call ran past ``PLOT_LIMIT`` or ``PLOT_BYTES_LIMIT``, is noted on stderr where it happened. ``KeyboardInterrupt`` propagates; the worker then discards the figures. @@ -158,7 +159,7 @@ def _destroy_all() -> None: def _describe(exc: BaseException) -> str: - """``exc``'s repr, or its type's name when model code made it fail, clipped.""" + """``exc``'s repr, or its type's name when model code made that fail, truncated.""" try: text = str(repr(exc)) except KeyboardInterrupt: @@ -174,11 +175,12 @@ def _describe(exc: BaseException) -> str: def _render(figure: Any) -> _protocol.Plot: - """``figure`` as a model PNG at its own size, scaled down to fit, and a display PNG. + """Render ``figure`` as a pair of PNGs, one for the model and one for display. - Both images are checked before they are kept: model code can replace - ``savefig``, and a reply carrying something that is not a plot would - cost the session rather than the figure. + The model PNG is saved at the figure's own size, scaled down to fit the + model's limit. Both PNGs are validated before they are returned: model + code can replace ``savefig``, and returning something that is not a + valid plot would break the session, not just that figure. """ width, height = figure.get_size_inches() dpi = min(float(figure.dpi), _MODEL_LONG_EDGE / max(width, height)) diff --git a/pkg-py/src/commons/_execution/_runtime/_repl.py b/pkg-py/src/commons/_execution/_runtime/_repl.py index e2891021..73b80894 100644 --- a/pkg-py/src/commons/_execution/_runtime/_repl.py +++ b/pkg-py/src/commons/_execution/_runtime/_repl.py @@ -72,7 +72,8 @@ class Transcript: that model code allocates directly is bounded by the worker's rlimits. ``insert`` places an item (a plot) between the runs, and ``note`` adds - the worker's own text past both bounds, so a note is never lost. + the worker's own text without applying either bound, so a note is never + lost. """ def __init__(self) -> None: @@ -99,7 +100,7 @@ def write(self, stream: str, text: str) -> None: self._truncated.add(stream) def note(self, stream: str, text: str) -> None: - """Record the worker's own ``text`` on ``stream``, past both bounds.""" + """Record the worker's own ``text`` on ``stream``, without applying the bounds.""" self._append(stream, text, bounded=False) def insert(self, item: Any) -> None: @@ -228,7 +229,8 @@ def run( The capture replaces ``sys.stdout``, ``sys.stderr``, and their ``__stdout__``/``__stderr__`` names with views onto ``transcript``, - which the caller passes when it inserts into the output itself. Writes + which the caller passes when it will insert items of its own (such as + plots) into the output. Writes to file descriptor 1, or through a stream reference saved before the call, still reach the real stream; the worker loop owns that channel and guards it. diff --git a/pkg-py/src/commons/_execution/_runtime/_worker.py b/pkg-py/src/commons/_execution/_runtime/_worker.py index 6aabc226..679940d6 100644 --- a/pkg-py/src/commons/_execution/_runtime/_worker.py +++ b/pkg-py/src/commons/_execution/_runtime/_worker.py @@ -290,9 +290,9 @@ def guard(default): def _execute(call: _protocol.Call, namespace: dict) -> _protocol.Message: """Run one call in the session namespace and render its reply. - The figures still open when the code finishes are flushed inside the - interruptible window, because drawing a figure runs model code and can - take as long as the call did. + The figures still open when the code finishes are flushed while the + call can still be interrupted, because drawing a figure runs model code + and can take as long as the call itself. """ global _in_call namespace.update(call.handles) @@ -345,7 +345,7 @@ def _guarded(step): def _segments(entries) -> tuple: - """The transcript's entries as protocol segments, skipping any that are not.""" + """The transcript's entries as protocol segments, skipping any that cannot convert.""" segments = [] for entry in entries: try: From ebcdb794da61f5a842566ec65ccd822c0a319027 Mon Sep 17 00:00:00 2001 From: Josh Taillon Date: Fri, 9 Oct 2026 21:24:59 -0400 Subject: [PATCH 10/10] fix(py): keep text printed while the last figures draw, and clamp replies 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. --- pkg-py/src/commons/_execution/_protocol.py | 30 ++++--- .../_execution/_runtime/_figure_backend.py | 12 ++- .../src/commons/_execution/_runtime/_plots.py | 2 +- .../src/commons/_execution/_runtime/_repl.py | 31 ++++--- .../commons/_execution/_runtime/_worker.py | 34 ++++++-- pkg-py/tests/test_execution_plots.py | 81 +++++++++++++++++++ pkg-py/tests/test_execution_protocol.py | 2 +- 7 files changed, 161 insertions(+), 31 deletions(-) diff --git a/pkg-py/src/commons/_execution/_protocol.py b/pkg-py/src/commons/_execution/_protocol.py index 377d65be..d82ccff2 100644 --- a/pkg-py/src/commons/_execution/_protocol.py +++ b/pkg-py/src/commons/_execution/_protocol.py @@ -98,12 +98,13 @@ _TRUNCATION_NOTE = "\n[truncated by commons: the output exceeded the channel limit]" -# Most plots one reply may carry; matplotlib's own warning about open figures -# starts at the same count. +# Most plots one reply may include; matplotlib's own warning about open +# figures starts at the same count. PLOT_LIMIT = 20 -# Most PNG bytes one reply's plots may total, before base64. A quarter of the -# line leaves the rest for the value and the printed output. +# Most PNG bytes one reply's plots may total, before base64. That is a +# quarter of the line's length, or about a third once base64 adds its +# overhead, which leaves the rest for the value and the printed output. PLOT_BYTES_LIMIT = STREAM_LIMIT // 4 # Longest edge either image of a plot may have. The worker's own images stay @@ -112,8 +113,8 @@ PLOT_EDGE_LIMIT = 4096 # Most segments one reply's output may have, plots included. The worker's -# capture starts a text segment only when the stream changes, and refuses new -# ones well before this. +# capture starts a new text segment only when the stream changes or a plot +# comes between, and refuses new ones well before this. OUTPUT_LIMIT = 10_000 _PLOT_DROP_NOTE = "[commons: the plots were dropped; they exceeded the channel limit]\n" @@ -199,7 +200,11 @@ def height(self) -> int: @dataclass(frozen=True, kw_only=True) class Text: - """Text the call wrote to one of its streams, as a run of consecutive writes.""" + """Text written to one stream, as a run of consecutive writes. + + Most of it is what the call printed. The worker's own notes, such as + one saying a figure could not be drawn, are ``stderr`` text too. + """ stream: Literal["stdout", "stderr"] text: str @@ -242,7 +247,8 @@ class Result: class Error: """Sent by the worker to the driver: the code raised, and this is what it said. - ``output`` is what the code wrote and drew before it raised. + ``output`` is what the code wrote and drew before it raised. An + interrupted call is reported with an empty ``output``. """ id: str @@ -879,8 +885,8 @@ def _arrow_refusals(pyarrow: Any) -> tuple[type[BaseException], ...]: def encode_message(message: Message) -> bytes: """Render ``message`` as the single line that contains it. - Plots past ``PLOT_BYTES_LIMIT`` are dropped with a note before anything - is encoded. A message that would still exceed ``STREAM_LIMIT`` is + When the plots total more than ``PLOT_BYTES_LIMIT``, all of them are + dropped, with a note, before anything is encoded. A message that would still exceed ``STREAM_LIMIT`` is shrunk: text is clipped, then plots are dropped with a note, then values are replaced by their reprs. Raises ``ProtocolError`` when nothing is left to shrink (a ``Call`` with megabytes of code, say), @@ -964,6 +970,10 @@ def _segment_body(segment: Segment) -> dict[str, str]: "png": base64.b64encode(segment.png).decode("ascii"), "display_png": base64.b64encode(segment.display_png).decode("ascii"), } + case _: + raise ProtocolError( + f"not an output segment: {type(segment).__name__}" + ) def _shrink_text(message: Message) -> Message: diff --git a/pkg-py/src/commons/_execution/_runtime/_figure_backend.py b/pkg-py/src/commons/_execution/_runtime/_figure_backend.py index 4cd00de7..a53ed991 100644 --- a/pkg-py/src/commons/_execution/_runtime/_figure_backend.py +++ b/pkg-py/src/commons/_execution/_runtime/_figure_backend.py @@ -10,9 +10,19 @@ from __future__ import annotations import _plots # pyrefly: ignore[missing-import] +from matplotlib.backend_bases import FigureManagerBase from matplotlib.backends.backend_agg import FigureCanvasAgg -FigureCanvas = FigureCanvasAgg + +class FigureManager(FigureManagerBase): + """Shows a figure the way ``show()`` below does, for ``fig.show()``.""" + + def show(self) -> None: + _plots.flush() + + +class FigureCanvas(FigureCanvasAgg): + manager_class = FigureManager def show(*args: object, **kwargs: object) -> None: diff --git a/pkg-py/src/commons/_execution/_runtime/_plots.py b/pkg-py/src/commons/_execution/_runtime/_plots.py index 496a75bf..a1d61fa3 100644 --- a/pkg-py/src/commons/_execution/_runtime/_plots.py +++ b/pkg-py/src/commons/_execution/_runtime/_plots.py @@ -117,7 +117,7 @@ def flush() -> None: _note( call, f"figures {call.figures} onward were dropped; their images " - "exceeded the channel's room for plots", + "exceeded the channel limit", ) call.full = True break diff --git a/pkg-py/src/commons/_execution/_runtime/_repl.py b/pkg-py/src/commons/_execution/_runtime/_repl.py index 73b80894..3bb39df9 100644 --- a/pkg-py/src/commons/_execution/_runtime/_repl.py +++ b/pkg-py/src/commons/_execution/_runtime/_repl.py @@ -28,6 +28,7 @@ import io import sys import traceback +from collections.abc import Callable from dataclasses import dataclass from typing import Any @@ -39,14 +40,14 @@ # The most output one stream may keep, note included, so a runaway print loop # cannot exhaust the worker's memory. Both values equal their counterparts in -# `commons._execution._protocol`, whose clip then never applies a second time -# and whose note the model sees either way. +# `commons._execution._protocol`, so text this capture keeps is not clipped +# again there, and the model sees the same note either way. _CAPTURE_LIMIT = 1024 * 1024 _TRUNCATION_NOTE = "\n[truncated by commons: the output exceeded the channel limit]" -# The most text segments one call's output may have. A segment starts only -# when the output switches streams, so this is reached only by output that -# alternates thousands of times. `commons._execution._protocol.OUTPUT_LIMIT` +# The most text segments one call's output may have. A new segment starts +# when the output switches streams or follows a plot, so this is reached +# only by output that alternates thousands of times. `commons._execution._protocol.OUTPUT_LIMIT` # leaves room past it for plots and the worker's notes. _SEGMENT_LIMIT = 9_000 _SEGMENT_NOTE = "\n[truncated by commons: the output switched streams too often]" @@ -215,6 +216,7 @@ def run( code: str, namespace: dict[str, Any] | None = None, transcript: Transcript | None = None, + after: Callable[[], object] | None = None, ) -> Evaluation: """Run ``code`` in ``namespace`` the way a REPL would, and report the outcome. @@ -234,6 +236,12 @@ def run( to file descriptor 1, or through a stream reference saved before the call, still reach the real stream; the worker loop owns that channel and guards it. + + ``after`` runs once the code has finished, whether or not it raised, + while output is still captured. The worker uses it to add the figures + the code left open, so anything printed while they are drawn is kept. + Any exception from it other than ``KeyboardInterrupt`` propagates, so + the caller should pass a function that does not raise. """ if namespace is None: namespace = {} @@ -252,11 +260,14 @@ def run( try: # Inside the try, so an interrupt mid-swap still restores them. sys.__stdout__, sys.__stderr__ = stdout, stderr # type: ignore[bad-assignment] - value = _evaluate(code, namespace) - except KeyboardInterrupt: - raise - except BaseException as exc: # noqa: BLE001 - any failure is the call's answer - error, tb = _render_error(exc) + try: + value = _evaluate(code, namespace) + except KeyboardInterrupt: + raise + except BaseException as exc: # noqa: BLE001 - any failure is the call's answer + error, tb = _render_error(exc) + if after is not None: + after() finally: sys.__stdout__, sys.__stderr__ = real_dunder # Model code had access to the transcript, so the read-back may fail; diff --git a/pkg-py/src/commons/_execution/_runtime/_worker.py b/pkg-py/src/commons/_execution/_runtime/_worker.py index 679940d6..99b1f1bd 100644 --- a/pkg-py/src/commons/_execution/_runtime/_worker.py +++ b/pkg-py/src/commons/_execution/_runtime/_worker.py @@ -28,6 +28,7 @@ import os import signal import sys +import warnings # The channel takeover runs before the sandbox, which needs the protocol fds # preserved across its fd sweep. @@ -297,15 +298,15 @@ def _execute(call: _protocol.Call, namespace: dict) -> _protocol.Message: global _in_call namespace.update(call.handles) transcript = _repl.Transcript() - # Bound before model code runs, which can reach the transcript and the - # plot module and replace their methods. - read_back, flush = transcript.entries, _plots.flush + # Looked up before model code runs, which can replace it. + flush = _plots.flush _plots.begin(transcript) evaluation = None _in_call = True try: - evaluation = _repl.run(call.code, namespace, transcript) - _guarded(flush) + evaluation = _repl.run( + call.code, namespace, transcript, after=lambda: _guarded(flush) + ) except KeyboardInterrupt: # The driver's SIGINT broke the call out of its computation. The # session and its variables survive; the driver reports the @@ -323,7 +324,7 @@ def _execute(call: _protocol.Call, namespace: dict) -> _protocol.Message: pass if evaluation is None: return _protocol.Error(id=call.id, message="KeyboardInterrupt") - output = _segments(_guarded(read_back) or ()) + output = _guarded(lambda: _segments(evaluation.output)) or () if evaluation.error: return _protocol.Error( id=call.id, @@ -345,17 +346,30 @@ def _guarded(step): def _segments(entries) -> tuple: - """The transcript's entries as protocol segments, skipping any that cannot convert.""" + """The transcript's entries as protocol segments, within the driver's limits. + + The transcript stays within these limits on its own. Model code can + still change it directly, so entries that are not text or a valid plot + are skipped, and output past ``OUTPUT_LIMIT`` segments or + ``PLOT_LIMIT`` plots is dropped. Without this, the driver would refuse + the reply and restart the session. + """ segments = [] + plots = 0 for entry in entries: + if len(segments) >= _protocol.OUTPUT_LIMIT: + break try: kind, body = entry except (TypeError, ValueError): continue + if type(kind) is not str: + continue if kind in ("stdout", "stderr") and type(body) is str: segments.append(_protocol.Text(stream=kind, text=body)) - elif kind == "item" and _is_plot(body): + elif kind == "item" and plots < _protocol.PLOT_LIMIT and _is_plot(body): segments.append(body) + plots += 1 return tuple(segments) @@ -389,6 +403,10 @@ def main() -> None: # Read by matplotlib when pyplot first needs a backend, so it must be set # before model code can import pyplot. os.environ["MPLBACKEND"] = _plots.BACKEND + # Model code often switches to the Agg backend itself. Its figures still + # come back at the end of the call, so the warning that Agg cannot show + # them would only mislead the model. + warnings.filterwarnings("ignore", message="FigureCanvasAgg is non-interactive") _engage_sandbox(network, protection) # Installed explicitly, so a worker whose parent ignores SIGINT, as a # shell does for a background job, can still be interrupted. diff --git a/pkg-py/tests/test_execution_plots.py b/pkg-py/tests/test_execution_plots.py index a01a67f3..11d95a6f 100644 --- a/pkg-py/tests/test_execution_plots.py +++ b/pkg-py/tests/test_execution_plots.py @@ -478,3 +478,84 @@ async def test_a_note_naming_an_unrepresentable_exception_is_bounded(): reply = await worker.run(code) assert isinstance(reply, Result), reply assert len(printed(reply, "stderr")) < 1000 + + +async def test_text_written_while_the_last_figures_are_drawn_is_kept(): + # The figures still open when the code finishes are drawn while output + # is still captured, so a warning raised in a draw reaches the reply. + code = """ +import matplotlib.pyplot as plt +from matplotlib.artist import Artist + +class Chatty(Artist): + def draw(self, renderer): + print("drawing now") + +plt.figure().add_artist(Chatty()) +print("code done") +""" + async with make_worker() as worker: + reply = await worker.run(code) + assert isinstance(reply, Result), reply + # Each figure is drawn twice, once for each of its images. + assert printed(reply) == "code done\n" + "drawing now\n" * 2 + assert len(plots(reply)) == 1 + + +async def test_fig_show_places_the_figure_like_plt_show(): + code = """ +import matplotlib.pyplot as plt +print("before") +fig, ax = plt.subplots() +ax.plot([1, 2]) +fig.show() +print("after") +""" + async with make_worker() as worker: + reply = await worker.run(code) + assert isinstance(reply, Result), reply + assert shape(reply) == ["before\n", "plot", "after\n"] + + +async def test_switching_to_agg_still_returns_the_figure_quietly(): + # Model code often selects Agg itself; show() then cannot place the + # figure, but it still comes back, with no warning to mislead the model. + code = """ +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +plt.plot([1, 2]) +plt.show() +print("after") +""" + async with make_worker() as worker: + reply = await worker.run(code) + assert isinstance(reply, Result), reply + assert shape(reply) == ["after\n", "plot"] + + +@pytest.mark.parametrize( + "tamper", + [ + "for _ in range(25):\n t.insert(plot)", + "t._entries.extend([['stdout', ['x']], ['stderr', ['y']]] * 6000)", + ], + ids=["too-many-plots", "too-many-segments"], +) +async def test_a_tampered_transcript_still_gives_a_reply_the_driver_accepts(tamper): + code = f""" +import sys +import matplotlib.pyplot as plt +plt.plot([1, 2]) +plt.show() +t = sys.stdout._transcript +plot = [body for kind, body in t.entries() if kind == "item"][0] +{tamper} +x = 41 +""" + async with make_worker() as worker: + reply = await worker.run(code) + assert isinstance(reply, Result), reply + follow_up = await worker.run("x + 1") + assert isinstance(follow_up, Result) + assert follow_up.value == 42 diff --git a/pkg-py/tests/test_execution_protocol.py b/pkg-py/tests/test_execution_protocol.py index a5ec047f..bbc8d712 100644 --- a/pkg-py/tests/test_execution_protocol.py +++ b/pkg-py/tests/test_execution_protocol.py @@ -1403,7 +1403,7 @@ def test_output_keeps_text_and_plots_in_the_order_they_happened(): assert round_trip(result) == result -def test_an_error_carries_the_output_from_before_it(): +def test_an_error_includes_the_output_from_before_it(): # What was written and drawn before the exception is still worth # showing, as in R. error = Error(id="c1", message="ValueError: late", output=(out("x\n"), SMALL_PLOT))