nontainer 0.1.0__py3-none-any.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.
- nontainer/__init__.py +67 -0
- nontainer/adapters/__init__.py +17 -0
- nontainer/adapters/a2ui.py +438 -0
- nontainer/adapters/agno.py +300 -0
- nontainer/adapters/mcp.py +357 -0
- nontainer/adapters/render.py +726 -0
- nontainer/apps/__init__.py +69 -0
- nontainer/apps/browser.py +172 -0
- nontainer/apps/contract.py +198 -0
- nontainer/apps/curl.py +178 -0
- nontainer/apps/dispatch.py +450 -0
- nontainer/apps/serve.py +159 -0
- nontainer/apps/testapp.py +484 -0
- nontainer/cache.py +124 -0
- nontainer/editing.py +227 -0
- nontainer/errors.py +25 -0
- nontainer/hints.py +44 -0
- nontainer/presets.py +323 -0
- nontainer/protocol.py +198 -0
- nontainer/providers/__init__.py +17 -0
- nontainer/providers/agentfs.py +499 -0
- nontainer/providers/dir.py +170 -0
- nontainer/providers/kvgit.py +206 -0
- nontainer/py.typed +0 -0
- nontainer/skills.py +201 -0
- nontainer/workspace.py +1265 -0
- nontainer-0.1.0.dist-info/METADATA +198 -0
- nontainer-0.1.0.dist-info/RECORD +30 -0
- nontainer-0.1.0.dist-info/WHEEL +4 -0
- nontainer-0.1.0.dist-info/licenses/LICENSE +21 -0
nontainer/__init__.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""nontainer: a fake little computer for your agent.
|
|
2
|
+
|
|
3
|
+
Public surface:
|
|
4
|
+
|
|
5
|
+
workspace(...) -- factory; the one-liner entry point
|
|
6
|
+
Workspace -- files + shell + python + cache, versioned
|
|
7
|
+
PythonConfig -- what sandboxed code may touch
|
|
8
|
+
TerminalResult, PythonResult, WriteOutcome, EditOutcome
|
|
9
|
+
WorkspaceProvider -- the substrate protocol (bring your own)
|
|
10
|
+
Capabilities, CheckpointInfo
|
|
11
|
+
errors: WorkspaceError, NotSupportedError, SessionIdError,
|
|
12
|
+
CheckpointNotFoundError
|
|
13
|
+
|
|
14
|
+
Adapters (optional extras):
|
|
15
|
+
|
|
16
|
+
nontainer.adapters.agno -- WorkspaceTools (agno Toolkit)
|
|
17
|
+
python -m nontainer.mcp -- MCP server (stdio)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from .cache import Cache, CacheError
|
|
21
|
+
from .editing import EditOutcome
|
|
22
|
+
from .errors import (
|
|
23
|
+
CheckpointNotFoundError,
|
|
24
|
+
NotSupportedError,
|
|
25
|
+
SessionIdError,
|
|
26
|
+
WorkspaceError,
|
|
27
|
+
)
|
|
28
|
+
from .protocol import (
|
|
29
|
+
SESSION_ID_RE,
|
|
30
|
+
Capabilities,
|
|
31
|
+
CheckpointInfo,
|
|
32
|
+
WorkspaceProvider,
|
|
33
|
+
validate_session_id,
|
|
34
|
+
)
|
|
35
|
+
from .workspace import (
|
|
36
|
+
ModuleGrant,
|
|
37
|
+
Mount,
|
|
38
|
+
PythonConfig,
|
|
39
|
+
PythonResult,
|
|
40
|
+
TerminalResult,
|
|
41
|
+
Workspace,
|
|
42
|
+
WriteOutcome,
|
|
43
|
+
workspace,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"workspace",
|
|
48
|
+
"Workspace",
|
|
49
|
+
"PythonConfig",
|
|
50
|
+
"Mount",
|
|
51
|
+
"ModuleGrant",
|
|
52
|
+
"TerminalResult",
|
|
53
|
+
"PythonResult",
|
|
54
|
+
"WriteOutcome",
|
|
55
|
+
"EditOutcome",
|
|
56
|
+
"WorkspaceProvider",
|
|
57
|
+
"Capabilities",
|
|
58
|
+
"CheckpointInfo",
|
|
59
|
+
"SESSION_ID_RE",
|
|
60
|
+
"validate_session_id",
|
|
61
|
+
"Cache",
|
|
62
|
+
"CacheError",
|
|
63
|
+
"WorkspaceError",
|
|
64
|
+
"NotSupportedError",
|
|
65
|
+
"SessionIdError",
|
|
66
|
+
"CheckpointNotFoundError",
|
|
67
|
+
]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Harness adapters: thin skins over one Workspace core.
|
|
2
|
+
|
|
3
|
+
- ``nontainer.adapters.agno`` — ``WorkspaceTools`` (agno Toolkit);
|
|
4
|
+
requires the ``[agno]`` extra.
|
|
5
|
+
- ``nontainer.adapters.mcp`` — FastMCP server; requires the ``[mcp]``
|
|
6
|
+
extra. Run via ``python -m nontainer.adapters.mcp``.
|
|
7
|
+
|
|
8
|
+
Shared behavior lives in ``nontainer.adapters.render``: observation
|
|
9
|
+
rendering (never inline ``namespace``; surface truncation), dynamic
|
|
10
|
+
tool descriptions, and the exposure-mode heuristic (``"auto"`` →
|
|
11
|
+
terminal-only when the python environment is plain, split tools when
|
|
12
|
+
it's augmented with cache/host objects).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from .render import resolve_tools_mode
|
|
16
|
+
|
|
17
|
+
__all__ = ["resolve_tools_mode"]
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
"""a2ui projection: a reply (prose + artifacts) → declarative agent-UI.
|
|
2
|
+
|
|
3
|
+
a2ui (Agent-to-User Interface) is adopted as an EGRESS format at the edge,
|
|
4
|
+
never as the internal representation — see ``docs`` and the plan. This
|
|
5
|
+
adapter is split into two layers so the volatile part stays small:
|
|
6
|
+
|
|
7
|
+
- Layer 1 (this file's bulk): SEMANTICS — stable, renderer-agnostic, fully
|
|
8
|
+
tested. ``splice`` is the canonical definition of how a reply interleaves
|
|
9
|
+
with its artifacts; ``component_for`` maps one artifact to a neutral,
|
|
10
|
+
a2ui-shaped component fragment. Both are pure functions: plain dicts and
|
|
11
|
+
tuples in and out, no I/O, no new dependencies (callers pass the artifact
|
|
12
|
+
bytes and a ``file_url`` resolver).
|
|
13
|
+
|
|
14
|
+
- Layer 2 (``turn_to_a2ui`` + ``BASIC_CATALOG``): the version-specific
|
|
15
|
+
ENVELOPE, now PINNED to A2UI v0.9 (the stable production family; v1.0 is
|
|
16
|
+
still a release candidate). It composes layer 1's nested fragments into
|
|
17
|
+
the v0.9 wire sequence — one ``createSurface``, one ``updateComponents``
|
|
18
|
+
carrying a FLAT adjacency list, then one ``updateDataModel`` per data-
|
|
19
|
+
model entry. Every version-specific field name stays isolated here, so
|
|
20
|
+
when the consumer moves to v1.0 only this half of the file churns.
|
|
21
|
+
|
|
22
|
+
Two v0.9 quirks the envelope absorbs so layer 1 stays neutral: (1) the
|
|
23
|
+
component list is flat — containers reference children by id, not by
|
|
24
|
+
nesting — so ``turn_to_a2ui`` flattens each fragment into deterministic
|
|
25
|
+
ids (root ``"root"``, segment roots ``"seg0"``, ``"seg1"``, ..., nested
|
|
26
|
+
children ``"seg3-1"``, ``"seg3-1-value-1"``). (2) the basic-catalog
|
|
27
|
+
``Text`` has no ``role`` prop, so layer 1's Text ``role`` is folded into
|
|
28
|
+
the component id (``"seg3-1-value-1"``) and dropped from the emitted
|
|
29
|
+
component. Layer 1's ``{"$ref": key}`` markers become v0.9 JSON-Pointer
|
|
30
|
+
bindings ``{"path": "/artifacts/{name}/{key}"}`` during flattening, the
|
|
31
|
+
value delivered by ``updateDataModel``. Basic-catalog ``Text`` renders
|
|
32
|
+
Markdown, so ``("md", text)`` segments ship verbatim as Text components.
|
|
33
|
+
|
|
34
|
+
The component vocabulary here is deliberately neutral-but-a2ui-shaped:
|
|
35
|
+
catalog components (``Card``/``Row``/``Column``/``Text``/``Image``) plus a
|
|
36
|
+
single extension type ``Chart`` for plotly specs. A fragment is
|
|
37
|
+
``{"component": <tree>, "data_model": {ref_key: value}}``; components carry
|
|
38
|
+
``{"$ref": key}`` where a value lives in the data model (today: only the
|
|
39
|
+
plotly spec, which is bulky and de-facto-standard — the consumer brings the
|
|
40
|
+
renderer).
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import json
|
|
46
|
+
import re
|
|
47
|
+
from typing import Callable, Union
|
|
48
|
+
|
|
49
|
+
from .render import artifact_kind
|
|
50
|
+
|
|
51
|
+
# ("md", text) | ("artifact", name, path). Matches the two shapes studio's
|
|
52
|
+
# AgentMessage.svelte emits from its own splice.
|
|
53
|
+
Segment = Union[tuple[str, str], tuple[str, str, str]]
|
|
54
|
+
|
|
55
|
+
# The SAME regex studio's AgentMessage.svelte uses to split prose on image
|
|
56
|
+
# refs. The two implementations are kept deliberately in sync: this Python
|
|
57
|
+
# function is the canonical spec (and the a2ui converter's input); the Svelte
|
|
58
|
+
# copy renders it client-side. Change one, change the other.
|
|
59
|
+
_IMAGE_REF = re.compile(r"!\[([^\]]*)\]\((/[^)\s]+)\)")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def splice(prose: str, artifacts: list[tuple[str, str]]) -> list[Segment]:
|
|
63
|
+
"""Canonical interleaving of a reply with its artifacts.
|
|
64
|
+
|
|
65
|
+
Prose is split at markdown image refs ````. A ref splices
|
|
66
|
+
an ``("artifact", name, path)`` segment at its position; runs of text
|
|
67
|
+
between refs become ``("md", text)`` segments (never empty ones). The
|
|
68
|
+
artifact LIST is authoritative for names — a ref whose path is in the
|
|
69
|
+
list uses the note's name, not the prose alt-text (they may differ).
|
|
70
|
+
A ref whose path is NOT in the list still splices (using its alt-text
|
|
71
|
+
as name), so workspace images an agent embeds by hand render too,
|
|
72
|
+
mirroring studio.
|
|
73
|
+
|
|
74
|
+
The Jupyter rule: artifacts from the list whose path was never
|
|
75
|
+
referenced in prose append as trailing ``("artifact", ...)`` segments
|
|
76
|
+
in list order. Each artifact appends at most once even if the list
|
|
77
|
+
names it twice; a path referenced N times in prose splices at each
|
|
78
|
+
reference and is not also appended.
|
|
79
|
+
"""
|
|
80
|
+
# First path wins the name if the note lists a path twice.
|
|
81
|
+
name_by_path: dict[str, str] = {}
|
|
82
|
+
for name, path in artifacts:
|
|
83
|
+
name_by_path.setdefault(path, name)
|
|
84
|
+
|
|
85
|
+
out: list[Segment] = []
|
|
86
|
+
referenced: set[str] = set()
|
|
87
|
+
last = 0
|
|
88
|
+
for m in _IMAGE_REF.finditer(prose):
|
|
89
|
+
if m.start() > last:
|
|
90
|
+
out.append(("md", prose[last : m.start()]))
|
|
91
|
+
alt, path = m.group(1), m.group(2)
|
|
92
|
+
out.append(("artifact", name_by_path.get(path, alt), path))
|
|
93
|
+
referenced.add(path)
|
|
94
|
+
last = m.end()
|
|
95
|
+
if last < len(prose):
|
|
96
|
+
out.append(("md", prose[last:]))
|
|
97
|
+
|
|
98
|
+
appended: set[str] = set()
|
|
99
|
+
for name, path in artifacts:
|
|
100
|
+
if path in referenced or path in appended:
|
|
101
|
+
continue
|
|
102
|
+
out.append(("artifact", name, path))
|
|
103
|
+
appended.add(path)
|
|
104
|
+
return out
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
# component_for: one artifact -> a renderer-agnostic fragment
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def component_for(
|
|
113
|
+
name: str,
|
|
114
|
+
path: str,
|
|
115
|
+
data: bytes | None,
|
|
116
|
+
file_url: Callable[[str], str],
|
|
117
|
+
) -> dict:
|
|
118
|
+
"""Project one artifact into an a2ui fragment.
|
|
119
|
+
|
|
120
|
+
Dispatches on ``artifact_kind(path)``. ``data`` is the artifact's raw
|
|
121
|
+
bytes, or ``None`` when the caller could not read the file — every kind
|
|
122
|
+
that needs the bytes then degrades to the Text+link fallback (``image``
|
|
123
|
+
still works, being URL-only). Malformed JSON where JSON is expected
|
|
124
|
+
degrades the same way; this function never raises.
|
|
125
|
+
|
|
126
|
+
Returns ``{"component": <tree>, "data_model": {...}}``.
|
|
127
|
+
"""
|
|
128
|
+
kind = artifact_kind(path)
|
|
129
|
+
|
|
130
|
+
if kind == "image":
|
|
131
|
+
return _fragment({"componentType": "Image", "url": file_url(path)})
|
|
132
|
+
|
|
133
|
+
# The builders parse agent-writable files (near-miss adoption promotes
|
|
134
|
+
# DIRECT /ui writes into the note), so a malformed payload here is a
|
|
135
|
+
# reachable input, not a programming error. The except makes the
|
|
136
|
+
# never-raises contract structurally true: any builder surprise lands
|
|
137
|
+
# in the fallback instead of breaking an egress stream mid-turn.
|
|
138
|
+
if data is not None:
|
|
139
|
+
try:
|
|
140
|
+
if kind == "cards":
|
|
141
|
+
payload = _try_json(data)
|
|
142
|
+
if isinstance(payload, dict):
|
|
143
|
+
return _cards(payload)
|
|
144
|
+
elif kind == "table":
|
|
145
|
+
payload = _try_json(data)
|
|
146
|
+
if isinstance(payload, dict):
|
|
147
|
+
return _table(payload)
|
|
148
|
+
elif kind == "plotly":
|
|
149
|
+
spec = _try_json(data)
|
|
150
|
+
if isinstance(spec, dict):
|
|
151
|
+
return _chart(spec)
|
|
152
|
+
elif kind == "json":
|
|
153
|
+
# .json undersells itself: agents reach for
|
|
154
|
+
# write_json('/ui/x.json'). Content-sniff a plotly spec
|
|
155
|
+
# (studio's looksLikePlotly, full-parse only since we hold
|
|
156
|
+
# the whole file); otherwise fall to the link.
|
|
157
|
+
spec = _try_json(data)
|
|
158
|
+
if _looks_like_plotly(spec):
|
|
159
|
+
return _chart(spec)
|
|
160
|
+
except Exception:
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
# html/text/json-non-plotly/binary, and every bytes-needing kind that
|
|
164
|
+
# could not parse: a Text label + a link. Never ship raw HTML across a2ui.
|
|
165
|
+
return _fragment(
|
|
166
|
+
{"componentType": "Text", "text": f"artifact: {name}", "link": file_url(path)}
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _fragment(component: dict, data_model: dict | None = None) -> dict:
|
|
171
|
+
return {"component": component, "data_model": data_model or {}}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _text(value: object, role: str) -> dict:
|
|
175
|
+
return {"componentType": "Text", "text": str(value), "role": role}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _cards(payload: dict) -> dict:
|
|
179
|
+
"""The ``.cards.json`` payload ``{"items": [...]}`` → a Row of Cards,
|
|
180
|
+
dispatched per item ``type`` (mirrors studio's CardRow):
|
|
181
|
+
|
|
182
|
+
- ``stat`` → a Card of Text children roled label / value / sublabel
|
|
183
|
+
(sublabel only when present);
|
|
184
|
+
- ``callout`` → a Card carrying a passthrough ``tone`` prop (the
|
|
185
|
+
flattener copies unknown props through), with Text children roled
|
|
186
|
+
title / body (each emitted only when non-empty).
|
|
187
|
+
|
|
188
|
+
Values go inline in the Text — card text is small, so no data-model
|
|
189
|
+
indirection needed. Items with no recognizable type render nothing."""
|
|
190
|
+
items = payload.get("items")
|
|
191
|
+
cards = []
|
|
192
|
+
for item in items if isinstance(items, list) else []:
|
|
193
|
+
if not isinstance(item, dict):
|
|
194
|
+
continue
|
|
195
|
+
kind = item.get("type")
|
|
196
|
+
if kind == "stat":
|
|
197
|
+
children = [
|
|
198
|
+
_text(item.get("label", ""), "label"),
|
|
199
|
+
_text(item.get("value", ""), "value"),
|
|
200
|
+
]
|
|
201
|
+
if "sublabel" in item:
|
|
202
|
+
children.append(_text(item["sublabel"], "sublabel"))
|
|
203
|
+
cards.append({"componentType": "Card", "children": children})
|
|
204
|
+
elif kind == "callout":
|
|
205
|
+
children = []
|
|
206
|
+
if item.get("title"):
|
|
207
|
+
children.append(_text(item["title"], "title"))
|
|
208
|
+
if item.get("body"):
|
|
209
|
+
children.append(_text(item["body"], "body"))
|
|
210
|
+
cards.append(
|
|
211
|
+
{
|
|
212
|
+
"componentType": "Card",
|
|
213
|
+
"tone": item.get("tone", "info"),
|
|
214
|
+
"children": children,
|
|
215
|
+
}
|
|
216
|
+
)
|
|
217
|
+
# unrecognized type: skip, never raise
|
|
218
|
+
return _fragment({"componentType": "Row", "children": cards})
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
_TABLE_ROW_CAP = 50
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _table(payload: dict) -> dict:
|
|
225
|
+
"""The ``.table.json`` split-orient payload ``{"columns": [...], "data":
|
|
226
|
+
[[...]], "total": N}`` → a Column: a header Row of Text, then up to 50
|
|
227
|
+
data Rows. When ``total`` exceeds the rendered count, a trailing Text
|
|
228
|
+
notes ``showing N of M rows`` (the payload is already head-capped
|
|
229
|
+
upstream, so a big table degrades gracefully)."""
|
|
230
|
+
# both guarded the same way: these fields come from agent-writable
|
|
231
|
+
# files, and a truthy non-list ({"columns": 5}) must degrade, not raise
|
|
232
|
+
columns = payload.get("columns")
|
|
233
|
+
columns = columns if isinstance(columns, list) else []
|
|
234
|
+
rows = payload.get("data")
|
|
235
|
+
rows = rows if isinstance(rows, list) else []
|
|
236
|
+
total = payload.get("total")
|
|
237
|
+
|
|
238
|
+
children = [
|
|
239
|
+
{
|
|
240
|
+
"componentType": "Row",
|
|
241
|
+
"children": [_text(c, "header") for c in columns],
|
|
242
|
+
}
|
|
243
|
+
]
|
|
244
|
+
rendered = rows[:_TABLE_ROW_CAP]
|
|
245
|
+
for row in rendered:
|
|
246
|
+
cells = row if isinstance(row, list) else [row]
|
|
247
|
+
children.append(
|
|
248
|
+
{
|
|
249
|
+
"componentType": "Row",
|
|
250
|
+
"children": [_text(cell, "cell") for cell in cells],
|
|
251
|
+
}
|
|
252
|
+
)
|
|
253
|
+
if isinstance(total, int) and total > len(rendered):
|
|
254
|
+
children.append(_text(f"showing {len(rendered)} of {total} rows", "caption"))
|
|
255
|
+
return _fragment({"componentType": "Column", "children": children})
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _chart(spec: dict) -> dict:
|
|
259
|
+
"""Plotly spec → the one extension component. The spec is bulky and the
|
|
260
|
+
de-facto standard, so it rides in the data model and the component
|
|
261
|
+
references it — the consumer brings a plotly renderer."""
|
|
262
|
+
return _fragment(
|
|
263
|
+
{"componentType": "Chart", "spec": {"$ref": "spec"}},
|
|
264
|
+
{"spec": spec},
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _try_json(data: bytes) -> object | None:
|
|
269
|
+
try:
|
|
270
|
+
return json.loads(data)
|
|
271
|
+
except (ValueError, TypeError):
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _looks_like_plotly(obj: object) -> bool:
|
|
276
|
+
"""Port of studio's ``looksLikePlotly`` (frontend/src/lib/sniff.js),
|
|
277
|
+
full-parse branch only: a top-level dict with a ``data`` list and a
|
|
278
|
+
``layout`` dict."""
|
|
279
|
+
return (
|
|
280
|
+
isinstance(obj, dict)
|
|
281
|
+
and isinstance(obj.get("data"), list)
|
|
282
|
+
and isinstance(obj.get("layout"), dict)
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ---------------------------------------------------------------------------
|
|
287
|
+
# Layer 2: the A2UI v0.9 envelope
|
|
288
|
+
# ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
# The v0.9 basic-catalog URL. createSurface must name a catalog; consumers
|
|
291
|
+
# with a Chart-capable custom catalog pass their own catalog_id instead.
|
|
292
|
+
BASIC_CATALOG = "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json"
|
|
293
|
+
|
|
294
|
+
_VERSION = "v0.9"
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def turn_to_a2ui(
|
|
298
|
+
prose: str,
|
|
299
|
+
artifacts: list[tuple[str, str]],
|
|
300
|
+
read_bytes: Callable[[str], bytes | None],
|
|
301
|
+
file_url: Callable[[str], str],
|
|
302
|
+
*,
|
|
303
|
+
surface_id: str,
|
|
304
|
+
catalog_id: str = BASIC_CATALOG,
|
|
305
|
+
) -> list[dict]:
|
|
306
|
+
"""Compose one reply (prose + artifacts) into an A2UI v0.9 message list.
|
|
307
|
+
|
|
308
|
+
``artifacts`` is the ``parse_artifacts_note`` output — ``(name, path)``
|
|
309
|
+
pairs. ``read_bytes(path) -> bytes | None`` fetches an artifact's payload
|
|
310
|
+
so the envelope owns no I/O policy (callers do reads and their own
|
|
311
|
+
caching; ``None`` means unreadable, which degrades inside
|
|
312
|
+
``component_for``). ``file_url(path)`` resolves an artifact's public URL.
|
|
313
|
+
|
|
314
|
+
Pipeline: ``splice`` interleaves prose and artifacts into ordered
|
|
315
|
+
segments; each ``("artifact", ...)`` segment is projected by
|
|
316
|
+
``component_for``; every nested fragment is FLATTENED into a v0.9
|
|
317
|
+
adjacency list with deterministic ids. ``("md", text)`` segments become
|
|
318
|
+
Markdown ``Text`` components verbatim (basic-catalog Text renders
|
|
319
|
+
Markdown, so no conversion).
|
|
320
|
+
|
|
321
|
+
Emits, in order, each carrying ``"version": "v0.9"``:
|
|
322
|
+
|
|
323
|
+
1. one ``createSurface`` (``surfaceId``, ``catalogId``);
|
|
324
|
+
2. one ``updateComponents`` with the full flat component list, root
|
|
325
|
+
``Column`` (id ``"root"``) FIRST so a buffering consumer can start
|
|
326
|
+
rendering before the tail arrives;
|
|
327
|
+
3. one ``updateDataModel`` per data-model entry, keyed by JSON Pointer
|
|
328
|
+
``/artifacts/{name}/{key}`` — the same path the flattened components
|
|
329
|
+
bind to (layer 1's ``{"$ref": key}`` markers are rewritten to
|
|
330
|
+
``{"path": ...}`` bindings during flattening).
|
|
331
|
+
|
|
332
|
+
Ids are stable across identical inputs and unique: ``"root"``, segment
|
|
333
|
+
roots ``"seg{i}"`` (i = splice index), nested children ``"{parent}-{n}"``
|
|
334
|
+
or, for a Text carrying a layer-1 ``role``, ``"{parent}-{role}-{k}"``
|
|
335
|
+
(the ``role`` prop is then dropped — v0.9 basic Text has no such prop).
|
|
336
|
+
|
|
337
|
+
Empty prose with no artifacts still yields a VALID surface: a
|
|
338
|
+
``createSurface`` plus an ``updateComponents`` whose root ``Column`` has
|
|
339
|
+
no children (no ``updateDataModel``). Never raises on any splice /
|
|
340
|
+
``component_for`` output; a ``read_bytes`` that itself raises is treated
|
|
341
|
+
as an unreadable artifact.
|
|
342
|
+
"""
|
|
343
|
+
components: list[dict] = [{"id": "root", "component": "Column", "children": []}]
|
|
344
|
+
root_children: list[str] = components[0]["children"]
|
|
345
|
+
data_entries: list[tuple[str, object]] = []
|
|
346
|
+
|
|
347
|
+
for i, seg in enumerate(splice(prose, artifacts)):
|
|
348
|
+
seg_id = f"seg{i}"
|
|
349
|
+
root_children.append(seg_id)
|
|
350
|
+
if seg[0] == "md":
|
|
351
|
+
components.append({"id": seg_id, "component": "Text", "text": seg[1]})
|
|
352
|
+
continue
|
|
353
|
+
_, name, path = seg
|
|
354
|
+
try:
|
|
355
|
+
data = read_bytes(path)
|
|
356
|
+
except Exception:
|
|
357
|
+
# read_bytes is the caller's I/O; the envelope stays total.
|
|
358
|
+
data = None
|
|
359
|
+
frag = component_for(name, path, data, file_url)
|
|
360
|
+
_flatten(frag.get("component") or {}, seg_id, name, components)
|
|
361
|
+
for key, value in (frag.get("data_model") or {}).items():
|
|
362
|
+
data_entries.append((f"/artifacts/{name}/{key}", value))
|
|
363
|
+
|
|
364
|
+
messages: list[dict] = [
|
|
365
|
+
{
|
|
366
|
+
"version": _VERSION,
|
|
367
|
+
"createSurface": {"surfaceId": surface_id, "catalogId": catalog_id},
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
"version": _VERSION,
|
|
371
|
+
"updateComponents": {"surfaceId": surface_id, "components": components},
|
|
372
|
+
},
|
|
373
|
+
]
|
|
374
|
+
for path, value in data_entries:
|
|
375
|
+
messages.append(
|
|
376
|
+
{
|
|
377
|
+
"version": _VERSION,
|
|
378
|
+
"updateDataModel": {
|
|
379
|
+
"surfaceId": surface_id,
|
|
380
|
+
"path": path,
|
|
381
|
+
"value": value,
|
|
382
|
+
},
|
|
383
|
+
}
|
|
384
|
+
)
|
|
385
|
+
return messages
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _flatten(node: dict, node_id: str, artifact: str, out: list[dict]) -> None:
|
|
389
|
+
"""Append ``node`` and its subtree to ``out`` as flat v0.9 components.
|
|
390
|
+
|
|
391
|
+
Pre-order (parent before children) so the list stays buffering-friendly.
|
|
392
|
+
``componentType`` becomes ``component``; ``children`` become a list of
|
|
393
|
+
generated child ids; ``role`` is dropped (folded into the child id by
|
|
394
|
+
``_child_id``); a ``{"$ref": key}`` prop value is rewritten to a
|
|
395
|
+
``/artifacts/{artifact}/{key}`` JSON-Pointer binding.
|
|
396
|
+
"""
|
|
397
|
+
flat: dict = {"id": node_id, "component": node.get("componentType")}
|
|
398
|
+
for key, val in node.items():
|
|
399
|
+
if key in ("componentType", "children", "role"):
|
|
400
|
+
continue
|
|
401
|
+
flat[key] = _bind(val, artifact)
|
|
402
|
+
|
|
403
|
+
children = node.get("children")
|
|
404
|
+
child_pairs: list[tuple[dict, str]] = []
|
|
405
|
+
if isinstance(children, list):
|
|
406
|
+
seen: dict[str, int] = {}
|
|
407
|
+
ids: list[str] = []
|
|
408
|
+
for idx, child in enumerate(children, start=1):
|
|
409
|
+
cid = _child_id(node_id, idx, child, seen)
|
|
410
|
+
ids.append(cid)
|
|
411
|
+
child_pairs.append((child, cid))
|
|
412
|
+
flat["children"] = ids
|
|
413
|
+
|
|
414
|
+
out.append(flat)
|
|
415
|
+
for child, cid in child_pairs:
|
|
416
|
+
_flatten(child, cid, artifact, out)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _child_id(parent_id: str, index: int, child: object, seen: dict[str, int]) -> str:
|
|
420
|
+
"""A stable child id. A Text with a ``role`` folds the role in (v0.9 Text
|
|
421
|
+
drops the prop), disambiguated by per-role occurrence so a header Row's
|
|
422
|
+
several ``role: header`` cells stay unique; everything else uses its
|
|
423
|
+
1-based sibling position. Roles are words, positions are numbers, so the
|
|
424
|
+
two schemes never collide even inside a mixed container (a table Column
|
|
425
|
+
holds numbered Rows plus a ``caption`` Text)."""
|
|
426
|
+
role = child.get("role") if isinstance(child, dict) else None
|
|
427
|
+
if role:
|
|
428
|
+
seen[role] = seen.get(role, 0) + 1
|
|
429
|
+
return f"{parent_id}-{role}-{seen[role]}"
|
|
430
|
+
return f"{parent_id}-{index}"
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _bind(val: object, artifact: str) -> object:
|
|
434
|
+
"""Rewrite a layer-1 ``{"$ref": key}`` marker into a v0.9 JSON-Pointer
|
|
435
|
+
binding; pass everything else through untouched."""
|
|
436
|
+
if isinstance(val, dict) and set(val) == {"$ref"}:
|
|
437
|
+
return {"path": f"/artifacts/{artifact}/{val['$ref']}"}
|
|
438
|
+
return val
|