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/mcp_server.py ADDED
@@ -0,0 +1,313 @@
1
+ """The MCP server: one ``Session`` served to an AI assistant over the Model Context Protocol.
2
+
3
+ ``build_server`` registers the tools, which draw an image with a style, show the result as a
4
+ picture and write it out. Every tool calls the session layer and nothing below it, so this module
5
+ holds no algorithm code.
6
+
7
+ ``Session`` is not safe for concurrent changes, and the SDK runs a plain ``def`` tool on a worker
8
+ thread. Every tool is therefore ``async def``, which keeps each session call on the event loop's
9
+ thread. Only the stages of a run leave it, on the session's own worker thread.
10
+
11
+ Lengths are in mm, the page origin is its top-left corner and y grows down. A path must be
12
+ absolute: a stdio server starts in whatever folder its client was in, so a relative path would land
13
+ somewhere the user did not mean.
14
+ """
15
+
16
+ import json
17
+ import math
18
+ import warnings
19
+ from collections import deque
20
+ from collections.abc import Iterator
21
+ from contextlib import ExitStack, contextmanager
22
+ from dataclasses import asdict, replace
23
+ from pathlib import Path
24
+ from typing import Any, Literal, cast
25
+
26
+ import anyio
27
+ from mcp.server.mcpserver import Context, Image, MCPServer
28
+ from mcp.server.mcpserver.exceptions import ToolError
29
+ from mcp.types import TextContent
30
+ from typing_extensions import TypedDict # pydantic refuses typing.TypedDict before Python 3.12
31
+
32
+ import plotline
33
+ from plotline import registry
34
+ from plotline.session import Session
35
+ from plotline.types import Cancelled, Canvas, Frame, Pen, RunState
36
+
37
+ INSTRUCTIONS = (
38
+ "plotline turns an image into line art for a pen plotter. Work in this order: load_image, "
39
+ "then set_page and set_pens if the defaults do not fit, then list_styles and describe_style "
40
+ "to choose a style and see its settings. Call analyse_image to see the image's statistics and "
41
+ "suggested changes; apply a suggestion with run(style=..., settings=...) or set_page. run "
42
+ "draws the image and returns its stats and a preview at true pen width. Look at the preview, "
43
+ "change settings with run(settings=...), and repeat; only the stages after a changed setting "
44
+ "run again. Use preview with region_mm to inspect detail. export writes the result. All "
45
+ "lengths are millimetres, the page origin is its top-left corner and y grows down. Paths must "
46
+ "be absolute."
47
+ )
48
+
49
+
50
+ PREVIEW_PX = 1000
51
+ MIN_PX = 100
52
+ MAX_PX = 4000
53
+ MAX_PIXELS = 16_000_000 # bounds the memory one preview can ask for, since the input is untrusted
54
+
55
+
56
+ class PenSpec(TypedDict):
57
+ name: str
58
+ width_mm: float
59
+ color: str
60
+
61
+
62
+ @contextmanager
63
+ def _errors() -> Iterator[None]:
64
+ """Turn the errors that bad input causes into a ``ToolError`` with the original message, which
65
+ the assistant sees as a failed call it can correct. Any other exception is a bug."""
66
+ try:
67
+ yield
68
+ except (ValueError, OSError, RuntimeError, Cancelled) as error:
69
+ raise ToolError(str(error)) from error
70
+
71
+
72
+ def _absolute(path: str) -> Path:
73
+ """``path`` with ``~`` expanded. Raises ``ValueError`` when the result is not absolute."""
74
+ expanded = Path(path).expanduser()
75
+ if not expanded.is_absolute():
76
+ raise ValueError(f"{path!r} is not an absolute path")
77
+ return expanded
78
+
79
+
80
+ def _array(items: list[Any]) -> TextContent:
81
+ # Returning the list itself would send one block per item; one JSON array reads better.
82
+ return TextContent(type="text", text=json.dumps(items, indent=2))
83
+
84
+
85
+ def _image_size(session: Session) -> dict[str, int] | None:
86
+ # shortcut: session.image copies the pixels, only the shape is used; upgrade when a call gets
87
+ # slow on a huge image, with a size accessor in the engine.
88
+ image = session.image
89
+ return None if image is None else {"width_px": image.shape[1], "height_px": image.shape[0]}
90
+
91
+
92
+ def _page(session: Session) -> Canvas:
93
+ """The result's page: the picture shows the result, and the page may have changed since the
94
+ run. Raises ``RuntimeError`` while a run is under way, since the engine holds the new drawing
95
+ a moment before ``session.result`` does, and the picture would then be checked against the
96
+ wrong page."""
97
+ if session.state is RunState.running:
98
+ raise RuntimeError("a run is under way; wait for the run to finish, then try again")
99
+ return session.canvas if session.result is None else session.result.canvas
100
+
101
+
102
+ def _pixels(width_px: int, width: float, height: float) -> float:
103
+ """The pixels of a picture ``width_px`` wide of an area ``width`` by ``height`` mm, or ``inf``
104
+ when its height in pixels is too large for a float. The height rounds half away from zero, as
105
+ the engine's does."""
106
+ rows = height * (width_px / width)
107
+ return width_px * math.floor(rows + 0.5) if math.isfinite(rows) else math.inf
108
+
109
+
110
+ def _widest(width: float, height: float, limit: int) -> int:
111
+ """The largest ``width_px`` up to ``limit`` whose picture of an area ``width`` by ``height`` mm
112
+ has at most ``MAX_PIXELS``. Rounding the height can push the square-root bound a few pixels
113
+ over."""
114
+ most = min(limit, int(math.sqrt(MAX_PIXELS * (width / height))))
115
+ while _pixels(most, width, height) > MAX_PIXELS:
116
+ most -= 1
117
+ return most
118
+
119
+
120
+ def _page_px(session: Session) -> int:
121
+ """The width of the previews that run returns and export writes: the largest up to
122
+ ``PREVIEW_PX`` whose picture of the whole page stays within ``MAX_PIXELS``."""
123
+ page = _page(session)
124
+ return _widest(page.width_mm, page.height_mm, PREVIEW_PX)
125
+
126
+
127
+ def build_server(session: Session | None = None) -> MCPServer:
128
+ """The server, with its tools working on ``session``, or on a new one when none is given."""
129
+ session = Session(max_fps=10) if session is None else session
130
+ server = MCPServer("plotline", version=plotline.__version__, instructions=INSTRUCTIONS)
131
+
132
+ @server.tool()
133
+ async def list_styles() -> TextContent:
134
+ """List the styles that can be run, each with a name, a label and a description."""
135
+ return _array([asdict(style) for style in registry.list_styles()])
136
+
137
+ @server.tool()
138
+ async def describe_style(style: str) -> dict[str, Any]:
139
+ """Describe a style: its stages, and for each setting the type, range, default and help
140
+ text. Change a setting by passing it to run."""
141
+ with _errors():
142
+ return registry.get_style(style).schema()
143
+
144
+ @server.tool()
145
+ async def load_image(path: str) -> dict[str, Any]:
146
+ """Load the PNG, JPEG or WebP file at the absolute path as the source image."""
147
+ with _errors():
148
+ p = _absolute(path)
149
+ session.load_image(p)
150
+ return {"path": str(p), **(_image_size(session) or {})}
151
+
152
+ @server.tool()
153
+ async def analyse_image() -> dict[str, Any]:
154
+ """Measure the loaded image and suggest changes for it. stats are numbers about its tones,
155
+ noise and colour. Each suggestion has a kind, a reason and a patch, which holds any of
156
+ style, settings ({stage: {setting: value}}) and page ({width_mm, height_mm}), or none for a
157
+ note. Apply a patch with run(style=..., settings=...) or set_page. The suggestions follow
158
+ the current style, settings and page."""
159
+ with _errors():
160
+ return session.analysis()
161
+
162
+ @server.tool()
163
+ async def set_page(
164
+ width_mm: float | None = None,
165
+ height_mm: float | None = None,
166
+ margin_mm: float | None = None,
167
+ fit: Literal["fit", "fill"] | None = None,
168
+ ) -> dict[str, Any]:
169
+ """Set the page size and margin in mm, and whether the image fits inside the drawing area
170
+ or fills it. A value that is left out stays as it was."""
171
+ given: dict[str, Any] = {
172
+ "width_mm": width_mm,
173
+ "height_mm": height_mm,
174
+ "margin_mm": margin_mm,
175
+ "fit": fit,
176
+ }
177
+ with _errors():
178
+ changes = {key: value for key, value in given.items() if value is not None}
179
+ session.set_canvas(replace(session.canvas, **changes))
180
+ return asdict(session.canvas)
181
+
182
+ @server.tool()
183
+ async def set_pens(pens: list[PenSpec]) -> TextContent:
184
+ """Set the pens, in drawing order. A pen has a name, a line width in mm and a colour, which
185
+ is #rrggbb or an ink name such as blue."""
186
+ inks = plotline.inks()
187
+ with _errors():
188
+ session.set_pens(
189
+ [
190
+ Pen(p["name"], p["width_mm"], inks.get(p["color"].lower(), p["color"]))
191
+ for p in pens
192
+ ]
193
+ )
194
+ return _array([asdict(pen) for pen in session.pens])
195
+
196
+ @server.tool()
197
+ async def run(
198
+ ctx: Context,
199
+ style: str | None = None,
200
+ settings: dict[str, dict[str, Any]] | None = None,
201
+ ) -> list[str | Image]:
202
+ """Draw the image and return the stats as JSON and a preview at true pen width, up to 1000
203
+ px wide. A style that differs from the current one replaces it and resets the settings to
204
+ its defaults. settings is {stage: {setting: value}} and changes only the values given; only
205
+ the stages after a changed setting run again. When a change is refused, nothing changes
206
+ and nothing runs."""
207
+ with _errors(), ExitStack() as stack:
208
+ old_style, old_settings = session.style.name, session.settings
209
+ try:
210
+ if style is not None and style != old_style:
211
+ session.set_style(style)
212
+ for stage, values in (settings or {}).items():
213
+ session.update(stage, **values)
214
+ except Exception:
215
+ session.set_style(old_style)
216
+ for stage, values in old_settings.items():
217
+ session.update(stage, **values)
218
+ raise
219
+
220
+ latest: deque[Frame] = deque(maxlen=1)
221
+ stack.callback(session.subscribe(latest.append))
222
+ handle = session.run()
223
+ stack.callback(handle.cancel) # a run that has ended stays as it ended
224
+ sent: Frame | None = None
225
+ count = 0
226
+ while handle.state is RunState.running:
227
+ await anyio.sleep(0.1)
228
+ if latest and latest[-1] is not sent:
229
+ sent = latest[-1]
230
+ count += 1
231
+ await ctx.report_progress(count, None, f"{sent.stage}: {sent.label}")
232
+ if handle.state is not RunState.done:
233
+ raise handle.error or Cancelled("the run was cancelled")
234
+ # A newer run that started while this call slept owns the engine's drawing now.
235
+ if session.state is RunState.running or session.result is not handle.result:
236
+ raise Cancelled("the run was cancelled")
237
+ png = session.preview_png(_page_px(session))
238
+ return [json.dumps(asdict(session.stats())), Image(data=png, format="png")]
239
+
240
+ @server.tool()
241
+ async def preview(
242
+ width_px: int = PREVIEW_PX,
243
+ travel: bool = False,
244
+ source: bool = False,
245
+ region_mm: list[float] | None = None,
246
+ ) -> Image:
247
+ """Draw the result at true pen width as a PNG width_px wide, from 100 to 4000. travel adds
248
+ the pen-up moves and source lays the tone map under the paths. region_mm is [x, y, width,
249
+ height] in mm to show only that part of the page, which may reach past it. A picture has
250
+ at most 16 million pixels."""
251
+ with _errors():
252
+ if not MIN_PX <= width_px <= MAX_PX:
253
+ raise ValueError(f"width_px must be from {MIN_PX} to {MAX_PX}, got {width_px}")
254
+ page = _page(session)
255
+ area = [page.width_mm, page.height_mm] if region_mm is None else region_mm[2:]
256
+ # Any other area is refused by the engine, which names the bad value.
257
+ if len(area) == 2 and all(math.isfinite(v) and v > 0 for v in area):
258
+ width, height = area
259
+ if (n := _pixels(width_px, width, height)) > MAX_PIXELS:
260
+ raise ValueError(
261
+ f"a picture {width_px} px wide would have {n} pixels, over the limit of "
262
+ f"{MAX_PIXELS}; use width_px {_widest(width, height, width_px)} or less"
263
+ )
264
+ # The engine refuses a region that is not four numbers.
265
+ region = cast(Any, None if region_mm is None else tuple(region_mm))
266
+ return Image(data=session.preview_png(width_px, travel, source, region), format="png")
267
+
268
+ @server.tool()
269
+ async def get_session() -> dict[str, Any]:
270
+ """Show the style, its settings, the page in mm, the pens, the image size, the state of the
271
+ latest run and the stats of the result, where there is one."""
272
+ with _errors():
273
+ stats = asdict(session.stats()) if session.result is not None else None
274
+ return {
275
+ "style": session.style.name,
276
+ "settings": session.settings,
277
+ "page": asdict(session.canvas),
278
+ "pens": [asdict(pen) for pen in session.pens],
279
+ "image": _image_size(session),
280
+ "state": session.state.value,
281
+ "stats": stats,
282
+ }
283
+
284
+ @server.tool()
285
+ async def load_preset(path: str) -> dict[str, Any]:
286
+ """Load the preset file at the absolute path, which replaces the style, page, pens and
287
+ settings. Settings that the style does not have are dropped and named in the warnings."""
288
+ with _errors(), warnings.catch_warnings(record=True) as caught:
289
+ warnings.simplefilter("always")
290
+ session.load_preset(_absolute(path))
291
+ return {"style": session.style.name, "warnings": [str(w.message) for w in caught]}
292
+
293
+ @server.tool()
294
+ async def export(
295
+ path: str, format: Literal["svg", "json", "png", "preset"], overwrite: bool = False
296
+ ) -> dict[str, Any]:
297
+ """Write the result as svg, json (the paths) or png (a preview), or the settings as a
298
+ preset, to the absolute path. A file that exists is kept unless overwrite is true."""
299
+ with _errors():
300
+ p = _absolute(path)
301
+ if p.exists() and not overwrite:
302
+ raise ValueError(f"{p} exists; pass overwrite=true to replace it")
303
+ if format == "svg":
304
+ session.export_svg(p)
305
+ elif format == "json":
306
+ session.export_json(p)
307
+ elif format == "png":
308
+ p.write_bytes(session.preview_png(_page_px(session)))
309
+ else:
310
+ session.save_preset(p)
311
+ return {"path": str(p), "format": format, "bytes": p.stat().st_size}
312
+
313
+ return server
plotline/py.typed ADDED
File without changes
plotline/registry.py ADDED
@@ -0,0 +1,16 @@
1
+ """The built-in styles, read from the engine's registry."""
2
+
3
+ from plotline import _core
4
+ from plotline.types import Style
5
+
6
+
7
+ def list_styles() -> list[Style]:
8
+ """The styles a user can pick, in the engine's order; hidden test styles are left out."""
9
+ return [Style(**s) for s in _core.list_styles()]
10
+
11
+
12
+ def get_style(name: str) -> Style:
13
+ """The style called ``name``, hidden ones included. Raises ``ValueError`` for an unknown
14
+ name."""
15
+ schema = _core.describe(name)
16
+ return Style(schema["name"], schema["label"], schema["description"])