forgeui 0.1.0a2__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.
Files changed (67) hide show
  1. forgeui/__init__.py +10 -0
  2. forgeui/a2ui/README.md +68 -0
  3. forgeui/a2ui/__init__.py +36 -0
  4. forgeui/a2ui/adapter.py +486 -0
  5. forgeui/a2ui/errors.py +54 -0
  6. forgeui/a2ui/models.py +116 -0
  7. forgeui/app.py +465 -0
  8. forgeui/catalog/__init__.py +5 -0
  9. forgeui/catalog/registry.py +552 -0
  10. forgeui/cli.py +38 -0
  11. forgeui/config.py +120 -0
  12. forgeui/container.py +135 -0
  13. forgeui/data/__init__.py +6 -0
  14. forgeui/data/database.py +103 -0
  15. forgeui/data/models.py +175 -0
  16. forgeui/data/repositories.py +444 -0
  17. forgeui/domain/__init__.py +23 -0
  18. forgeui/domain/device_health.py +213 -0
  19. forgeui/domain/models.py +217 -0
  20. forgeui/expressions/__init__.py +14 -0
  21. forgeui/expressions/ast.py +126 -0
  22. forgeui/expressions/evaluator.py +231 -0
  23. forgeui/icons.py +126 -0
  24. forgeui/llm/__init__.py +51 -0
  25. forgeui/llm/fake.py +31 -0
  26. forgeui/llm/generation.py +218 -0
  27. forgeui/llm/ollama.py +118 -0
  28. forgeui/llm/prompting.py +113 -0
  29. forgeui/llm/types.py +103 -0
  30. forgeui/logging.py +42 -0
  31. forgeui/observability.py +62 -0
  32. forgeui/py.typed +1 -0
  33. forgeui/renderer/__init__.py +12 -0
  34. forgeui/renderer/renderer.py +839 -0
  35. forgeui/runtime.py +114 -0
  36. forgeui/security.py +57 -0
  37. forgeui/services/__init__.py +40 -0
  38. forgeui/services/actions.py +413 -0
  39. forgeui/services/apps.py +207 -0
  40. forgeui/services/audit.py +79 -0
  41. forgeui/services/capabilities.py +159 -0
  42. forgeui/services/devices.py +166 -0
  43. forgeui/services/exceptions.py +27 -0
  44. forgeui/services/jobs.py +170 -0
  45. forgeui/services/state.py +131 -0
  46. forgeui/sources/__init__.py +29 -0
  47. forgeui/sources/http.py +122 -0
  48. forgeui/sources/registry.py +358 -0
  49. forgeui/surfaces.py +64 -0
  50. forgeui/validation/__init__.py +30 -0
  51. forgeui/validation/parser.py +82 -0
  52. forgeui/validation/repair.py +68 -0
  53. forgeui/validation/validator.py +653 -0
  54. forgeui/web/__init__.py +5 -0
  55. forgeui/web/router.py +1083 -0
  56. forgeui/web/static/favicon.svg +4 -0
  57. forgeui/web/static/forgeui-embed.js +38 -0
  58. forgeui/web/static/forgeui.css +1780 -0
  59. forgeui/web/static/forgeui.js +300 -0
  60. forgeui/web/templates/base.html +53 -0
  61. forgeui/web/templates/components/_component.html +88 -0
  62. forgeui-0.1.0a2.dist-info/METADATA +277 -0
  63. forgeui-0.1.0a2.dist-info/RECORD +67 -0
  64. forgeui-0.1.0a2.dist-info/WHEEL +4 -0
  65. forgeui-0.1.0a2.dist-info/entry_points.txt +2 -0
  66. forgeui-0.1.0a2.dist-info/licenses/LICENSE +21 -0
  67. forgeui-0.1.0a2.dist-info/licenses/THIRD_PARTY_NOTICES.md +25 -0
