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/__init__.py +30 -0
- plotline/_core.pyd +0 -0
- plotline/_core.pyi +58 -0
- plotline/cli/__init__.py +0 -0
- plotline/cli/main.py +294 -0
- plotline/mcp_server.py +313 -0
- plotline/py.typed +0 -0
- plotline/registry.py +16 -0
- plotline/session.py +400 -0
- plotline/types.py +130 -0
- plotline/web/THIRD-PARTY-LICENSES.txt +1492 -0
- plotline/web/assets/index-B3NFwj0a.css +1 -0
- plotline/web/assets/index-BcHuSLsL.js +1 -0
- plotline/web/assets/plotline_wasm_bg-BxSp_00A.wasm +0 -0
- plotline/web/assets/worker-DB9SFtUV.js +1 -0
- plotline/web/index.html +15 -0
- plotline/web/lucide-preact-LICENSE.txt +43 -0
- plotline/web/preact-LICENSE.txt +21 -0
- plotline-0.1.0.dev0.dist-info/METADATA +159 -0
- plotline-0.1.0.dev0.dist-info/RECORD +24 -0
- plotline-0.1.0.dev0.dist-info/WHEEL +4 -0
- plotline-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- plotline-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
- plotline-0.1.0.dev0.dist-info/sboms/plotline-py.cyclonedx.json +2984 -0
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"])
|