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..d82ccff2 100644 --- a/pkg-py/src/commons/_execution/_protocol.py +++ b/pkg-py/src/commons/_execution/_protocol.py @@ -33,23 +33,31 @@ 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", "STREAM_LIMIT", "Call", "ChannelError", "Error", "Message", "OpaqueValue", + "Plot", "ProtocolError", "Ready", "Result", + "Segment", + "Text", "decode_message", "decode_value", "encode_message", "encode_value", + "png_size", "read_message", "write_message", ] @@ -90,6 +98,29 @@ _TRUNCATION_NOTE = "\n[truncated by commons: the output exceeded the channel limit]" +# 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. 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 +# 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 + +# Most segments one reply's output may have, plots included. The worker's +# 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" + +_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 +174,47 @@ 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 Text: + """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 + + +# 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.""" @@ -159,7 +231,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 @@ -168,17 +240,21 @@ class Result: id: str value: Any = None - stdout: str = "" - stderr: str = "" + 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.""" + """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. An + interrupted call is reported with an empty ``output``. + """ id: str message: str traceback: str = "" + output: tuple[Segment, ...] = () Message = Ready | Call | Result | Error @@ -807,18 +883,28 @@ 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 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. + """Render ``message`` as the single line that contains it. + + 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), + 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 + # base64 only to be thrown away. + message = _drop_plots(message) 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)) @@ -857,8 +943,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, + "output": [_segment_body(segment) for segment in message.output], } case Error(): return { @@ -866,6 +951,7 @@ def _message_body(message: Message) -> dict[str, Any]: "id": message.id, "message": message.message, "traceback": message.traceback, + "output": [_segment_body(segment) for segment in message.output], } case _: raise ProtocolError( @@ -873,25 +959,90 @@ def _message_body(message: Message) -> dict[str, Any]: ) +def _segment_body(segment: Segment) -> dict[str, str]: + """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} + case Plot(): + return { + "type": "plot", + "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: - """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(message.stderr), - ) + return replace(message, output=_clip_output(message.output)) case Error(): return replace( message, message=_clip(message.message), traceback=_clip(message.traceback), + output=_clip_output(message.output), ) case _: return 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 exceeds 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 [segment for segment in message.output if isinstance(segment, Plot)] + case _: + 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 at the end of its output.""" + match message: + 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 + + def _shrink_values(message: Message) -> Message: """The same message with its values replaced by their reprs. @@ -962,21 +1113,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"} - ) + _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"), + output=_decode_output(body, "result"), ) case "error": - _refuse_unknown_fields(body, "error", {"type", "id", "message", "traceback"}) + _refuse_unknown_fields( + 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"), + output=_decode_output(body, "error"), ) case _: raise ProtocolError(f"unknown message type: {kind!r}") @@ -1022,6 +1173,91 @@ 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 memory than a plot should need. + """ + 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_output(body: dict[str, Any], kind: str) -> tuple[Segment, ...]: + """The ``output`` of a result or error body, each segment checked. + + 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: + raise ProtocolError( + 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" + ) + 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 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"): + 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 + 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]) + + 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..a53ed991 --- /dev/null +++ b/pkg-py/src/commons/_execution/_runtime/_figure_backend.py @@ -0,0 +1,30 @@ +"""The matplotlib backend the worker selects: Agg rendering, and an inline ``show()``. + +``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.backend_bases import FigureManagerBase +from matplotlib.backends.backend_agg import 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: + """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 new file mode 100644 index 00000000..a1d61fa3 --- /dev/null +++ b/pkg-py/src/commons/_execution/_runtime/_plots.py @@ -0,0 +1,205 @@ +"""Plot capture: the figures a call shows or leaves open, rendered as PNGs and closed. + +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. +""" + +from __future__ import annotations + +import io +import sys +from typing import Any + +import _protocol # pyrefly: ignore[missing-import] + +__all__ = ["BACKEND", "begin", "end", "flush"] + +# 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 + +# 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 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 + 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 survives 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 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. + """ + call = _call + pyplot = sys.modules.get("matplotlib.pyplot") + 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 + _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 + _note( + call, f"figure {call.figures} could not be rendered: {_describe(exc)}" + ) + continue + 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. + _note( + call, + f"figures {call.figures} onward were dropped; their images " + "exceeded the channel limit", + ) + call.full = True + break + 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 _close_all() -> None: + """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 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, or its type's name when model code made that fail, truncated.""" + try: + text = str(repr(exc)) + except KeyboardInterrupt: + raise + except BaseException: # noqa: BLE001 - the fallback is the type name + try: + text = str(type(exc).__name__) + except BaseException: # noqa: BLE001 - the fallback is a fixed text + text = "an exception" + if len(text) > _DESCRIPTION_LIMIT: + return text[:_DESCRIPTION_LIMIT] + "…" + return text + + +def _render(figure: Any) -> _protocol.Plot: + """Render ``figure`` as a pair of PNGs, one for the model and one for display. + + 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)) + 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() + with matplotlib.rc_context({"savefig.bbox": "standard"}): + figure.savefig(buffer, format="png", dpi=dpi) + return buffer.getvalue() diff --git a/pkg-py/src/commons/_execution/_runtime/_repl.py b/pkg-py/src/commons/_execution/_runtime/_repl.py index b7865feb..3bb39df9 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 @@ -24,10 +28,11 @@ import io import sys import traceback +from collections.abc import Callable 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. @@ -35,11 +40,18 @@ # 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 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]" + # 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 +63,92 @@ _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 without applying either bound, 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``, without applying the 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 +189,35 @@ 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, + after: Callable[[], object] | 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 +230,28 @@ 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 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. + + ``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 = {} - 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 = "" @@ -186,29 +260,25 @@ def run(code: str, namespace: dict[str, Any] | None = None) -> Evaluation: 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 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 124b01d0..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. @@ -69,6 +70,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 +289,24 @@ 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 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) + transcript = _repl.Transcript() + # 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) + 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 @@ -300,18 +314,75 @@ 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. + # 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 = _guarded(lambda: _segments(evaluation.output)) or () if evaluation.error: return _protocol.Error( - id=call.id, message=evaluation.error, traceback=evaluation.traceback + id=call.id, + message=evaluation.error, + traceback=evaluation.traceback, + output=output, ) - return _protocol.Result( - id=call.id, - value=evaluation.value, - stdout=evaluation.stdout, - stderr=evaluation.stderr, - ) + 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, 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 plots < _protocol.PLOT_LIMIT and _is_plot(body): + segments.append(body) + plots += 1 + 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: @@ -329,6 +400,13 @@ 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 + # 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_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 1df33133..d72d182a 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.output) == 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..11d95a6f --- /dev/null +++ b/pkg-py/tests/test_execution_plots.py @@ -0,0 +1,561 @@ +"""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 Failure, Worker +from commons._execution._protocol import PLOT_LIMIT, Error, Plot, Result, Text +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) + + +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]) +""" + + +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] = 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. + 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] = 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) + + +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(plots(reply)) == 1 + assert printed(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 plots(reply) == [] + + +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 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(plots(reply)) == 1 + follow_up = await worker.run("1") + assert isinstance(follow_up, Result) + assert plots(follow_up) == [] + + +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(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(): + # 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] = plots(reply) + 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(plots(reply)) == 1 + assert "cannot draw this" in printed(reply, "stderr") + follow_up = await worker.run("1") + assert isinstance(follow_up, Result) + assert plots(follow_up) == [] + + +async def test_an_interrupted_call_leaves_no_figure_for_the_next_one(): + 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\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 plots(reply) == [] + + +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 plots(reply) == [] + + +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 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 + + +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 +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 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 + + +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 + + +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(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 + + +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(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 + + +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] = plots(reply) + assert (plot.width, plot.height) == (640, 480) + + +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(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"] + + +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 + # 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 + + +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 cc15bf81..bbc8d712 100644 --- a/pkg-py/tests/test_execution_protocol.py +++ b/pkg-py/tests/test_execution_protocol.py @@ -19,14 +19,20 @@ 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, STREAM_LIMIT, Call, ChannelError, Error, OpaqueValue, + Plot, ProtocolError, Ready, Result, + Text, decode_message, decode_value, encode_message, @@ -40,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 @@ -58,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 @@ -438,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(): @@ -455,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(): @@ -1326,3 +1351,215 @@ 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}) + + +# --- output ----------------------------------------------------------------- + + +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 _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 _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, + } + + +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_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)) + assert round_trip(error) == error + + +def test_a_plot_reports_the_size_of_its_model_image(): + assert (SMALL_PLOT.width, SMALL_PLOT.height) == (640, 480) + + +@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_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_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(_output_line(_plot_segment(png=png), kind=kind)) + + +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_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_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.output[:2] == (out("a\n"), out("b\n")) + assert crossed.output[-1] == err(_protocol._PLOT_DROP_NOTE) + + +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 printed(crossed, "stderr").startswith(text) + + +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", + output=(out(big), err("warn\n"), out(big), SMALL_PLOT, err("late\n")), + ) + crossed = decode_message(encode_message(result)) + assert isinstance(crossed, Result) + 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_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 plots(crossed) == [] + assert crossed.output[-1] == err(_protocol._PLOT_DROP_NOTE) + + +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), + output=(_big_plot(PLOT_BYTES_LIMIT // 2),), + ) + crossed = decode_message(encode_message(error)) + assert isinstance(crossed, Error) + assert crossed.output == (err(_protocol._PLOT_DROP_NOTE),) + + +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, output=(plot,)))) + assert isinstance(crossed, Result) + assert plots(crossed) == [] + pd.testing.assert_frame_equal(crossed.value, frame) 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"]]} 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"