forgeui/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """ForgeUI public package."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("forgeui")
7
+ except PackageNotFoundError: # pragma: no cover - editable source checkout
8
+ __version__ = "0.1.0a2"
9
+
10
+ __all__ = ["__version__"]
forgeui/a2ui/README.md ADDED
@@ -0,0 +1,68 @@
1
+ # ForgeUI A2UI import boundary
2
+
3
+ This package imports one deliberately small snapshot subset of the closed,
4
+ current-production Google A2UI v0.9.1 protocol. It is not a streaming renderer
5
+ and does not claim general A2UI conformance.
6
+
7
+ The implementation is pinned to:
8
+
9
+ - protocol version `v0.9.1`;
10
+ - Google repository commit
11
+ `d4723f29254520e1214d5004cb555d83eaafb828`;
12
+ - MIME type `application/a2ui+json`;
13
+ - server schema URL
14
+ `https://a2ui.org/specification/v0_9_1/server_to_client.json`;
15
+ - the official basic catalog identifier
16
+ `https://a2ui.org/specification/v0_9_1/catalogs/basic/catalog.json`.
17
+
18
+ Primary sources:
19
+
20
+ - [published v0.9.1 server schema](https://a2ui.org/specification/v0_9_1/server_to_client.json)
21
+ - [published v0.9.1 basic catalog](https://a2ui.org/specification/v0_9_1/catalogs/basic/catalog.json)
22
+ - [v0.9.1 status and documentation](https://github.com/google/A2UI/tree/d4723f29254520e1214d5004cb555d83eaafb828/specification/v0_9_1)
23
+ - [server-to-client schema](https://github.com/google/A2UI/blob/d4723f29254520e1214d5004cb555d83eaafb828/specification/v0_9_1/json/server_to_client.json)
24
+ - [basic catalog schema](https://github.com/google/A2UI/blob/d4723f29254520e1214d5004cb555d83eaafb828/specification/v0_9_1/catalogs/basic/catalog.json)
25
+ - [protocol text](https://github.com/google/A2UI/blob/d4723f29254520e1214d5004cb555d83eaafb828/specification/v0_9_1/docs/a2ui_protocol.md)
26
+ - [v0.9 to v0.9.1 evolution guide](https://github.com/google/A2UI/blob/d4723f29254520e1214d5004cb555d83eaafb828/specification/v0_9_1/docs/evolution_guide.md)
27
+
28
+ The upstream v0.9.1 message schema deliberately accepts both `v0.9` and
29
+ `v0.9.1` for wire compatibility, and the schema files retain some v0.9 `$id`
30
+ values. ForgeUI is stricter: this boundary accepts only `v0.9.1` envelopes and
31
+ the v0.9.1 catalog URL used by the current protocol documentation.
32
+ Although v0.9.1 relaxes the protocol-wide `surfaceId` requirement, the importer
33
+ retains ForgeUI's lowercase, bounded identifier grammar at this trust boundary.
34
+
35
+ ## Accepted snapshot
36
+
37
+ The first message must be one `createSurface`. It may be followed by one or
38
+ more `updateComponents` batches whose IDs are globally unique and at most one
39
+ root `updateDataModel` containing a complete `device-health/1` snapshot.
40
+
41
+ The fixed component mapping is:
42
+
43
+ | A2UI v0.9.1 basic component | ForgeUI component |
44
+ | --- | --- |
45
+ | `Column` | `stack` |
46
+ | `Row` | `inline` |
47
+ | `Card` | `card` |
48
+ | `Text` (`h1`–`h4`, `body`, `caption`) | `heading` or `text` |
49
+ | `Divider` (horizontal only) | `divider` |
50
+ | `Icon` (`check`, `error`, `search`, `warning`) | fixed ForgeUI icon |
51
+
52
+ Only literal text and fixed device-health summary bindings are accepted.
53
+ The translated manifest always uses the `ops-compact` design profile,
54
+ `system` color mode, the `device-health/1` data contract, empty server state,
55
+ and no actions. It is passed through ForgeUI's authoritative
56
+ `validate_manifest` boundary before being returned.
57
+
58
+ ## Deliberate exclusions
59
+
60
+ The importer rejects custom catalogs, themes, data-model patches, repeated
61
+ component upserts, dynamic child templates, accessibility overrides, layout
62
+ weights, A2UI functions, inputs, actions, media/URLs, HTML, CSS, scripts,
63
+ unknown properties, deletion messages, multiple surfaces, and all protocol
64
+ versions other than the pinned `v0.9.1`.
65
+
66
+ Streams are bounded to 256 KiB, 32 messages, 80 elements, 12 children per
67
+ container, and the ForgeUI graph depth of 12. JSONL parsing also rejects
68
+ duplicate object keys.
@@ -0,0 +1,36 @@
1
+ """Narrow Google A2UI v0.9.1 snapshot import support.
2
+
3
+ This package does not claim general A2UI conformance.
4
+ """
5
+
6
+ from forgeui.a2ui.adapter import A2UIAdaptation, adapt_a2ui_jsonl, adapt_a2ui_messages
7
+ from forgeui.a2ui.errors import (
8
+ A2UIAdapterError,
9
+ A2UIManifestValidationError,
10
+ InvalidA2UIMessageError,
11
+ UnsupportedA2UIFeatureError,
12
+ UnsupportedA2UIVersionError,
13
+ )
14
+ from forgeui.a2ui.models import (
15
+ A2UI_BASIC_CATALOG_ID,
16
+ A2UI_MIME_TYPE,
17
+ A2UI_SERVER_SCHEMA_URL,
18
+ A2UI_SPEC_COMMIT,
19
+ A2UI_VERSION,
20
+ )
21
+
22
+ __all__ = [
23
+ "A2UI_BASIC_CATALOG_ID",
24
+ "A2UI_MIME_TYPE",
25
+ "A2UI_SERVER_SCHEMA_URL",
26
+ "A2UI_SPEC_COMMIT",
27
+ "A2UI_VERSION",
28
+ "A2UIAdaptation",
29
+ "A2UIAdapterError",
30
+ "A2UIManifestValidationError",
31
+ "InvalidA2UIMessageError",
32
+ "UnsupportedA2UIFeatureError",
33
+ "UnsupportedA2UIVersionError",
34
+ "adapt_a2ui_jsonl",
35
+ "adapt_a2ui_messages",
36
+ ]
@@ -0,0 +1,486 @@
1
+ """Import a safe snapshot subset of Google A2UI v0.9.1 into ``forgeui/1``.
2
+
3
+ This module is an import gateway, not a general A2UI renderer. It deliberately
4
+ does not implement progressive updates, interaction return channels, custom
5
+ catalogs, functions, templates, or arbitrary actions.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from collections.abc import Iterable, Mapping, Sequence
12
+ from dataclasses import dataclass
13
+ from itertools import islice
14
+ from typing import TypeVar, cast
15
+
16
+ from pydantic import BaseModel, ValidationError
17
+
18
+ from forgeui.a2ui.errors import (
19
+ A2UIAdapterError,
20
+ A2UIManifestValidationError,
21
+ InvalidA2UIMessageError,
22
+ UnsupportedA2UIFeatureError,
23
+ UnsupportedA2UIVersionError,
24
+ )
25
+ from forgeui.a2ui.models import (
26
+ A2UI_BASIC_CATALOG_ID,
27
+ A2UI_VERSION,
28
+ MAX_A2UI_BYTES,
29
+ MAX_A2UI_MESSAGES,
30
+ SUPPORTED_COMPONENT_MODELS,
31
+ A2UICard,
32
+ A2UIColumn,
33
+ A2UICreateSurface,
34
+ A2UIDataBinding,
35
+ A2UIDivider,
36
+ A2UIIcon,
37
+ A2UIRow,
38
+ A2UIText,
39
+ A2UIUpdateComponents,
40
+ A2UIUpdateDataModel,
41
+ SupportedA2UIComponent,
42
+ )
43
+ from forgeui.domain.device_health import DeviceHealthSnapshot
44
+ from forgeui.domain.models import ForgeManifest
45
+ from forgeui.validation import validate_manifest
46
+
47
+ _ModelT = TypeVar("_ModelT", bound=BaseModel)
48
+
49
+ _DATA_BINDINGS = {
50
+ "/generated_at": "data.generated_at",
51
+ "/stale": "data.stale",
52
+ "/summary/total": "data.summary.total",
53
+ "/summary/healthy": "data.summary.healthy",
54
+ "/summary/warning": "data.summary.warning",
55
+ "/summary/critical": "data.summary.critical",
56
+ "/summary/offline": "data.summary.offline",
57
+ "/summary/fleet_cpu": "data.summary.fleet_cpu",
58
+ "/summary/fleet_memory": "data.summary.fleet_memory",
59
+ "/summary/fleet_disk": "data.summary.fleet_disk",
60
+ }
61
+
62
+ _ICON_NAMES = {
63
+ "check": "check",
64
+ "error": "alert",
65
+ "search": "search",
66
+ "warning": "warning",
67
+ }
68
+
69
+ _ACTIVE_CONTENT_FRAGMENTS = (
70
+ "://",
71
+ "javascript:",
72
+ "vbscript:",
73
+ "data:",
74
+ "mailto:",
75
+ "tel:",
76
+ "file:",
77
+ "blob:",
78
+ "url(",
79
+ "@import",
80
+ "expression(",
81
+ "var(--",
82
+ "<script",
83
+ "</",
84
+ "onerror=",
85
+ "onload=",
86
+ "](",
87
+ )
88
+
89
+
90
+ @dataclass(frozen=True, slots=True)
91
+ class A2UIAdaptation:
92
+ """A validated ForgeUI snapshot and optional validated device data."""
93
+
94
+ surface_id: str
95
+ manifest: ForgeManifest
96
+ data_model: DeviceHealthSnapshot | None
97
+
98
+
99
+ def _raise_schema_error(
100
+ error: ValidationError,
101
+ *,
102
+ code: str,
103
+ path: str,
104
+ ) -> InvalidA2UIMessageError:
105
+ detail = error.errors(include_url=False, include_input=False)[0]
106
+ suffix = ".".join(str(part) for part in detail["loc"])
107
+ issue_path = f"{path}.{suffix}" if suffix else path
108
+ return InvalidA2UIMessageError(code, str(detail["msg"]), path=issue_path)
109
+
110
+
111
+ def _validate_model(
112
+ model: type[_ModelT],
113
+ value: object,
114
+ *,
115
+ code: str,
116
+ path: str,
117
+ ) -> _ModelT:
118
+ try:
119
+ return model.model_validate(value, strict=True)
120
+ except ValidationError as exc:
121
+ raise _raise_schema_error(exc, code=code, path=path) from exc
122
+
123
+
124
+ def _scan_active_content(value: object, path: str = "$") -> None:
125
+ """Reject active-content shaped strings even when they would only render as text."""
126
+
127
+ if isinstance(value, str):
128
+ if value == A2UI_BASIC_CATALOG_ID:
129
+ return
130
+ lowered = value.casefold()
131
+ if (
132
+ any(fragment in lowered for fragment in _ACTIVE_CONTENT_FRAGMENTS)
133
+ or any(character in value for character in "<>{}")
134
+ or value.startswith("//")
135
+ or (value.startswith("/") and not path.endswith(".path") and value != "/")
136
+ or lowered.startswith("www.")
137
+ ):
138
+ raise InvalidA2UIMessageError(
139
+ "active_content",
140
+ "URLs, HTML, CSS, scripts, and markup-shaped content are not importable",
141
+ path=path,
142
+ )
143
+ return
144
+ if isinstance(value, Mapping):
145
+ for key, child in value.items():
146
+ _scan_active_content(child, f"{path}.{key}")
147
+ return
148
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
149
+ for index, child in enumerate(value):
150
+ _scan_active_content(child, f"{path}[{index}]")
151
+
152
+
153
+ def _parse_component(raw: object, *, path: str) -> SupportedA2UIComponent:
154
+ if not isinstance(raw, Mapping):
155
+ raise InvalidA2UIMessageError("invalid_component", "component must be an object", path=path)
156
+ component_name = raw.get("component")
157
+ if not isinstance(component_name, str):
158
+ raise InvalidA2UIMessageError(
159
+ "invalid_component",
160
+ "component must have a string component discriminator",
161
+ path=f"{path}.component",
162
+ )
163
+ model = SUPPORTED_COMPONENT_MODELS.get(component_name)
164
+ if model is None:
165
+ raise UnsupportedA2UIFeatureError(
166
+ "unsupported_component",
167
+ f"{component_name!r} is outside the fixed ForgeUI A2UI allowlist",
168
+ path=f"{path}.component",
169
+ )
170
+ parsed = _validate_model(model, raw, code="invalid_component", path=path)
171
+ return cast(SupportedA2UIComponent, parsed)
172
+
173
+
174
+ def _translate_text(value: str | A2UIDataBinding, *, path: str) -> object:
175
+ if isinstance(value, str):
176
+ return value
177
+ target = _DATA_BINDINGS.get(value.path)
178
+ if target is None:
179
+ raise UnsupportedA2UIFeatureError(
180
+ "unsupported_data_binding",
181
+ "only fixed device-health summary bindings are importable",
182
+ path=path,
183
+ )
184
+ return {"kind": "ref", "path": target}
185
+
186
+
187
+ def _translate_component(component: SupportedA2UIComponent) -> dict[str, object]:
188
+ if isinstance(component, A2UIText):
189
+ text = _translate_text(component.text, path=f"$.components.{component.id}.text")
190
+ if component.variant in {"h1", "h2", "h3", "h4"}:
191
+ return {
192
+ "type": "heading",
193
+ "props": {"text": text, "level": int(component.variant[1])},
194
+ }
195
+ tone = "muted" if component.variant == "caption" else "default"
196
+ return {"type": "text", "props": {"text": text, "tone": tone}}
197
+ if isinstance(component, A2UIColumn):
198
+ return {
199
+ "type": "stack",
200
+ "props": {"gap": "md", "align": component.align},
201
+ "children": component.children,
202
+ }
203
+ if isinstance(component, A2UIRow):
204
+ return {
205
+ "type": "inline",
206
+ "props": {"gap": "md", "align": component.align, "wrap": True},
207
+ "children": component.children,
208
+ }
209
+ if isinstance(component, A2UICard):
210
+ return {
211
+ "type": "card",
212
+ "props": {"tone": "default"},
213
+ "children": [component.child],
214
+ }
215
+ if isinstance(component, A2UIDivider):
216
+ return {"type": "divider", "props": {}}
217
+ if isinstance(component, A2UIIcon):
218
+ return {
219
+ "type": "icon",
220
+ "props": {"name": _ICON_NAMES[component.name]},
221
+ }
222
+ raise AssertionError(f"unhandled component model: {type(component).__name__}")
223
+
224
+
225
+ def _encoded_size(messages: Sequence[Mapping[str, object]]) -> int:
226
+ try:
227
+ return sum(
228
+ len(json.dumps(message, ensure_ascii=False, separators=(",", ":")).encode("utf-8")) + 1
229
+ for message in messages
230
+ )
231
+ except (TypeError, ValueError) as exc:
232
+ raise InvalidA2UIMessageError("non_json", str(exc)) from exc
233
+
234
+
235
+ def _materialize_messages(
236
+ messages: Iterable[Mapping[str, object]],
237
+ ) -> list[Mapping[str, object]]:
238
+ materialized = list(islice(messages, MAX_A2UI_MESSAGES + 1))
239
+ if not materialized:
240
+ raise InvalidA2UIMessageError("empty_stream", "at least one A2UI message is required")
241
+ if len(materialized) > MAX_A2UI_MESSAGES:
242
+ raise InvalidA2UIMessageError(
243
+ "message_limit",
244
+ f"message count exceeds {MAX_A2UI_MESSAGES}",
245
+ )
246
+ for index, message in enumerate(materialized):
247
+ if not isinstance(message, Mapping):
248
+ raise InvalidA2UIMessageError(
249
+ "invalid_message",
250
+ "each message must be an object",
251
+ path=f"$[{index}]",
252
+ )
253
+ if _encoded_size(materialized) > MAX_A2UI_BYTES:
254
+ raise InvalidA2UIMessageError(
255
+ "byte_limit",
256
+ f"message stream exceeds {MAX_A2UI_BYTES} bytes",
257
+ )
258
+ return materialized
259
+
260
+
261
+ def _message_operation(message: Mapping[str, object], *, index: int) -> str:
262
+ path = f"$[{index}]"
263
+ if "version" not in message:
264
+ raise InvalidA2UIMessageError(
265
+ "missing_version", "every message must declare version", path=path
266
+ )
267
+ if message["version"] != A2UI_VERSION:
268
+ raise UnsupportedA2UIVersionError(message["version"])
269
+ operation_keys = set(message) - {"version"}
270
+ if len(operation_keys) != 1:
271
+ raise InvalidA2UIMessageError(
272
+ "invalid_envelope",
273
+ "message must contain exactly one operation",
274
+ path=path,
275
+ )
276
+ operation = operation_keys.pop()
277
+ if operation not in {"createSurface", "updateComponents", "updateDataModel"}:
278
+ raise UnsupportedA2UIFeatureError(
279
+ "unsupported_message",
280
+ f"{operation!r} is outside the snapshot import subset",
281
+ path=path,
282
+ )
283
+ return operation
284
+
285
+
286
+ def adapt_a2ui_messages(
287
+ messages: Iterable[Mapping[str, object]],
288
+ ) -> A2UIAdaptation:
289
+ """Translate one bounded v0.9.1 snapshot, then call ``validate_manifest``.
290
+
291
+ The stream must start with one ``createSurface`` and may then contain
292
+ ``updateComponents`` batches with globally unique component IDs plus at
293
+ most one root ``updateDataModel`` containing ``device-health/1`` data.
294
+ """
295
+
296
+ materialized = _materialize_messages(messages)
297
+ components: dict[str, SupportedA2UIComponent] = {}
298
+ surface_id: str | None = None
299
+ data_model: DeviceHealthSnapshot | None = None
300
+
301
+ for index, message in enumerate(materialized):
302
+ _scan_active_content(message, f"$[{index}]")
303
+ operation = _message_operation(message, index=index)
304
+ payload = message[operation]
305
+ path = f"$[{index}].{operation}"
306
+
307
+ if operation == "createSurface":
308
+ if index != 0 or surface_id is not None:
309
+ raise InvalidA2UIMessageError(
310
+ "surface_order",
311
+ "exactly one createSurface must be the first message",
312
+ path=path,
313
+ )
314
+ create = _validate_model(
315
+ A2UICreateSurface,
316
+ payload,
317
+ code="invalid_create_surface",
318
+ path=path,
319
+ )
320
+ surface_id = create.surface_id
321
+ continue
322
+
323
+ if surface_id is None:
324
+ raise InvalidA2UIMessageError(
325
+ "surface_order",
326
+ "createSurface must precede surface updates",
327
+ path=path,
328
+ )
329
+
330
+ if operation == "updateComponents":
331
+ update = _validate_model(
332
+ A2UIUpdateComponents,
333
+ payload,
334
+ code="invalid_update_components",
335
+ path=path,
336
+ )
337
+ if update.surface_id != surface_id:
338
+ raise InvalidA2UIMessageError(
339
+ "surface_mismatch",
340
+ "all messages must target the created surface",
341
+ path=f"{path}.surfaceId",
342
+ )
343
+ for component_index, raw_component in enumerate(update.components):
344
+ component_path = f"{path}.components[{component_index}]"
345
+ component = _parse_component(raw_component, path=component_path)
346
+ if component.id in components:
347
+ raise InvalidA2UIMessageError(
348
+ "duplicate_component",
349
+ f"component ID {component.id!r} is defined more than once",
350
+ path=f"{component_path}.id",
351
+ )
352
+ components[component.id] = component
353
+ continue
354
+
355
+ if data_model is not None:
356
+ raise UnsupportedA2UIFeatureError(
357
+ "unsupported_data_update",
358
+ "only one full root data-model snapshot is importable",
359
+ path=path,
360
+ )
361
+ update_data = _validate_model(
362
+ A2UIUpdateDataModel,
363
+ payload,
364
+ code="invalid_update_data_model",
365
+ path=path,
366
+ )
367
+ if update_data.surface_id != surface_id:
368
+ raise InvalidA2UIMessageError(
369
+ "surface_mismatch",
370
+ "all messages must target the created surface",
371
+ path=f"{path}.surfaceId",
372
+ )
373
+ try:
374
+ data_model = DeviceHealthSnapshot.model_validate(
375
+ update_data.value,
376
+ strict=True,
377
+ )
378
+ except ValidationError as exc:
379
+ raise _raise_schema_error(
380
+ exc,
381
+ code="invalid_device_health_data",
382
+ path=f"{path}.value",
383
+ ) from exc
384
+
385
+ if surface_id is None:
386
+ raise InvalidA2UIMessageError("missing_surface", "stream does not create a surface")
387
+ if "root" not in components:
388
+ raise InvalidA2UIMessageError(
389
+ "missing_root",
390
+ "the component snapshot must define exactly one component with ID 'root'",
391
+ path="$.components",
392
+ )
393
+
394
+ candidate: dict[str, object] = {
395
+ "spec": "forgeui/1",
396
+ "metadata": {
397
+ "title": "Imported A2UI device health",
398
+ "description": "Validated A2UI v0.9.1 snapshot.",
399
+ "version": "1",
400
+ },
401
+ "design": {"name": "ops-compact", "color_mode": "system"},
402
+ "context": {"locale": "en-US", "timezone": "UTC", "refresh_seconds": 60},
403
+ "data": {"contract": "device-health/1", "source": "device-health"},
404
+ "state": {"values": {}, "writable": []},
405
+ "root": "root",
406
+ "elements": {
407
+ component_id: _translate_component(component)
408
+ for component_id, component in components.items()
409
+ },
410
+ "actions": {},
411
+ }
412
+ report = validate_manifest(candidate)
413
+ if not report.valid or report.manifest is None:
414
+ raise A2UIManifestValidationError(report.issues)
415
+ return A2UIAdaptation(
416
+ surface_id=surface_id,
417
+ manifest=report.manifest,
418
+ data_model=data_model,
419
+ )
420
+
421
+
422
+ def _reject_duplicate_json_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
423
+ value: dict[str, object] = {}
424
+ for key, child in pairs:
425
+ if key in value:
426
+ raise InvalidA2UIMessageError(
427
+ "duplicate_json_key",
428
+ f"JSON object key {key!r} appears more than once",
429
+ )
430
+ value[key] = child
431
+ return value
432
+
433
+
434
+ def adapt_a2ui_jsonl(payload: str | bytes) -> A2UIAdaptation:
435
+ """Parse strict UTF-8 JSONL and adapt it without accepting duplicate keys."""
436
+
437
+ if isinstance(payload, bytes):
438
+ if len(payload) > MAX_A2UI_BYTES:
439
+ raise InvalidA2UIMessageError(
440
+ "byte_limit",
441
+ f"message stream exceeds {MAX_A2UI_BYTES} bytes",
442
+ )
443
+ try:
444
+ text = payload.decode("utf-8", errors="strict")
445
+ except UnicodeDecodeError as exc:
446
+ raise InvalidA2UIMessageError("invalid_utf8", "payload must be UTF-8") from exc
447
+ else:
448
+ text = payload
449
+ if len(text.encode("utf-8")) > MAX_A2UI_BYTES:
450
+ raise InvalidA2UIMessageError(
451
+ "byte_limit",
452
+ f"message stream exceeds {MAX_A2UI_BYTES} bytes",
453
+ )
454
+
455
+ lines = text.splitlines()
456
+ if any(not line.strip() for line in lines):
457
+ raise InvalidA2UIMessageError(
458
+ "invalid_jsonl",
459
+ "blank JSONL records are not allowed",
460
+ )
461
+ if len(lines) > MAX_A2UI_MESSAGES:
462
+ raise InvalidA2UIMessageError(
463
+ "message_limit",
464
+ f"message count exceeds {MAX_A2UI_MESSAGES}",
465
+ )
466
+
467
+ messages: list[Mapping[str, object]] = []
468
+ for index, line in enumerate(lines):
469
+ try:
470
+ parsed = json.loads(line, object_pairs_hook=_reject_duplicate_json_keys)
471
+ except A2UIAdapterError:
472
+ raise
473
+ except json.JSONDecodeError as exc:
474
+ raise InvalidA2UIMessageError(
475
+ "invalid_json",
476
+ f"record is not valid JSON: {exc.msg}",
477
+ path=f"$[{index}]",
478
+ ) from exc
479
+ if not isinstance(parsed, dict):
480
+ raise InvalidA2UIMessageError(
481
+ "invalid_message",
482
+ "each JSONL record must be an object",
483
+ path=f"$[{index}]",
484
+ )
485
+ messages.append(parsed)
486
+ return adapt_a2ui_messages(messages)
forgeui/a2ui/errors.py ADDED
@@ -0,0 +1,54 @@
1
+ """Errors raised by the deliberately narrow A2UI import boundary."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from forgeui.validation import ValidationIssue
10
+
11
+
12
+ class A2UIAdapterError(ValueError):
13
+ """Base error with a stable machine-readable code and input path."""
14
+
15
+ def __init__(self, code: str, message: str, *, path: str = "$") -> None:
16
+ self.code = code
17
+ self.path = path
18
+ self.message = message
19
+ super().__init__(f"{code} at {path}: {message}")
20
+
21
+
22
+ class UnsupportedA2UIVersionError(A2UIAdapterError):
23
+ """The input does not use the one closed protocol version we import."""
24
+
25
+ def __init__(self, version: object) -> None:
26
+ super().__init__(
27
+ "unsupported_version",
28
+ f"only the pinned A2UI v0.9.1 subset is supported, received {version!r}",
29
+ path="$.version",
30
+ )
31
+
32
+
33
+ class UnsupportedA2UIFeatureError(A2UIAdapterError):
34
+ """The input is valid A2UI in principle but outside ForgeUI's proven subset."""
35
+
36
+
37
+ class InvalidA2UIMessageError(A2UIAdapterError):
38
+ """The input is malformed or violates an adapter boundary."""
39
+
40
+
41
+ class A2UIManifestValidationError(A2UIAdapterError):
42
+ """The translated candidate failed ForgeUI's authoritative validator."""
43
+
44
+ def __init__(self, issues: Sequence[ValidationIssue]) -> None:
45
+ self.issues = tuple(issues)
46
+ summary = "; ".join(
47
+ f"{issue.code} at {issue.path}: {issue.message}" for issue in self.issues[:4]
48
+ )
49
+ if len(self.issues) > 4:
50
+ summary += f"; and {len(self.issues) - 4} more"
51
+ super().__init__(
52
+ "manifest_validation_failed",
53
+ summary or "ForgeUI rejected the translated manifest",
54
+ )