agentenv-framework-protocol 0.1.269__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.
- agentenv_framework_protocol-0.1.269.dist-info/METADATA +599 -0
- agentenv_framework_protocol-0.1.269.dist-info/RECORD +20 -0
- agentenv_framework_protocol-0.1.269.dist-info/WHEEL +4 -0
- agentenv_framework_protocol-0.1.269.dist-info/licenses/LICENSE +202 -0
- agentenv_framework_protocol-0.1.269.dist-info/licenses/NOTICE +4 -0
- agentenv_framework_protocol-0.1.269.dist-info/licenses/THIRD_PARTY_NOTICES.md +1701 -0
- agentenv_protocol/__init__.py +121 -0
- agentenv_protocol/a2a_agent/__init__.py +204 -0
- agentenv_protocol/a2a_agent/_triggers.py +489 -0
- agentenv_protocol/a2a_agent/extensions.py +1151 -0
- agentenv_protocol/a2a_agent/framework.py +1283 -0
- agentenv_protocol/a2a_agent/registry.py +449 -0
- agentenv_protocol/a2a_agent/tasks/__init__.py +39 -0
- agentenv_protocol/a2a_agent/tasks/v1.py +408 -0
- agentenv_protocol/agent_env_environment.py +653 -0
- agentenv_protocol/client.py +185 -0
- agentenv_protocol/manifest.py +203 -0
- agentenv_protocol/preflight.py +81 -0
- agentenv_protocol/transfers.py +554 -0
- agentenv_protocol/types.py +165 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Data-plane wire protocol client (JSON-RPC) for servers implementing the AgentEnv protocol."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from .manifest import INTERFACE_MANIFEST_PATH
|
|
9
|
+
from .types import INTAKE_EXTENSION_URI, MCP_PATH, MCP_TRANSPORT, METHOD_ADD, METHOD_GET, METHOD_RESET, RPC_PATH, WELL_KNOWN_PATH, AddDataResponse, GetDataResponse, Part, ResetDataResponse
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def _rpc(base_url: str, method: str, params: dict, timeout: int, verify: bool) -> dict:
|
|
15
|
+
request = {"jsonrpc": "2.0", "id": 1, "method": method, "params": params}
|
|
16
|
+
async with httpx.AsyncClient(verify=verify) as client:
|
|
17
|
+
response = await client.post(f"{base_url}{RPC_PATH}", json=request, timeout=timeout)
|
|
18
|
+
response.raise_for_status()
|
|
19
|
+
body = response.json()
|
|
20
|
+
if "error" in body:
|
|
21
|
+
error = body["error"]
|
|
22
|
+
raise RuntimeError(f"{method} failed: {error.get('message')} ({error.get('data') or error.get('code')})")
|
|
23
|
+
return body["result"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def reset_data(base_url: str, timeout: int = 30, verify: bool = True) -> ResetDataResponse:
|
|
27
|
+
return ResetDataResponse.model_validate(await _rpc(base_url, METHOD_RESET, {}, timeout, verify))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def add_data(base_url: str, parts: list[Part], timeout: int = 120, verify: bool = True) -> AddDataResponse:
|
|
31
|
+
params = {"parts": [p.model_dump(mode="json", exclude_none=True) for p in parts]}
|
|
32
|
+
return AddDataResponse.model_validate(await _rpc(base_url, METHOD_ADD, params, timeout, verify))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def get_data(base_url: str, timeout: int = 30, verify: bool = True) -> GetDataResponse:
|
|
36
|
+
return GetDataResponse.model_validate(await _rpc(base_url, METHOD_GET, {}, timeout, verify))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def get_card(base_url: str, timeout: int = 10, verify: bool = True) -> dict:
|
|
40
|
+
async with httpx.AsyncClient(verify=verify) as client:
|
|
41
|
+
response = await client.get(f"{base_url}{WELL_KNOWN_PATH}", timeout=timeout)
|
|
42
|
+
response.raise_for_status()
|
|
43
|
+
return response.json()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def supports_v1(base_url: str, verify: bool = True) -> bool:
|
|
47
|
+
try:
|
|
48
|
+
await get_card(base_url, verify=verify)
|
|
49
|
+
return True
|
|
50
|
+
except httpx.HTTPStatusError as e:
|
|
51
|
+
if e.response.status_code != 404:
|
|
52
|
+
logger.warning(f"v1 probe {base_url}: unexpected {e.response.status_code}, treating as legacy")
|
|
53
|
+
return False
|
|
54
|
+
except Exception as e:
|
|
55
|
+
logger.warning(f"v1 probe {base_url}: {type(e).__name__}, treating as legacy")
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def find_extension(card: dict, uri: str) -> dict | None:
|
|
60
|
+
"""Return the EnvironmentCard extension advertised under `uri`, or None.
|
|
61
|
+
|
|
62
|
+
Extensions live under `capabilities.extensions` (mirroring A2A's AgentCard). Tolerates a
|
|
63
|
+
missing/null `capabilities` or `extensions` so legacy and no-extensions cards return None
|
|
64
|
+
rather than raising.
|
|
65
|
+
"""
|
|
66
|
+
for ext in (card.get("capabilities") or {}).get("extensions") or []:
|
|
67
|
+
if ext.get("uri") == uri:
|
|
68
|
+
return ext
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def extension_params(card: dict, uri: str) -> dict:
|
|
73
|
+
"""Return the `params` of the extension at `uri`, or {} if absent or paramless."""
|
|
74
|
+
ext = find_extension(card, uri)
|
|
75
|
+
return (ext.get("params") or {}) if ext else {}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def find_extension_method(card: dict, uri: str, method: str) -> dict | None:
|
|
79
|
+
"""Return the method named `method` that the extension at `uri` advertises, or None.
|
|
80
|
+
|
|
81
|
+
`method` names an entry of the extension's `params.methods`. The returned entry's own `method`
|
|
82
|
+
key is its HTTP verb (POST unless advertised), and its `endpoint` is the method's own, else the
|
|
83
|
+
extension's.
|
|
84
|
+
"""
|
|
85
|
+
advertised = extension_params(card, uri)
|
|
86
|
+
entry = (advertised.get("methods") or {}).get(method)
|
|
87
|
+
if entry is None:
|
|
88
|
+
return None
|
|
89
|
+
return {**entry, "endpoint": entry.get("endpoint") or advertised.get("endpoint"), "method": (entry.get("method") or "POST").upper()}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def mcp_path(card: dict) -> str:
|
|
93
|
+
"""Return the path of the MCP endpoint a card declares, or `MCP_PATH` by convention.
|
|
94
|
+
|
|
95
|
+
Paths are relative to the address the card was fetched from.
|
|
96
|
+
"""
|
|
97
|
+
for interface in card.get("additionalInterfaces") or []:
|
|
98
|
+
if interface.get("transport") == MCP_TRANSPORT and interface.get("url"):
|
|
99
|
+
return interface["url"]
|
|
100
|
+
return MCP_PATH
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def intake_declaration(card: dict) -> dict | None:
|
|
104
|
+
"""Return the intake declaration advertised under `INTAKE_EXTENSION_URI`, or None.
|
|
105
|
+
|
|
106
|
+
Distinct from `extension_params`: absence of the extension (or its `params`) returns None —
|
|
107
|
+
"no claim", so consumers skip fit-checks — rather than `{}`, which would read as "declares an
|
|
108
|
+
empty intake". Mirrors the `supports_v1` 404->legacy tolerance.
|
|
109
|
+
"""
|
|
110
|
+
ext = find_extension(card, INTAKE_EXTENSION_URI)
|
|
111
|
+
if not ext:
|
|
112
|
+
return None
|
|
113
|
+
params = ext.get("params")
|
|
114
|
+
return params if params else None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def find_tool(card: dict, name: str) -> dict | None:
|
|
118
|
+
"""Return the MCP tool advertised under `name`, or None.
|
|
119
|
+
|
|
120
|
+
`capabilities.tools` is advertisement-only (invocation stays MCP). Tolerates a missing/null
|
|
121
|
+
`capabilities` or `tools` so legacy and no-tools cards return None rather than raising.
|
|
122
|
+
"""
|
|
123
|
+
for entry in (card.get("capabilities") or {}).get("tools") or []:
|
|
124
|
+
if entry.get("name") == name:
|
|
125
|
+
return entry
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def find_child(card: dict, name: str) -> dict | None:
|
|
130
|
+
"""Return the nested child EnvironmentCard named `name`, or None.
|
|
131
|
+
|
|
132
|
+
A composed card (e.g. a gateway fronting one or more MCP servers) nests each backing
|
|
133
|
+
environment's card under `children_environments`, with that child's endpoints already
|
|
134
|
+
rewritten to be reachable from the composed card's base_url. Callers resolve a service by
|
|
135
|
+
name here, then use the child card with `find_extension`/`invoke_extension`. Tolerates a
|
|
136
|
+
missing/null `children_environments` so single/leaf cards return None rather than raising.
|
|
137
|
+
"""
|
|
138
|
+
for child in card.get("children_environments") or []:
|
|
139
|
+
if child.get("name") == name:
|
|
140
|
+
return child
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
async def get_interface_manifest(base_url: str, interface: str, endpoint: str = INTERFACE_MANIFEST_PATH, timeout: int = 30, verify: bool = True):
|
|
145
|
+
"""Fetch one interface's manifest from a server's manifest index endpoint.
|
|
146
|
+
|
|
147
|
+
``endpoint`` is the index path — pass the one advertised on the card under
|
|
148
|
+
``GET_INTERFACES_EXTENSION_URI`` (already gateway-rewritten) or leave the
|
|
149
|
+
default when talking to a server directly. Mechanical by design: raises
|
|
150
|
+
``httpx.HTTPStatusError`` on a non-2xx response and ``ValueError`` on a
|
|
151
|
+
non-JSON body; treating a 404 as "server opted out" is caller policy.
|
|
152
|
+
"""
|
|
153
|
+
url = f"{base_url.rstrip('/')}{endpoint}/{interface}"
|
|
154
|
+
async with httpx.AsyncClient(verify=verify) as client:
|
|
155
|
+
response = await client.get(url, timeout=timeout)
|
|
156
|
+
response.raise_for_status()
|
|
157
|
+
return response.json()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
async def invoke_extension(base_url: str, card: dict, uri: str, params: dict | None = None, timeout: int = 30, verify: bool = True, *, method: str | None = None):
|
|
161
|
+
"""Invoke a card extension via its advertised REST endpoint.
|
|
162
|
+
|
|
163
|
+
Reads the endpoint and HTTP verb of the extension method named `method` from the card, so the
|
|
164
|
+
card alone is enough to invoke; without `method`, the first method listed. Returns the parsed
|
|
165
|
+
JSON result; raises on a non-2xx response.
|
|
166
|
+
"""
|
|
167
|
+
ext = find_extension(card, uri)
|
|
168
|
+
if ext is None:
|
|
169
|
+
raise ValueError(f"extension not advertised on card: {uri}")
|
|
170
|
+
advertised = ext.get("params") or {}
|
|
171
|
+
name = method if method is not None else next(iter(advertised.get("methods") or {}), None)
|
|
172
|
+
op = find_extension_method(card, uri, name) if name is not None else {"endpoint": advertised.get("endpoint"), "method": "POST"}
|
|
173
|
+
if op is None:
|
|
174
|
+
offered = ", ".join(advertised.get("methods") or {}) or "none"
|
|
175
|
+
raise ValueError(f"extension {uri} does not advertise method {method!r} (advertises: {offered})")
|
|
176
|
+
if not op["endpoint"]:
|
|
177
|
+
raise ValueError(f"extension {uri} has no endpoint in params")
|
|
178
|
+
url = f"{base_url}{op['endpoint']}"
|
|
179
|
+
async with httpx.AsyncClient(verify=verify) as client:
|
|
180
|
+
if op["method"] == "GET":
|
|
181
|
+
response = await client.get(url, params=params or {}, timeout=timeout)
|
|
182
|
+
else:
|
|
183
|
+
response = await client.request(op["method"], url, json=params or {}, timeout=timeout)
|
|
184
|
+
response.raise_for_status()
|
|
185
|
+
return response.json()
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Interface Manifest: a structured description of a server's data model that
|
|
2
|
+
interface renderers (CLI, GUI, ...) are generated from.
|
|
3
|
+
|
|
4
|
+
Servers build manifests from their API spec, validate them against their live
|
|
5
|
+
MCP tools, and serve them at ``INTERFACE_MANIFEST_PATH``, advertised on the
|
|
6
|
+
EnvironmentCard under ``GET_INTERFACES_EXTENSION_URI``. Producers and
|
|
7
|
+
consumers both import the schema, constants, and version rules from here.
|
|
8
|
+
|
|
9
|
+
Two layers: a structural core (:class:`InterfaceManifest`) and one projection
|
|
10
|
+
per interface (:class:`CliManifest` for the CLI). Manifests are structure
|
|
11
|
+
only — no environment data or per-environment behavior — and every operation
|
|
12
|
+
references an MCP tool by name, so renderers interact with a server purely
|
|
13
|
+
through the protocol and tool calls.
|
|
14
|
+
|
|
15
|
+
Skew tolerance: models ignore unknown fields, and :func:`manifest_compatible`
|
|
16
|
+
permits compatible version skew, so producers and consumers can deploy
|
|
17
|
+
independently.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
from typing import Dict, List, Literal, Optional
|
|
24
|
+
|
|
25
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
26
|
+
|
|
27
|
+
# Bumped when the manifest shape changes in a way renderers must notice.
|
|
28
|
+
MANIFEST_VERSION = "0.1.0"
|
|
29
|
+
|
|
30
|
+
# Where a server serves its manifest index (GET -> list of interface names);
|
|
31
|
+
# one interface's manifest is at f"{INTERFACE_MANIFEST_PATH}/{interface}".
|
|
32
|
+
INTERFACE_MANIFEST_PATH = "/agentenv/interface-manifest"
|
|
33
|
+
|
|
34
|
+
# EnvironmentCard extension advertising the (gateway-rewritten) index endpoint
|
|
35
|
+
# in `params.endpoint`. Absent = pre-extension image; `[]` from the endpoint =
|
|
36
|
+
# definitively no manifests.
|
|
37
|
+
GET_INTERFACES_EXTENSION_URI = "urn:agentenv:get-interfaces/v1"
|
|
38
|
+
|
|
39
|
+
OperationKind = Literal["list", "read", "create", "update", "delete"]
|
|
40
|
+
|
|
41
|
+
# CLI command name per core operation kind (``read`` renders as ``get``).
|
|
42
|
+
CLI_COMMANDS: Dict[str, str] = {
|
|
43
|
+
"list": "list",
|
|
44
|
+
"read": "get",
|
|
45
|
+
"create": "create",
|
|
46
|
+
"update": "update",
|
|
47
|
+
"delete": "delete",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
_VERSION_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def manifest_compatible(served: object, supported: str = MANIFEST_VERSION) -> bool:
|
|
54
|
+
"""Whether a manifest at version ``served`` is consumable by a renderer built for ``supported``.
|
|
55
|
+
|
|
56
|
+
Same major is compatible; while the major is 0 (pre-1.0 semver) the minor
|
|
57
|
+
must match too, so only patch-level skew is tolerated. A missing or
|
|
58
|
+
unparseable version is incompatible.
|
|
59
|
+
"""
|
|
60
|
+
if not isinstance(served, str):
|
|
61
|
+
return False
|
|
62
|
+
served_match = _VERSION_RE.match(served)
|
|
63
|
+
supported_match = _VERSION_RE.match(supported)
|
|
64
|
+
if not served_match or not supported_match:
|
|
65
|
+
return False
|
|
66
|
+
if served_match.group(1) != supported_match.group(1):
|
|
67
|
+
return False
|
|
68
|
+
if served_match.group(1) == "0" and served_match.group(2) != supported_match.group(2):
|
|
69
|
+
return False
|
|
70
|
+
return True
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ManifestModel(BaseModel):
|
|
74
|
+
"""Base for all manifest models: unknown fields from a newer producer are ignored."""
|
|
75
|
+
|
|
76
|
+
model_config = ConfigDict(extra="ignore")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class FieldSpec(ManifestModel):
|
|
80
|
+
"""One field of an entity, as seen through the server's tools."""
|
|
81
|
+
|
|
82
|
+
name: str
|
|
83
|
+
type: str
|
|
84
|
+
format: Optional[str] = None
|
|
85
|
+
enum: Optional[List[str]] = None
|
|
86
|
+
description: Optional[str] = None
|
|
87
|
+
required: bool = False
|
|
88
|
+
read_only: bool = False
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class Relationship(ManifestModel):
|
|
92
|
+
"""A field that points at another entity (for detail-view linking)."""
|
|
93
|
+
|
|
94
|
+
field: str
|
|
95
|
+
references: str
|
|
96
|
+
many: bool = False
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class ParamSpec(ManifestModel):
|
|
100
|
+
"""A single input parameter of an operation/action, as the tool accepts it.
|
|
101
|
+
|
|
102
|
+
Carries everything an interface projection needs to render the input
|
|
103
|
+
without re-reading the spec: its type, array item type, whether it is
|
|
104
|
+
required, and (for enum fields) the constrained set of allowed values.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
name: str
|
|
108
|
+
type: str
|
|
109
|
+
item_type: Optional[str] = None
|
|
110
|
+
required: bool = False
|
|
111
|
+
enum: Optional[List[str]] = None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class Operation(ManifestModel):
|
|
115
|
+
"""A CRUD operation on an entity, backed by exactly one MCP tool."""
|
|
116
|
+
|
|
117
|
+
kind: OperationKind
|
|
118
|
+
tool: str
|
|
119
|
+
summary: Optional[str] = None
|
|
120
|
+
description: Optional[str] = None
|
|
121
|
+
required: List[str] = Field(default_factory=list)
|
|
122
|
+
params: List[ParamSpec] = Field(default_factory=list)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class Action(ManifestModel):
|
|
126
|
+
"""A non-CRUD tool (rendered as a button/command), backed by one MCP tool."""
|
|
127
|
+
|
|
128
|
+
tool: str
|
|
129
|
+
description: Optional[str] = None
|
|
130
|
+
params: List[ParamSpec] = Field(default_factory=list)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class Entity(ManifestModel):
|
|
134
|
+
"""A record type: its fields, relationships, and per-entity operations."""
|
|
135
|
+
|
|
136
|
+
name: str
|
|
137
|
+
fields: List[FieldSpec] = Field(default_factory=list)
|
|
138
|
+
relationships: List[Relationship] = Field(default_factory=list)
|
|
139
|
+
operations: List[Operation] = Field(default_factory=list)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class InterfaceManifest(ManifestModel):
|
|
143
|
+
"""The full structural description of a server."""
|
|
144
|
+
|
|
145
|
+
service: str
|
|
146
|
+
manifest_version: str = MANIFEST_VERSION
|
|
147
|
+
entities: List[Entity] = Field(default_factory=list)
|
|
148
|
+
actions: List[Action] = Field(default_factory=list)
|
|
149
|
+
|
|
150
|
+
def to_json(self) -> str:
|
|
151
|
+
"""Deterministic, human-diffable JSON (trailing newline for POSIX).
|
|
152
|
+
|
|
153
|
+
``exclude_none`` drops absent optionals (``format``/``enum``/
|
|
154
|
+
``description``) so committed goldens stay compact; Pydantic consumers
|
|
155
|
+
see absent == default anyway.
|
|
156
|
+
"""
|
|
157
|
+
return self.model_dump_json(indent=2, exclude_none=True) + "\n"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ── CLI interface projection ────────────────────────────────────────────────
|
|
161
|
+
#
|
|
162
|
+
# The command-oriented view of the structural core: per-entity commands
|
|
163
|
+
# (list/get/create/update/delete) + actions, each backed by one MCP tool with
|
|
164
|
+
# its required/optional params (enum fields carry their constrained options).
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class CliCommand(ManifestModel):
|
|
168
|
+
"""One CLI command: an entity operation or action, backed by one MCP tool."""
|
|
169
|
+
|
|
170
|
+
tool: str
|
|
171
|
+
summary: Optional[str] = None
|
|
172
|
+
description: Optional[str] = None
|
|
173
|
+
params: List[ParamSpec] = Field(default_factory=list)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class CliEntity(ManifestModel):
|
|
177
|
+
"""An entity's CLI commands, keyed by verb (``list``/``get``/...)."""
|
|
178
|
+
|
|
179
|
+
entity: str
|
|
180
|
+
commands: Dict[str, CliCommand] = Field(default_factory=dict)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class CliAction(ManifestModel):
|
|
184
|
+
"""A non-CRUD tool exposed as its own top-level CLI command."""
|
|
185
|
+
|
|
186
|
+
name: str
|
|
187
|
+
tool: str
|
|
188
|
+
params: List[ParamSpec] = Field(default_factory=list)
|
|
189
|
+
description: Optional[str] = None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class CliManifest(ManifestModel):
|
|
193
|
+
"""The CLI projection of a server's structural core."""
|
|
194
|
+
|
|
195
|
+
service: str
|
|
196
|
+
manifest_version: str = MANIFEST_VERSION
|
|
197
|
+
interface: Literal["cli"] = "cli"
|
|
198
|
+
entities: List[CliEntity] = Field(default_factory=list)
|
|
199
|
+
actions: List[CliAction] = Field(default_factory=list)
|
|
200
|
+
|
|
201
|
+
def to_json(self) -> str:
|
|
202
|
+
"""Deterministic, human-diffable JSON (see :meth:`InterfaceManifest.to_json`)."""
|
|
203
|
+
return self.model_dump_json(indent=2, exclude_none=True) + "\n"
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Pre-deploy fit-check for the data-plane intake declaration.
|
|
2
|
+
|
|
3
|
+
Pure functions over plain dicts — no I/O, no Mongo/S3 — so they run at authoring/
|
|
4
|
+
registration time. The agent-env-side ``preflight(universe, env)`` wraps these by
|
|
5
|
+
reading ``client.intake_declaration(card)`` and each service's seed ``data.json``.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _json_add_format(declaration: dict, content_format: str) -> Optional[dict]:
|
|
13
|
+
"""Return the declared ``data/add`` format for an inline (part=data) payload, or None."""
|
|
14
|
+
for fmt in declaration.get("add") or []:
|
|
15
|
+
if fmt.get("part") == "data" and fmt.get("format") == content_format:
|
|
16
|
+
return fmt
|
|
17
|
+
return None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def intake_fit_check(
|
|
21
|
+
declaration: Optional[dict],
|
|
22
|
+
data_json: dict,
|
|
23
|
+
*,
|
|
24
|
+
content_format: str = "json",
|
|
25
|
+
) -> list[str]:
|
|
26
|
+
"""Check a candidate seed ``data_json`` against a declared intake.
|
|
27
|
+
|
|
28
|
+
``declaration`` is the dict from ``client.intake_declaration(card)`` (an
|
|
29
|
+
``IntakeDeclaration``), or ``None``. ``data_json`` is the collection→rows seed the
|
|
30
|
+
server will load via ``load_from_json``.
|
|
31
|
+
|
|
32
|
+
Returns a list of human-readable issues; empty means it fits (or there is no claim
|
|
33
|
+
to check against). Absence of a declaration reads as "no claim" and returns ``[]``,
|
|
34
|
+
matching ``intake_declaration``'s absence semantics.
|
|
35
|
+
|
|
36
|
+
The check models the *loadable subset* and tolerates extra fields (real
|
|
37
|
+
``data.json`` is a superset on validated-tier servers): it flags collections the
|
|
38
|
+
server does not accept and rows missing a declared required field, but never
|
|
39
|
+
complains about extra fields.
|
|
40
|
+
"""
|
|
41
|
+
if not declaration:
|
|
42
|
+
return []
|
|
43
|
+
|
|
44
|
+
fmt = _json_add_format(declaration, content_format)
|
|
45
|
+
if fmt is None:
|
|
46
|
+
accepted = [
|
|
47
|
+
f"{f.get('part')}/{f.get('format')}" for f in declaration.get("add") or []
|
|
48
|
+
]
|
|
49
|
+
return [
|
|
50
|
+
f"data/add does not accept part=data format={content_format} "
|
|
51
|
+
f"(accepted: {accepted or 'nothing'})"
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
tables = fmt.get("tables")
|
|
55
|
+
if not tables:
|
|
56
|
+
# Accepted, but no per-table schema was declared — nothing more to verify.
|
|
57
|
+
return []
|
|
58
|
+
|
|
59
|
+
issues: list[str] = []
|
|
60
|
+
for collection, rows in data_json.items():
|
|
61
|
+
if not isinstance(rows, list):
|
|
62
|
+
# Scalar top-level extras (e.g. contacts.current_user_id, email.user_email)
|
|
63
|
+
# ride alongside the collections on export; they are not tables to validate.
|
|
64
|
+
continue
|
|
65
|
+
if collection not in tables:
|
|
66
|
+
issues.append(
|
|
67
|
+
f"collection '{collection}' is not accepted "
|
|
68
|
+
f"(declared: {sorted(tables)})"
|
|
69
|
+
)
|
|
70
|
+
continue
|
|
71
|
+
schema = tables[collection] or {}
|
|
72
|
+
required = schema.get("required") or []
|
|
73
|
+
if not required:
|
|
74
|
+
continue
|
|
75
|
+
for i, row in enumerate(rows):
|
|
76
|
+
if not isinstance(row, dict):
|
|
77
|
+
continue
|
|
78
|
+
missing = [r for r in required if r not in row]
|
|
79
|
+
if missing:
|
|
80
|
+
issues.append(f"{collection}[{i}] missing required field(s): {missing}")
|
|
81
|
+
return issues
|