plotline 0.1.0.dev0__cp311-abi3-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
plotline/session.py ADDED
@@ -0,0 +1,400 @@
1
+ """The session: one working context over the engine's session, with the side effects the engine
2
+ leaves to its host.
3
+
4
+ The engine (``plotline._core``) keeps the source image, the canvas, the pens, the chosen style with
5
+ its settings, the plot speeds, the stage cache and the latest drawing, and checks every change. This
6
+ module adds what a Python host does: it reads and writes files, drives a run on a worker thread or
7
+ in the caller's, passes frames to subscribers at a capped rate, and turns the engine's dicts and
8
+ flat path buffers into the dataclasses of ``plotline.types``. The CLI, the GUI and the MCP server
9
+ call this layer and nothing below it.
10
+ """
11
+
12
+ import json
13
+ import math
14
+ import os
15
+ import threading
16
+ import time
17
+ import warnings
18
+ from collections.abc import Callable, Sequence
19
+ from dataclasses import asdict
20
+ from itertools import pairwise
21
+ from pathlib import Path
22
+ from typing import Any, TypeVar
23
+
24
+ import numpy as np
25
+ from numpy.typing import NDArray
26
+
27
+ from plotline import _core
28
+ from plotline.registry import get_style
29
+ from plotline.types import (
30
+ Cancelled,
31
+ Canvas,
32
+ Drawing,
33
+ Frame,
34
+ Layer,
35
+ Pen,
36
+ RunState,
37
+ Speeds,
38
+ Stats,
39
+ Style,
40
+ ToneMap,
41
+ )
42
+
43
+ T = TypeVar("T")
44
+
45
+
46
+ def _split(coords: NDArray[np.float64], offsets: NDArray[np.uint32]) -> list[NDArray[np.float64]]:
47
+ """The paths of a flat buffer as ``(n, 2)`` views: path ``i`` is
48
+ ``coords[offsets[i]:offsets[i + 1]]``."""
49
+ return [coords[a:b] for a, b in pairwise(offsets.tolist())]
50
+
51
+
52
+ def _frame(f: dict[str, Any]) -> Frame:
53
+ coords = f["coords"]
54
+ paths = None if coords is None else _split(coords, f["offsets"])
55
+ return Frame(f["stage"], f["progress"], f["label"], f["points"], paths, f["pen"])
56
+
57
+
58
+ def _drawing(d: dict[str, Any]) -> Drawing:
59
+ layers = [Layer(Pen(**x["pen"]), _split(x["coords"], x["offsets"])) for x in d["layers"]]
60
+ tone = None if d["tone"] is None else ToneMap(**d["tone"])
61
+ return Drawing(Canvas(**d["canvas"]), layers, tone)
62
+
63
+
64
+ def _have(value: T | None) -> T:
65
+ if value is None:
66
+ raise RuntimeError("there is no result yet; run the style first")
67
+ return value
68
+
69
+
70
+ class RunHandle:
71
+ """One run of a style, as ``Session.run`` or ``Session.run_blocking`` makes it.
72
+
73
+ ``state`` starts as ``running`` and ends as ``done``, ``cancelled`` or ``error``. ``result``
74
+ is set when the run is ``done`` and ``error`` holds the exception when it ended in ``error``:
75
+ a ``RuntimeError`` with the engine's message for a failed stage, or what a subscriber raised.
76
+ All of them can be read from any thread. A handle from ``run_blocking`` has no thread, so it
77
+ is finished by the time the caller has it.
78
+ """
79
+
80
+ def __init__(self, run: _core.Run) -> None:
81
+ self._run = run
82
+ self._thread: threading.Thread | None = None
83
+ self._state = RunState.running
84
+ self._result: Drawing | None = None
85
+ self._error: BaseException | None = None
86
+
87
+ @property
88
+ def state(self) -> RunState:
89
+ return self._state
90
+
91
+ @property
92
+ def result(self) -> Drawing | None:
93
+ return self._result
94
+
95
+ @property
96
+ def error(self) -> BaseException | None:
97
+ return self._error
98
+
99
+ def cancel(self) -> None:
100
+ """Ask the run to stop; the stage under way sees it at its next check. A run that has
101
+ already finished stays as it ended."""
102
+ self._run.cancel()
103
+
104
+ def wait(self, timeout: float | None = None) -> RunState:
105
+ """Block until the run has ended, or for ``timeout`` seconds at most, and return
106
+ ``state``, which is still ``running`` after a timeout. A subscriber runs on the worker
107
+ thread, which cannot wait for itself, so a call from there raises ``RuntimeError``
108
+ instead of hanging."""
109
+ if self._thread is threading.current_thread():
110
+ raise RuntimeError("a run cannot wait for itself: wait() was called on its own thread")
111
+ if self._thread is not None:
112
+ self._thread.join(timeout)
113
+ return self._state
114
+
115
+ def _end(
116
+ self,
117
+ state: RunState,
118
+ result: Drawing | None = None,
119
+ error: BaseException | None = None,
120
+ ) -> None:
121
+ # The state goes last, so a reader that sees the end state sees the result or error too.
122
+ self._result = result
123
+ self._error = error
124
+ self._state = state
125
+
126
+
127
+ class Session:
128
+ """One working context: an image, a canvas, pens, a style with its settings, plot speeds, the
129
+ stage cache and the latest result.
130
+
131
+ The attributes can be read at any time; ``image``, ``canvas``, ``pens``, ``style`` and
132
+ ``settings`` are copies of what the engine holds. Change them through the ``set_*`` methods and
133
+ ``update``, which the engine checks: a refused value raises ``ValueError`` and changes
134
+ nothing. ``cache_bytes`` bounds the stage cache and ``max_fps`` caps how often frames are
135
+ delivered to the subscribers. ``run`` works on a worker thread and ``run_blocking`` in the
136
+ caller's. Starting either one stops the run before it, so at most one is active. A session is
137
+ not safe for concurrent changes, so a front end should call it from one thread. ``speeds``,
138
+ which ``stats`` reads, has no setter and is edited in place.
139
+ """
140
+
141
+ def __init__(self, cache_bytes: int = 512 * 2**20, max_fps: float = 15.0) -> None:
142
+ self._core = _core.Session(cache_bytes)
143
+ self.result: Drawing | None = None
144
+ self.speeds = Speeds()
145
+ self.max_fps = max_fps
146
+ self._handle: RunHandle | None = None
147
+ self._subscribers: dict[object, Callable[[Frame], None]] = {}
148
+ self._last_delivery = -math.inf
149
+
150
+ @property
151
+ def image(self) -> NDArray[np.float32] | None:
152
+ """The source image as an ``(H, W, 3)`` float32 array in 0..1, or ``None``."""
153
+ return self._core.image()
154
+
155
+ @property
156
+ def canvas(self) -> Canvas:
157
+ return Canvas(**self._core.canvas())
158
+
159
+ @property
160
+ def pens(self) -> tuple[Pen, ...]:
161
+ return tuple(Pen(**pen) for pen in self._core.pens())
162
+
163
+ @property
164
+ def style(self) -> Style:
165
+ return get_style(self._core.style_name())
166
+
167
+ @property
168
+ def settings(self) -> dict[str, dict[str, Any]]:
169
+ """Every stage's settings, keyed by stage name."""
170
+ return self._core.settings()
171
+
172
+ @property
173
+ def state(self) -> RunState:
174
+ """The state of the latest run, or ``idle`` before any run."""
175
+ return RunState.idle if self._handle is None else self._handle.state
176
+
177
+ def load_image(self, path: str | os.PathLike[str]) -> None:
178
+ """Read the PNG, JPEG or WebP file at ``path`` as the source image. Raises ``OSError``
179
+ when the file cannot be read and ``ValueError`` when the engine cannot decode it."""
180
+ self._core.set_image_bytes(Path(path).read_bytes())
181
+
182
+ def set_image(self, image: NDArray[Any]) -> None:
183
+ """Use ``image`` as the source image. It must have shape ``(H, W, 3)`` or ``(H, W)``,
184
+ where a grey image is repeated into three channels, else ``ValueError`` is raised. An
185
+ integer array is divided by the largest value of its type, so ``uint8`` by 255 and
186
+ ``uint16`` by 65535; a float array is taken to be in 0..1 already. The engine keeps a
187
+ copy, so the caller may change the array afterwards."""
188
+ pixels = np.asarray(image)
189
+ if pixels.ndim == 2:
190
+ pixels = np.repeat(pixels[:, :, None], 3, axis=2)
191
+ elif pixels.ndim != 3 or pixels.shape[2] != 3:
192
+ raise ValueError(f"image must have shape (H, W) or (H, W, 3), got {pixels.shape}")
193
+ rgb = np.asarray(pixels, dtype=np.float32)
194
+ if np.issubdtype(pixels.dtype, np.integer):
195
+ # rgb is a new array, since the dtype changed, so the caller's is untouched.
196
+ rgb /= np.iinfo(pixels.dtype).max
197
+ self._core.set_image(rgb)
198
+
199
+ def analysis(self) -> dict[str, Any]:
200
+ """What the image looks like and what would suit it, as ``{"stats", "suggestions"}``.
201
+ ``stats`` holds numbers measured when the image was set. Each suggestion is
202
+ ``{"kind", "reason", "patch"}``, where ``patch`` is ``None`` or any of ``style``,
203
+ ``settings`` (``{stage: {key: value}}``) and ``page`` (``{width_mm, height_mm}``), which
204
+ the caller applies; the list follows the current style, settings and page. Raises
205
+ ``RuntimeError`` when no image is loaded."""
206
+ analysis = self._core.analysis()
207
+ if analysis is None:
208
+ raise RuntimeError("no image is loaded")
209
+ return analysis
210
+
211
+ def set_canvas(self, canvas: Canvas) -> None:
212
+ """Use ``canvas`` as the page. Raises ``ValueError`` for a page or margin that leaves no
213
+ drawing area."""
214
+ self._core.set_canvas(asdict(canvas))
215
+
216
+ def set_pens(self, pens: Sequence[Pen]) -> None:
217
+ """Use ``pens``, in drawing order. Raises ``ValueError`` when there are none, because
218
+ every result needs a pen, and for a pen the engine refuses."""
219
+ self._core.set_pens([asdict(pen) for pen in pens])
220
+
221
+ def set_style(self, name: str) -> None:
222
+ """Switch to the built-in style called ``name`` and reset the settings to its defaults.
223
+ The cache is kept, so a stage that the styles share, with the same settings and
224
+ inputs, is not run again. Raises
225
+ ``ValueError`` for an unknown name."""
226
+ self._core.set_style(name)
227
+
228
+ def update(self, stage: str, **values: Any) -> None:
229
+ """Change settings of one stage, leaving the rest as they were. The whole stage is
230
+ checked again, so a bad value or an unknown stage raises ``ValueError`` and changes
231
+ nothing."""
232
+ self._core.update(stage, values)
233
+
234
+ def save_preset(self, path: str | os.PathLike[str]) -> None:
235
+ """Write the style, canvas, pens and settings to ``path`` as a JSON preset, UTF-8 with LF
236
+ line ends and indented by two spaces so that it reads and diffs well."""
237
+ text = json.dumps(self._core.preset(), indent=2) + "\n"
238
+ Path(path).write_text(text, encoding="utf-8", newline="\n")
239
+
240
+ def load_preset(self, path: str | os.PathLike[str]) -> None:
241
+ """Read the preset file at ``path`` and apply it, so its style, canvas, pens and settings
242
+ replace the current ones. A key the file leaves out keeps its default, and each stage or
243
+ setting the style does not have is dropped with a ``UserWarning``. Raises ``ValueError``
244
+ for a file that is not a preset or that the engine refuses."""
245
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
246
+ for message in self._core.load_preset(data):
247
+ warnings.warn(message, UserWarning, stacklevel=2)
248
+
249
+ def run_blocking(self) -> Drawing:
250
+ """Run the style in the calling thread and return the drawing, which is also kept in
251
+ ``result``.
252
+
253
+ A run under way on a worker is cancelled and waited for first. Each frame goes through
254
+ ``_deliver`` as it arrives. A failed stage raises ``RuntimeError`` with the engine's
255
+ message and sets ``state`` to ``error``; a cancelled run raises ``Cancelled`` and sets it
256
+ to ``cancelled``. Either way ``result`` stays as it was. Raises ``RuntimeError`` when no
257
+ image is loaded.
258
+ """
259
+ return self._execute(self._start())
260
+
261
+ def run(self) -> RunHandle:
262
+ """Run the style on a worker thread and return its handle at once.
263
+
264
+ A run under way is cancelled and waited for first. The style, settings, canvas, pens and
265
+ image are taken now, so later changes do not reach the run. The subscribers get its
266
+ frames on the worker thread. Unlike ``run_blocking`` it raises no stage error: the run
267
+ ends in ``error`` and the handle keeps the exception. Raises ``RuntimeError`` when no
268
+ image is loaded.
269
+ """
270
+ handle = self._start()
271
+ thread = threading.Thread(target=self._work, args=(handle,), daemon=True)
272
+ handle._thread = thread
273
+ thread.start()
274
+ return handle
275
+
276
+ def cancel(self) -> None:
277
+ """Ask the latest run to stop, which ends it in ``cancelled``. Nothing happens when there
278
+ has been no run, and a run that has ended stays as it ended."""
279
+ if self._handle is not None:
280
+ self._handle.cancel()
281
+
282
+ def subscribe(self, callback: Callable[[Frame], None]) -> Callable[[], None]:
283
+ """Call ``callback(frame)`` for the frames of every later run, and return a function that
284
+ removes it again.
285
+
286
+ ``max_fps`` caps the rate, see ``_deliver``. The callback runs on the thread that drives
287
+ the run, the worker for ``run`` and the caller for ``run_blocking``, so it should return
288
+ quickly. An exception from it ends the run in ``error``, as a failed stage does.
289
+ """
290
+ key = object()
291
+ self._subscribers[key] = callback
292
+
293
+ def unsubscribe() -> None:
294
+ self._subscribers.pop(key, None)
295
+
296
+ return unsubscribe
297
+
298
+ def _start(self) -> RunHandle:
299
+ """Stop the latest run, wait for it, and start the next one in the engine, which takes a
300
+ snapshot of the session."""
301
+ if self._handle is not None:
302
+ self._handle.cancel()
303
+ self._handle.wait()
304
+ self._handle = handle = RunHandle(self._core.start())
305
+ return handle
306
+
307
+ def _work(self, handle: RunHandle) -> None:
308
+ """The body of a worker thread."""
309
+ try:
310
+ self._execute(handle)
311
+ except Exception:
312
+ pass # the handle keeps it, and a thread has no caller to raise to
313
+
314
+ def _execute(self, handle: RunHandle) -> Drawing:
315
+ """Drive the run to its end in the current thread, passing each frame to ``_deliver``,
316
+ and record how it ended on ``handle``. This is the one place that moves a run through its
317
+ states. ``Cancelled`` and any error are raised, with ``result`` as it was."""
318
+ self._last_delivery = -math.inf
319
+ run = handle._run
320
+ try:
321
+ while (frame := run.next_frame()) is not None:
322
+ self._deliver(_frame(frame))
323
+ state = RunState(run.state())
324
+ if state is RunState.done:
325
+ # No newer run has started, so the engine's latest drawing is this run's.
326
+ self.result = result = _drawing(_have(self._core.drawing()))
327
+ handle._end(state, result)
328
+ return result
329
+ if state is RunState.cancelled:
330
+ handle._end(state)
331
+ raise Cancelled("the run was cancelled")
332
+ failure = RuntimeError(run.error())
333
+ handle._end(RunState.error, error=failure)
334
+ raise failure
335
+ except BaseException as error:
336
+ # Anything raised before the handle ended, such as a subscriber's error, ends it here.
337
+ if handle.state is RunState.running:
338
+ handle._end(RunState.error, error=error)
339
+ raise
340
+
341
+ def _deliver(self, frame: Frame) -> None:
342
+ """Pass ``frame`` to the subscribers, unless less than ``1 / max_fps`` seconds have
343
+ passed since the last delivery. The first frame of a run and every frame with ``progress``
344
+ of 1 or more are always passed on, so a throttled preview still ends on the finished
345
+ state of each stage."""
346
+ # Not time.monotonic: before Python 3.13 it ticks every 15.6 ms on Windows, so a fast stage
347
+ # would lose frames even when max_fps asks for all of them.
348
+ now = time.perf_counter()
349
+ if frame.progress < 1 and now - self._last_delivery < 1 / self.max_fps:
350
+ return
351
+ self._last_delivery = now
352
+ for callback in list(self._subscribers.values()):
353
+ callback(frame)
354
+
355
+ def svg(self) -> str:
356
+ """The result as SVG text. Raises ``RuntimeError`` when there is no result."""
357
+ return _have(self._core.svg())
358
+
359
+ def json_dict(self) -> dict[str, Any]:
360
+ """The result as the dict of the JSON paths format. Raises ``RuntimeError`` when there is
361
+ no result."""
362
+ return _have(self._core.json())
363
+
364
+ def export_svg(self, path: str | os.PathLike[str]) -> None:
365
+ """Write the SVG to ``path`` as UTF-8 with LF line ends. Raises ``RuntimeError`` when
366
+ there is no result."""
367
+ Path(path).write_text(self.svg(), encoding="utf-8", newline="\n")
368
+
369
+ def export_json(self, path: str | os.PathLike[str]) -> None:
370
+ """Write the JSON paths format to ``path`` as UTF-8 with LF line ends, one value per line
371
+ and no indentation, which diffs well and is a quarter the size of indenting by two.
372
+ Raises ``RuntimeError`` when there is no result."""
373
+ text = json.dumps(self.json_dict(), indent=0) + "\n"
374
+ Path(path).write_text(text, encoding="utf-8", newline="\n")
375
+
376
+ def stats(self) -> Stats:
377
+ """The path count, pen-down and pen-up distances and estimated plot time of the result at
378
+ ``speeds``. Raises ``RuntimeError`` when there is no result."""
379
+ self._core.set_speeds(asdict(self.speeds))
380
+ return Stats(**_have(self._core.stats()))
381
+
382
+ def preview_png(
383
+ self,
384
+ width_px: int = 1000,
385
+ travel: bool = False,
386
+ source: bool = False,
387
+ region: tuple[float, float, float, float] | None = None,
388
+ ) -> bytes:
389
+ """The result drawn at true pen width as PNG bytes that are ``width_px`` wide, with the
390
+ pen-up moves when ``travel`` is set and the tone map under the paths when ``source`` is.
391
+ ``region`` is ``(x, y, width, height)`` in page mm, origin top-left; the picture then shows
392
+ only that part, which may reach past the page, and is as high as the region's shape needs.
393
+ Raises ``ValueError`` for a region with a value that is not finite or a width or height
394
+ that is not positive, and ``RuntimeError`` when there is no result."""
395
+ return _have(self._core.preview_png(width_px, travel, source, region))
396
+
397
+ def cache_stats(self) -> dict[str, int]:
398
+ """What the stage cache holds and how often it was asked: ``hits``, ``misses``,
399
+ ``bytes`` and ``entries``."""
400
+ return self._core.cache_stats()
plotline/types.py ADDED
@@ -0,0 +1,130 @@
1
+ """The values the Python package hands out and takes in: page, pens, speeds, stats, drawings,
2
+ frames, styles and run states.
3
+
4
+ They are plain dataclasses with no checks of their own. The engine checks a canvas and pens when a
5
+ session is given them and raises ``ValueError`` for one it refuses.
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+ from enum import StrEnum
10
+ from typing import Any, Literal
11
+
12
+ import numpy as np
13
+ from numpy.typing import NDArray
14
+
15
+ from plotline import _core
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Canvas:
20
+ """The page in mm, the margin on every side, and whether the image fits inside the drawing
21
+ area (``fit``) or fills it (``fill``)."""
22
+
23
+ width_mm: float
24
+ height_mm: float
25
+ margin_mm: float = 0.0
26
+ fit: Literal["fit", "fill"] = "fit"
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Pen:
31
+ """A pen: a name, the line width in mm and the colour as ``#rrggbb``."""
32
+
33
+ name: str
34
+ width_mm: float
35
+ color: str
36
+
37
+
38
+ @dataclass
39
+ class Speeds:
40
+ """How fast a plotter moves, as a front end lets the user set it.
41
+
42
+ ``draw_mm_s`` is the speed with the pen down and ``travel_mm_s`` the speed with it up, both in
43
+ mm per second. ``pen_lift_s`` is the time to lift the pen and set it down again, which every
44
+ path costs once. The class is not frozen, so a front end can edit the values in place.
45
+ """
46
+
47
+ draw_mm_s: float = 50.0
48
+ travel_mm_s: float = 100.0
49
+ pen_lift_s: float = 0.2
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class Stats:
54
+ """What a drawing costs to plot: the number of paths, the pen-down and pen-up distances in mm
55
+ and the estimated plot time in seconds."""
56
+
57
+ path_count: int
58
+ pen_down_mm: float
59
+ pen_up_mm: float
60
+ estimated_seconds: float
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class ToneMap:
65
+ """The ink density in 0..1 that a drawing was made from, one value per pixel of ``ink``, with
66
+ the size of a pixel in mm and the page position of the top left pixel."""
67
+
68
+ ink: NDArray[np.float32]
69
+ mm_per_px: float
70
+ origin_mm: tuple[float, float]
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class Layer:
75
+ """The paths drawn with one pen, each an ``(n, 2)`` array of points in mm; a path of one
76
+ point is a dot."""
77
+
78
+ pen: Pen
79
+ paths: list[NDArray[np.float64]]
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class Drawing:
84
+ """A finished drawing: the page, a layer per pen and the tone map, if the style made one."""
85
+
86
+ canvas: Canvas
87
+ layers: list[Layer]
88
+ tone: ToneMap | None
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class Frame:
93
+ """One step of a run as the preview sees it: the stage, its progress in 0..1, a label, the
94
+ points or paths it has so far, if any, and the pen whose lane gave the frame (0 outside
95
+ lanes)."""
96
+
97
+ stage: str
98
+ progress: float
99
+ label: str
100
+ points: NDArray[np.float64] | None
101
+ paths: list[NDArray[np.float64]] | None
102
+ pen: int
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class Style:
107
+ """A built-in style: its name, a short label and a description."""
108
+
109
+ name: str
110
+ label: str
111
+ description: str
112
+
113
+ def schema(self) -> dict[str, Any]:
114
+ """The style with each stage's name, label and settings as JSON Schema."""
115
+ return _core.describe(self.name)
116
+
117
+
118
+ class RunState(StrEnum):
119
+ """Where the latest run stands: ``idle`` before any run, ``running`` while one is under way,
120
+ then ``done``, ``cancelled`` or ``error`` for how it ended."""
121
+
122
+ idle = "idle"
123
+ running = "running"
124
+ done = "done"
125
+ cancelled = "cancelled"
126
+ error = "error"
127
+
128
+
129
+ class Cancelled(Exception):
130
+ """Raised by ``Session.run_blocking`` when the run was cancelled before it finished."""