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.
- forgeui/__init__.py +10 -0
- forgeui/a2ui/README.md +68 -0
- forgeui/a2ui/__init__.py +36 -0
- forgeui/a2ui/adapter.py +486 -0
- forgeui/a2ui/errors.py +54 -0
- forgeui/a2ui/models.py +116 -0
- forgeui/app.py +465 -0
- forgeui/catalog/__init__.py +5 -0
- forgeui/catalog/registry.py +552 -0
- forgeui/cli.py +38 -0
- forgeui/config.py +120 -0
- forgeui/container.py +135 -0
- forgeui/data/__init__.py +6 -0
- forgeui/data/database.py +103 -0
- forgeui/data/models.py +175 -0
- forgeui/data/repositories.py +444 -0
- forgeui/domain/__init__.py +23 -0
- forgeui/domain/device_health.py +213 -0
- forgeui/domain/models.py +217 -0
- forgeui/expressions/__init__.py +14 -0
- forgeui/expressions/ast.py +126 -0
- forgeui/expressions/evaluator.py +231 -0
- forgeui/icons.py +126 -0
- forgeui/llm/__init__.py +51 -0
- forgeui/llm/fake.py +31 -0
- forgeui/llm/generation.py +218 -0
- forgeui/llm/ollama.py +118 -0
- forgeui/llm/prompting.py +113 -0
- forgeui/llm/types.py +103 -0
- forgeui/logging.py +42 -0
- forgeui/observability.py +62 -0
- forgeui/py.typed +1 -0
- forgeui/renderer/__init__.py +12 -0
- forgeui/renderer/renderer.py +839 -0
- forgeui/runtime.py +114 -0
- forgeui/security.py +57 -0
- forgeui/services/__init__.py +40 -0
- forgeui/services/actions.py +413 -0
- forgeui/services/apps.py +207 -0
- forgeui/services/audit.py +79 -0
- forgeui/services/capabilities.py +159 -0
- forgeui/services/devices.py +166 -0
- forgeui/services/exceptions.py +27 -0
- forgeui/services/jobs.py +170 -0
- forgeui/services/state.py +131 -0
- forgeui/sources/__init__.py +29 -0
- forgeui/sources/http.py +122 -0
- forgeui/sources/registry.py +358 -0
- forgeui/surfaces.py +64 -0
- forgeui/validation/__init__.py +30 -0
- forgeui/validation/parser.py +82 -0
- forgeui/validation/repair.py +68 -0
- forgeui/validation/validator.py +653 -0
- forgeui/web/__init__.py +5 -0
- forgeui/web/router.py +1083 -0
- forgeui/web/static/favicon.svg +4 -0
- forgeui/web/static/forgeui-embed.js +38 -0
- forgeui/web/static/forgeui.css +1780 -0
- forgeui/web/static/forgeui.js +300 -0
- forgeui/web/templates/base.html +53 -0
- forgeui/web/templates/components/_component.html +88 -0
- forgeui-0.1.0a2.dist-info/METADATA +277 -0
- forgeui-0.1.0a2.dist-info/RECORD +67 -0
- forgeui-0.1.0a2.dist-info/WHEEL +4 -0
- forgeui-0.1.0a2.dist-info/entry_points.txt +2 -0
- forgeui-0.1.0a2.dist-info/licenses/LICENSE +21 -0
- 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.
|
forgeui/a2ui/__init__.py
ADDED
|
@@ -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
|
+
]
|
forgeui/a2ui/adapter.py
ADDED
|
@@ -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
|
+
)
|