agenthooksprotocol 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.
- agenthooksprotocol/__init__.py +64 -0
- agenthooksprotocol/_boundaries.py +498 -0
- agenthooksprotocol/_content.py +672 -0
- agenthooksprotocol/_diagnostics.py +93 -0
- agenthooksprotocol/_grants.py +597 -0
- agenthooksprotocol/_hooks.py +925 -0
- agenthooksprotocol/_models/__init__.py +18414 -0
- agenthooksprotocol/_models/capability.py +282 -0
- agenthooksprotocol/_models/content.py +58 -0
- agenthooksprotocol/_models/effect.py +52 -0
- agenthooksprotocol/_models/event.py +230 -0
- agenthooksprotocol/_models/registration.py +20 -0
- agenthooksprotocol/_models/subscription.py +24 -0
- agenthooksprotocol/_models/tool.py +132 -0
- agenthooksprotocol/_models/transport.py +8 -0
- agenthooksprotocol/ahp-codegen.lock.json +123 -0
- agenthooksprotocol/auth.py +343 -0
- agenthooksprotocol/candidate.py +10 -0
- agenthooksprotocol/capability.py +193 -0
- agenthooksprotocol/catalogue.py +201 -0
- agenthooksprotocol/codec.py +32 -0
- agenthooksprotocol/compaction.py +211 -0
- agenthooksprotocol/content.py +131 -0
- agenthooksprotocol/diagnostics.py +15 -0
- agenthooksprotocol/effect.py +160 -0
- agenthooksprotocol/elicitation.py +250 -0
- agenthooksprotocol/event.py +114 -0
- agenthooksprotocol/generated.py +11595 -0
- agenthooksprotocol/integrations/__init__.py +1 -0
- agenthooksprotocol/integrations/pydantic.py +28 -0
- agenthooksprotocol/interop.py +1075 -0
- agenthooksprotocol/lifecycle.py +294 -0
- agenthooksprotocol/lifecycle_client.py +417 -0
- agenthooksprotocol/lifecycle_server.py +490 -0
- agenthooksprotocol/lineage.py +115 -0
- agenthooksprotocol/observation_chain.py +151 -0
- agenthooksprotocol/path.py +4 -0
- agenthooksprotocol/permission.py +2 -0
- agenthooksprotocol/py.typed +0 -0
- agenthooksprotocol/registration.py +148 -0
- agenthooksprotocol/runtime.py +354 -0
- agenthooksprotocol/schemas.json +10557 -0
- agenthooksprotocol/server/__init__.py +8 -0
- agenthooksprotocol/server/_framing.py +33 -0
- agenthooksprotocol/server/_json.py +18 -0
- agenthooksprotocol/server/asgi.py +66 -0
- agenthooksprotocol/server/attachments.py +205 -0
- agenthooksprotocol/server/hooks.py +163 -0
- agenthooksprotocol/server/stdio.py +176 -0
- agenthooksprotocol/state.py +34 -0
- agenthooksprotocol/subscription.py +11 -0
- agenthooksprotocol/tool.py +63 -0
- agenthooksprotocol/transport.py +6 -0
- agenthooksprotocol/transports/__init__.py +14 -0
- agenthooksprotocol/transports/http.py +144 -0
- agenthooksprotocol/transports/stdio.py +92 -0
- agenthooksprotocol-0.1.0.dist-info/METADATA +370 -0
- agenthooksprotocol-0.1.0.dist-info/RECORD +61 -0
- agenthooksprotocol-0.1.0.dist-info/WHEEL +5 -0
- agenthooksprotocol-0.1.0.dist-info/licenses/LICENSE +202 -0
- agenthooksprotocol-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
"""Explicit, trusted asynchronous bindings for immutable normalized content.
|
|
2
|
+
|
|
3
|
+
Resolvers and uploaders are transport/storage adapters, not domain validators.
|
|
4
|
+
Bodies never appear inline in wire events, and no primary payload is inferred.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
10
|
+
from copy import deepcopy
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import math
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
import anyio
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from .runtime import OperationCancelledError, ProtocolError, json_equal
|
|
21
|
+
|
|
22
|
+
Reference = dict[str, Any]
|
|
23
|
+
Resolver = Callable[[Reference], Awaitable[bytes]]
|
|
24
|
+
Uploader = Callable[[bytes], Awaitable[Reference]]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def at(value: Any, path: tuple[str | int, ...]) -> Any:
|
|
28
|
+
for component in path:
|
|
29
|
+
value = value[component]
|
|
30
|
+
return value
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def put(value: Any, path: tuple[str | int, ...], replacement: Any) -> None:
|
|
34
|
+
if not path:
|
|
35
|
+
raise ProtocolError("Content binding requires an explicit nonempty path")
|
|
36
|
+
at(value, path[:-1])[path[-1]] = replacement
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def project_content(
|
|
40
|
+
event: dict[str, Any], selection: dict[str, str], include_native: bool = False
|
|
41
|
+
) -> dict[str, Any]:
|
|
42
|
+
"""Project descriptors at every normalized depth, never arbitrary host JSON."""
|
|
43
|
+
|
|
44
|
+
def walk(value: Any) -> Any:
|
|
45
|
+
if isinstance(value, list):
|
|
46
|
+
return [walk(item) for item in value]
|
|
47
|
+
if not isinstance(value, dict):
|
|
48
|
+
return deepcopy(value)
|
|
49
|
+
if all(key in value for key in ("id", "kind", "mediaType", "selection")):
|
|
50
|
+
item = deepcopy(value)
|
|
51
|
+
media = item["mediaType"]
|
|
52
|
+
category = (
|
|
53
|
+
"reasoning"
|
|
54
|
+
if item["kind"] == "reasoning"
|
|
55
|
+
else item.get("category")
|
|
56
|
+
or (
|
|
57
|
+
"text"
|
|
58
|
+
if media.startswith("text/")
|
|
59
|
+
else "images"
|
|
60
|
+
if media.startswith("image/")
|
|
61
|
+
else "audio"
|
|
62
|
+
if media.startswith("audio/")
|
|
63
|
+
else "video"
|
|
64
|
+
if media.startswith("video/")
|
|
65
|
+
else "files"
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
wanted = selection.get(category, selection["default"])
|
|
69
|
+
# A selection is a ceiling, never permission to restore absent bytes.
|
|
70
|
+
rank = {"omit": 0, "metadata": 1, "body": 2}
|
|
71
|
+
mode = min((wanted, item["selection"]), key=rank.__getitem__)
|
|
72
|
+
if mode != "body":
|
|
73
|
+
item.pop("body", None)
|
|
74
|
+
item.pop("gap", None)
|
|
75
|
+
item["selection"] = mode
|
|
76
|
+
return item
|
|
77
|
+
return {
|
|
78
|
+
key: deepcopy(item)
|
|
79
|
+
if key in ("input", "native", "extensions")
|
|
80
|
+
else walk(item)
|
|
81
|
+
for key, item in value.items()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
projected = walk(event)
|
|
85
|
+
if not include_native:
|
|
86
|
+
projected.pop("native", None)
|
|
87
|
+
return projected
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def merge_effective(original: Any, view: Any, effective: Any) -> Any:
|
|
91
|
+
"""Apply an admitted view delta without turning projection into a host edit."""
|
|
92
|
+
if json_equal(view, effective):
|
|
93
|
+
return deepcopy(original)
|
|
94
|
+
if (
|
|
95
|
+
isinstance(original, dict)
|
|
96
|
+
and isinstance(view, dict)
|
|
97
|
+
and isinstance(effective, dict)
|
|
98
|
+
):
|
|
99
|
+
merged = deepcopy(original)
|
|
100
|
+
for key in view.keys() - effective.keys():
|
|
101
|
+
merged.pop(key, None)
|
|
102
|
+
for key, value in effective.items():
|
|
103
|
+
merged[key] = (
|
|
104
|
+
merge_effective(original[key], view[key], value)
|
|
105
|
+
if key in original and key in view
|
|
106
|
+
else deepcopy(value)
|
|
107
|
+
)
|
|
108
|
+
return merged
|
|
109
|
+
return deepcopy(effective)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class ContentContext:
|
|
113
|
+
"""Explicit body bindings for one or more calls.
|
|
114
|
+
|
|
115
|
+
``bindings`` maps effect targets to descriptor paths in the event. MCP
|
|
116
|
+
request binds ``request``; result binds ``content`` and supplies an immutable
|
|
117
|
+
``original_request`` wire snapshot. Compaction binds ``instructions`` or
|
|
118
|
+
``summary``. ``principal`` is trusted transport identity, never event data.
|
|
119
|
+
A fresh prepared context is made per invocation, safe for concurrent calls.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
def __init__(
|
|
123
|
+
self,
|
|
124
|
+
*,
|
|
125
|
+
resolve: Resolver,
|
|
126
|
+
upload: Uploader,
|
|
127
|
+
bindings: Mapping[str, tuple[str | int, ...]],
|
|
128
|
+
principal: str,
|
|
129
|
+
original_request: dict[str, Any] | None = None,
|
|
130
|
+
) -> None:
|
|
131
|
+
if not principal or not isinstance(principal, str):
|
|
132
|
+
raise ProtocolError("Authenticated content principal is required")
|
|
133
|
+
if any(
|
|
134
|
+
not path
|
|
135
|
+
or not all(
|
|
136
|
+
isinstance(k, (str, int)) and not isinstance(k, bool) for k in path
|
|
137
|
+
)
|
|
138
|
+
for path in bindings.values()
|
|
139
|
+
):
|
|
140
|
+
raise ProtocolError("Explicit nonempty content paths are required")
|
|
141
|
+
self.resolve = resolve
|
|
142
|
+
self.upload = upload
|
|
143
|
+
self.bindings = {key: tuple(path) for key, path in bindings.items()}
|
|
144
|
+
self.principal = principal
|
|
145
|
+
self.original_request = deepcopy(original_request)
|
|
146
|
+
self._identities: dict[str, tuple[int, str]] = {}
|
|
147
|
+
|
|
148
|
+
async def prepare(self, request: dict[str, Any], validator: Any) -> PreparedContent:
|
|
149
|
+
prepared = PreparedContent(self, request, validator)
|
|
150
|
+
await prepared.load()
|
|
151
|
+
return prepared
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class PreparedContent:
|
|
155
|
+
def __init__(
|
|
156
|
+
self, context: ContentContext, request: dict[str, Any], validator: Any
|
|
157
|
+
) -> None:
|
|
158
|
+
self.context = context
|
|
159
|
+
self.request = deepcopy(request)
|
|
160
|
+
self.validator = validator
|
|
161
|
+
self.raw: dict[str, bytes] = {}
|
|
162
|
+
self.items: dict[str, dict[str, Any]] = {}
|
|
163
|
+
self.selected: dict[str, bytes] = {}
|
|
164
|
+
self.targets = set(context.bindings) - {"request"}
|
|
165
|
+
|
|
166
|
+
async def _read(self, item: dict[str, Any]) -> bytes | None:
|
|
167
|
+
self.validator.validate("content-item", item)
|
|
168
|
+
if item["selection"] != "body":
|
|
169
|
+
return None
|
|
170
|
+
if "body" not in item:
|
|
171
|
+
raise ProtocolError("Selected content body is unavailable")
|
|
172
|
+
reference = item["body"]
|
|
173
|
+
if any(
|
|
174
|
+
key in item and item[key] != reference[key] for key in ("size", "sha256")
|
|
175
|
+
):
|
|
176
|
+
raise ProtocolError("Content metadata disagrees with body descriptor")
|
|
177
|
+
data = await self.context.resolve(deepcopy(reference))
|
|
178
|
+
if (
|
|
179
|
+
not isinstance(data, bytes)
|
|
180
|
+
or len(data) != reference["size"]
|
|
181
|
+
or hashlib.sha256(data).hexdigest() != reference["sha256"]
|
|
182
|
+
):
|
|
183
|
+
raise ProtocolError("Resolved content does not match immutable descriptor")
|
|
184
|
+
identity = (reference["size"], reference["sha256"])
|
|
185
|
+
known = self.context._identities.get(reference["ref"])
|
|
186
|
+
if known is not None and known != identity:
|
|
187
|
+
raise ProtocolError("Immutable content reference changed")
|
|
188
|
+
self.context._identities[reference["ref"]] = identity
|
|
189
|
+
old = self.raw.get(reference["ref"])
|
|
190
|
+
if old is not None and old != data:
|
|
191
|
+
raise ProtocolError("Immutable content reference changed")
|
|
192
|
+
self.raw[reference["ref"]] = data
|
|
193
|
+
return data
|
|
194
|
+
|
|
195
|
+
async def load(self) -> None:
|
|
196
|
+
event = self.request["params"]["event"]
|
|
197
|
+
kind = event["type"]
|
|
198
|
+
expected = {
|
|
199
|
+
"user.elicitation.request": {"request": ("elicitation", "request")},
|
|
200
|
+
"user.elicitation.result": {"content": ("elicitation", "result")},
|
|
201
|
+
"context.compact.before": {"instructions": ("instructions",)},
|
|
202
|
+
"context.compact.after": {"summary": ("summary",)},
|
|
203
|
+
}.get(kind)
|
|
204
|
+
if expected is None or self.context.bindings != expected:
|
|
205
|
+
raise ProtocolError("Content targets must explicitly match this boundary")
|
|
206
|
+
if kind == "user.elicitation.result":
|
|
207
|
+
from .elicitation import validate_correlation
|
|
208
|
+
|
|
209
|
+
original = self.context.original_request
|
|
210
|
+
if original is None:
|
|
211
|
+
raise ProtocolError(
|
|
212
|
+
"Elicitation result requires its original request snapshot"
|
|
213
|
+
)
|
|
214
|
+
self.validator.validate("intercept-request", original)
|
|
215
|
+
validate_correlation(original, self.request)
|
|
216
|
+
# The original snapshot is not a way to override current selection.
|
|
217
|
+
item = at(event, self.context.bindings["content"])
|
|
218
|
+
if item.get("selection") == "body":
|
|
219
|
+
await self._read(original["params"]["event"]["elicitation"]["request"])
|
|
220
|
+
for target, path in self.context.bindings.items():
|
|
221
|
+
try:
|
|
222
|
+
item = at(event, path)
|
|
223
|
+
except (KeyError, IndexError, TypeError) as exc:
|
|
224
|
+
raise ProtocolError("Bound content descriptor is absent") from exc
|
|
225
|
+
self.items[target] = deepcopy(item)
|
|
226
|
+
data = await self._read(item)
|
|
227
|
+
if data is not None:
|
|
228
|
+
self.selected[target] = data
|
|
229
|
+
|
|
230
|
+
def _resolve(self, reference: dict[str, Any]) -> bytes:
|
|
231
|
+
try:
|
|
232
|
+
return self.raw[reference["ref"]]
|
|
233
|
+
except KeyError as exc:
|
|
234
|
+
raise ProtocolError("Content was not selected and resolved") from exc
|
|
235
|
+
|
|
236
|
+
def stage(
|
|
237
|
+
self, request: dict[str, Any], effects: list[dict[str, Any]]
|
|
238
|
+
) -> dict[str, Any]:
|
|
239
|
+
event = request["params"]["event"]
|
|
240
|
+
kind = event["type"]
|
|
241
|
+
updates: list[dict[str, Any]] = []
|
|
242
|
+
values: dict[str, Any] = {}
|
|
243
|
+
changed = False
|
|
244
|
+
if kind.startswith("user.elicitation."):
|
|
245
|
+
from .elicitation import apply_effects, read_selected, validate_exchange
|
|
246
|
+
|
|
247
|
+
original = (
|
|
248
|
+
request if kind.endswith(".request") else self.context.original_request
|
|
249
|
+
)
|
|
250
|
+
result = None if kind.endswith(".request") else request
|
|
251
|
+
body_effects = [
|
|
252
|
+
effect
|
|
253
|
+
for effect in effects
|
|
254
|
+
if effect["type"] in ("return", "deny", "modify")
|
|
255
|
+
]
|
|
256
|
+
if body_effects:
|
|
257
|
+
if not self.selected:
|
|
258
|
+
raise ProtocolError("Elicitation effects require selected bodies")
|
|
259
|
+
binding = apply_effects(
|
|
260
|
+
original,
|
|
261
|
+
result,
|
|
262
|
+
self._resolve,
|
|
263
|
+
self.validator.validate,
|
|
264
|
+
self.context.principal,
|
|
265
|
+
body_effects,
|
|
266
|
+
)
|
|
267
|
+
answer = binding["result"]
|
|
268
|
+
encoded = json.dumps(
|
|
269
|
+
answer, allow_nan=False, ensure_ascii=False, separators=(",", ":")
|
|
270
|
+
).encode()
|
|
271
|
+
target = "candidate" if result is None else "content"
|
|
272
|
+
values[target] = deepcopy(answer)
|
|
273
|
+
changed = result is not None and not json_equal(
|
|
274
|
+
json.loads(self.selected["content"]), answer
|
|
275
|
+
)
|
|
276
|
+
if result is None or changed:
|
|
277
|
+
updates.append({"target": target, "data": encoded})
|
|
278
|
+
values["provenance"] = binding["provenance"]
|
|
279
|
+
values["externalCompletion"] = False
|
|
280
|
+
elif self.selected:
|
|
281
|
+
if result is None:
|
|
282
|
+
values["request"] = read_selected(
|
|
283
|
+
event["elicitation"],
|
|
284
|
+
"request",
|
|
285
|
+
self._resolve,
|
|
286
|
+
self.validator.validate,
|
|
287
|
+
)
|
|
288
|
+
else:
|
|
289
|
+
binding = validate_exchange(
|
|
290
|
+
original,
|
|
291
|
+
result,
|
|
292
|
+
self._resolve,
|
|
293
|
+
self.validator.validate,
|
|
294
|
+
self.context.principal,
|
|
295
|
+
)
|
|
296
|
+
values["content"] = binding.get("result")
|
|
297
|
+
values["externalCompletion"] = False
|
|
298
|
+
else:
|
|
299
|
+
from .compaction import validate_text_effects
|
|
300
|
+
|
|
301
|
+
boundary = "before" if kind.endswith(".before") else "after"
|
|
302
|
+
validate_text_effects(
|
|
303
|
+
boundary, effects, request["params"]["capabilities"], self.validator
|
|
304
|
+
)
|
|
305
|
+
for target, raw in self.selected.items():
|
|
306
|
+
if not self.items[target]["mediaType"].startswith("text/"):
|
|
307
|
+
raise ProtocolError("Compaction requires selected UTF-8 text")
|
|
308
|
+
values[target] = raw.decode("utf-8", errors="strict")
|
|
309
|
+
for effect in effects:
|
|
310
|
+
if effect["type"] == "modify":
|
|
311
|
+
target = effect["target"]
|
|
312
|
+
if target not in self.selected:
|
|
313
|
+
raise ProtocolError(
|
|
314
|
+
"Compaction modification requires selected content"
|
|
315
|
+
)
|
|
316
|
+
values[target] = effect["value"]
|
|
317
|
+
elif effect["type"] == "return":
|
|
318
|
+
if "instructions" not in self.selected:
|
|
319
|
+
raise ProtocolError(
|
|
320
|
+
"Supplied compaction summary requires selected instructions"
|
|
321
|
+
)
|
|
322
|
+
values["candidate"] = effect["value"]
|
|
323
|
+
for target, value in values.items():
|
|
324
|
+
data = value.encode("utf-8")
|
|
325
|
+
if target == "candidate" or data != self.selected[target]:
|
|
326
|
+
updates.append({"target": target, "data": data})
|
|
327
|
+
changed = changed or target != "candidate"
|
|
328
|
+
return {"changed": changed, "updates": updates, "values": values}
|
|
329
|
+
|
|
330
|
+
async def finalize(self, state: dict[str, Any]) -> dict[str, Any]:
|
|
331
|
+
state = deepcopy(state)
|
|
332
|
+
updates = state.pop("_content_updates", [])
|
|
333
|
+
references = {}
|
|
334
|
+
used = set(self.raw)
|
|
335
|
+
for update in updates:
|
|
336
|
+
data = update["data"]
|
|
337
|
+
reference = await self.context.upload(data)
|
|
338
|
+
self.validator.validate("content-reference", reference)
|
|
339
|
+
if (
|
|
340
|
+
reference["size"] != len(data)
|
|
341
|
+
or reference["sha256"] != hashlib.sha256(data).hexdigest()
|
|
342
|
+
):
|
|
343
|
+
raise ProtocolError(
|
|
344
|
+
"Uploaded descriptor does not match replacement bytes"
|
|
345
|
+
)
|
|
346
|
+
if reference["ref"] in used or reference["ref"] in self.context._identities:
|
|
347
|
+
raise ProtocolError(
|
|
348
|
+
"Modified content requires a new immutable reference"
|
|
349
|
+
)
|
|
350
|
+
used.add(reference["ref"])
|
|
351
|
+
self.context._identities[reference["ref"]] = (
|
|
352
|
+
reference["size"],
|
|
353
|
+
reference["sha256"],
|
|
354
|
+
)
|
|
355
|
+
target = update["target"]
|
|
356
|
+
references[target] = deepcopy(reference)
|
|
357
|
+
if target == "candidate" and state.get("candidate") is not None:
|
|
358
|
+
state["candidate"].setdefault("provenance", {}).update(
|
|
359
|
+
authenticatedSource=self.context.principal,
|
|
360
|
+
contentReference=deepcopy(reference),
|
|
361
|
+
)
|
|
362
|
+
if target != "candidate":
|
|
363
|
+
item = deepcopy(self.items[target])
|
|
364
|
+
item.pop("gap", None)
|
|
365
|
+
item.update(selection="body", body=deepcopy(reference))
|
|
366
|
+
if "size" in item:
|
|
367
|
+
item["size"] = len(data)
|
|
368
|
+
if "sha256" in item:
|
|
369
|
+
item["sha256"] = reference["sha256"]
|
|
370
|
+
put(state["event"], self.context.bindings[target], item)
|
|
371
|
+
candidate_ref = (
|
|
372
|
+
(state.get("candidate") or {}).get("provenance", {}).get("contentReference")
|
|
373
|
+
)
|
|
374
|
+
if isinstance(candidate_ref, dict) and self.context._identities.get(
|
|
375
|
+
candidate_ref.get("ref")
|
|
376
|
+
) == (candidate_ref.get("size"), candidate_ref.get("sha256")):
|
|
377
|
+
references.setdefault("candidate", deepcopy(candidate_ref))
|
|
378
|
+
if references:
|
|
379
|
+
state["content_references"] = references
|
|
380
|
+
check = deepcopy(self.request)
|
|
381
|
+
check["params"]["event"] = state["event"]
|
|
382
|
+
self.validator.validate("intercept-request", check)
|
|
383
|
+
return state
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
class OwnedContentSource:
|
|
387
|
+
"""Owned async read(size)/receive(max_bytes) stream with bounded snapshots.
|
|
388
|
+
|
|
389
|
+
Construction transfers ownership but performs no I/O. Close even unused
|
|
390
|
+
sources. Async aclose is required. Native deadline/cancellation scopes cover
|
|
391
|
+
processing; safety cleanup is shielded and bounded to one second.
|
|
392
|
+
"""
|
|
393
|
+
|
|
394
|
+
def __init__(
|
|
395
|
+
self,
|
|
396
|
+
stream: Any,
|
|
397
|
+
*,
|
|
398
|
+
max_bytes: int = 4 * 1024 * 1024,
|
|
399
|
+
timeout: float = 30.0,
|
|
400
|
+
expected_size: int | None = None,
|
|
401
|
+
expected_sha256: str | None = None,
|
|
402
|
+
) -> None:
|
|
403
|
+
if (
|
|
404
|
+
isinstance(max_bytes, bool)
|
|
405
|
+
or not isinstance(max_bytes, int)
|
|
406
|
+
or max_bytes < 0
|
|
407
|
+
):
|
|
408
|
+
raise ValueError("max_bytes must be a nonnegative integer")
|
|
409
|
+
if (
|
|
410
|
+
isinstance(timeout, bool)
|
|
411
|
+
or not isinstance(timeout, (float, int))
|
|
412
|
+
or not math.isfinite(timeout)
|
|
413
|
+
or timeout <= 0
|
|
414
|
+
):
|
|
415
|
+
raise ValueError("timeout must be finite and positive")
|
|
416
|
+
if expected_size is not None and (
|
|
417
|
+
isinstance(expected_size, bool)
|
|
418
|
+
or not isinstance(expected_size, int)
|
|
419
|
+
or expected_size < 0
|
|
420
|
+
or expected_size > max_bytes
|
|
421
|
+
):
|
|
422
|
+
raise ValueError("expected_size must fit max_bytes")
|
|
423
|
+
if expected_sha256 is not None and (
|
|
424
|
+
not isinstance(expected_sha256, str)
|
|
425
|
+
or re.fullmatch(r"[0-9a-f]{64}", expected_sha256) is None
|
|
426
|
+
):
|
|
427
|
+
raise ValueError("expected_sha256 must be a lowercase SHA-256 digest")
|
|
428
|
+
if not callable(getattr(stream, "aclose", None)) or not (
|
|
429
|
+
callable(getattr(stream, "read", None))
|
|
430
|
+
or callable(getattr(stream, "receive", None))
|
|
431
|
+
):
|
|
432
|
+
raise TypeError("source requires async read/receive and aclose")
|
|
433
|
+
self.stream = stream
|
|
434
|
+
self.max_bytes = max_bytes
|
|
435
|
+
self.timeout = timeout
|
|
436
|
+
self.expected_size = expected_size
|
|
437
|
+
self.expected_sha256 = expected_sha256
|
|
438
|
+
self._snapshot: bytes | None = None
|
|
439
|
+
self._failed = False
|
|
440
|
+
self._closed = False
|
|
441
|
+
self._stream_closed = False
|
|
442
|
+
self._close_done = anyio.Event()
|
|
443
|
+
self._lock = anyio.Lock()
|
|
444
|
+
self._scope: Any = None
|
|
445
|
+
|
|
446
|
+
async def _close_stream(self) -> None:
|
|
447
|
+
if self._stream_closed:
|
|
448
|
+
with anyio.move_on_after(1, shield=True):
|
|
449
|
+
await self._close_done.wait()
|
|
450
|
+
return
|
|
451
|
+
self._stream_closed = True
|
|
452
|
+
try:
|
|
453
|
+
with anyio.move_on_after(1, shield=True):
|
|
454
|
+
await self.stream.aclose()
|
|
455
|
+
finally:
|
|
456
|
+
self._close_done.set()
|
|
457
|
+
|
|
458
|
+
async def snapshot(self) -> bytes:
|
|
459
|
+
async with self._lock:
|
|
460
|
+
if self._closed or self._failed:
|
|
461
|
+
raise ProtocolError("Owned content source is closed or failed")
|
|
462
|
+
if self._snapshot is not None:
|
|
463
|
+
return self._snapshot
|
|
464
|
+
try:
|
|
465
|
+
with anyio.fail_after(self.timeout) as scope:
|
|
466
|
+
self._scope = scope
|
|
467
|
+
chunks = bytearray()
|
|
468
|
+
reader = getattr(self.stream, "read", None) or self.stream.receive
|
|
469
|
+
while True:
|
|
470
|
+
amount = min(65536, self.max_bytes - len(chunks) + 1)
|
|
471
|
+
try:
|
|
472
|
+
part = await reader(amount)
|
|
473
|
+
except anyio.EndOfStream:
|
|
474
|
+
break
|
|
475
|
+
if not isinstance(part, bytes):
|
|
476
|
+
raise ProtocolError("Owned content source must yield bytes")
|
|
477
|
+
if not part:
|
|
478
|
+
break
|
|
479
|
+
if (
|
|
480
|
+
len(part) > amount
|
|
481
|
+
or len(chunks) + len(part) > self.max_bytes
|
|
482
|
+
):
|
|
483
|
+
raise ProtocolError(
|
|
484
|
+
"Owned content exceeds configured limit"
|
|
485
|
+
)
|
|
486
|
+
chunks.extend(part)
|
|
487
|
+
await anyio.lowlevel.checkpoint()
|
|
488
|
+
data = bytes(chunks)
|
|
489
|
+
if self.expected_size is not None and self.expected_size != len(
|
|
490
|
+
data
|
|
491
|
+
):
|
|
492
|
+
raise ProtocolError("Owned content size expectation mismatch")
|
|
493
|
+
if (
|
|
494
|
+
self.expected_sha256 is not None
|
|
495
|
+
and self.expected_sha256 != hashlib.sha256(data).hexdigest()
|
|
496
|
+
):
|
|
497
|
+
raise ProtocolError("Owned content digest expectation mismatch")
|
|
498
|
+
if self._closed:
|
|
499
|
+
raise OperationCancelledError(
|
|
500
|
+
"Owned content source closed during preparation"
|
|
501
|
+
)
|
|
502
|
+
self._snapshot = data
|
|
503
|
+
if self._snapshot is None:
|
|
504
|
+
raise OperationCancelledError("Owned content preparation cancelled")
|
|
505
|
+
return self._snapshot
|
|
506
|
+
except BaseException:
|
|
507
|
+
self._failed = True
|
|
508
|
+
self._snapshot = None
|
|
509
|
+
raise
|
|
510
|
+
finally:
|
|
511
|
+
self._scope = None
|
|
512
|
+
interrupted = sys.exception() is not None
|
|
513
|
+
try:
|
|
514
|
+
await self._close_stream()
|
|
515
|
+
except BaseException:
|
|
516
|
+
self._failed = True
|
|
517
|
+
self._snapshot = None
|
|
518
|
+
if not interrupted:
|
|
519
|
+
raise
|
|
520
|
+
|
|
521
|
+
async def aclose(self) -> None:
|
|
522
|
+
self._closed = True
|
|
523
|
+
self._snapshot = None
|
|
524
|
+
if self._scope is not None:
|
|
525
|
+
self._scope.cancel()
|
|
526
|
+
await self._close_stream()
|
|
527
|
+
|
|
528
|
+
async def __aenter__(self) -> OwnedContentSource:
|
|
529
|
+
if self._closed:
|
|
530
|
+
raise ProtocolError("Owned content source is closed")
|
|
531
|
+
return self
|
|
532
|
+
|
|
533
|
+
async def __aexit__(self, *_exc: Any) -> None:
|
|
534
|
+
await self.aclose()
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
class ContentSources:
|
|
538
|
+
"""Single-operation sources bound to named SDK content slots.
|
|
539
|
+
|
|
540
|
+
Host events carry metadata-only items. Each project call requires its own
|
|
541
|
+
authorized destination uploader, and allocates references independently.
|
|
542
|
+
Slots are SDK adapters, not generated wire fields. Close in operation finally.
|
|
543
|
+
"""
|
|
544
|
+
|
|
545
|
+
def __init__(
|
|
546
|
+
self,
|
|
547
|
+
bindings: Mapping[str, OwnedContentSource],
|
|
548
|
+
*,
|
|
549
|
+
uploads: Mapping[str, Uploader] | None = None,
|
|
550
|
+
) -> None:
|
|
551
|
+
# Generated from shared schema metadata; no private parallel slot list.
|
|
552
|
+
from ._boundaries import CONTENT_SOURCE_SLOTS
|
|
553
|
+
|
|
554
|
+
self._slots: dict[str, tuple[str, tuple[str | int, ...]]] = {}
|
|
555
|
+
for binding, source in bindings.items():
|
|
556
|
+
if not isinstance(binding, str) or not isinstance(
|
|
557
|
+
source, OwnedContentSource
|
|
558
|
+
):
|
|
559
|
+
raise ValueError(
|
|
560
|
+
"Content bindings require generated slot names and owned sources"
|
|
561
|
+
)
|
|
562
|
+
match = re.fullmatch(r"([^\[\]]+)((?:\[(?:0|[1-9][0-9]*)\])*)", binding)
|
|
563
|
+
if match is None or match[1] not in CONTENT_SOURCE_SLOTS:
|
|
564
|
+
raise ValueError("Unknown generated content source slot")
|
|
565
|
+
kind, template = CONTENT_SOURCE_SLOTS[match[1]]
|
|
566
|
+
indices = [int(value) for value in re.findall(r"\[([0-9]+)\]", match[2])]
|
|
567
|
+
if len(indices) != template.count("*"):
|
|
568
|
+
raise ValueError(
|
|
569
|
+
"Content source slot requires one index per array component"
|
|
570
|
+
)
|
|
571
|
+
values = iter(indices)
|
|
572
|
+
path = tuple(
|
|
573
|
+
next(values) if component == "*" else component
|
|
574
|
+
for component in template
|
|
575
|
+
)
|
|
576
|
+
self._slots[binding] = (kind, path)
|
|
577
|
+
self.bindings = dict(bindings)
|
|
578
|
+
self.uploads = dict(uploads or {})
|
|
579
|
+
if any(
|
|
580
|
+
not isinstance(key, str) or not key or not callable(value)
|
|
581
|
+
for key, value in self.uploads.items()
|
|
582
|
+
):
|
|
583
|
+
raise ValueError(
|
|
584
|
+
"Uploads must map exact backend IDs to async upload callbacks"
|
|
585
|
+
)
|
|
586
|
+
self._closed = False
|
|
587
|
+
self._references: dict[str, tuple[int, str]] = {}
|
|
588
|
+
self._close_done = anyio.Event()
|
|
589
|
+
|
|
590
|
+
async def project(
|
|
591
|
+
self,
|
|
592
|
+
event: dict[str, Any],
|
|
593
|
+
selection: dict[str, str],
|
|
594
|
+
*,
|
|
595
|
+
upload: Uploader | None = None,
|
|
596
|
+
backend: str | None = None,
|
|
597
|
+
validator: Any,
|
|
598
|
+
include_native: bool = False,
|
|
599
|
+
) -> dict[str, Any]:
|
|
600
|
+
if self._closed:
|
|
601
|
+
raise ProtocolError("Content sources are closed")
|
|
602
|
+
view = project_content(event, selection, include_native)
|
|
603
|
+
for slot, source in self.bindings.items():
|
|
604
|
+
kind, path = self._slots[slot]
|
|
605
|
+
if event.get("type") != kind:
|
|
606
|
+
raise ProtocolError("Content source slot does not match event boundary")
|
|
607
|
+
try:
|
|
608
|
+
original = at(event, path)
|
|
609
|
+
item = at(view, path)
|
|
610
|
+
except (KeyError, IndexError, TypeError) as exc:
|
|
611
|
+
raise ProtocolError("Bound content source item is absent") from exc
|
|
612
|
+
validator.validate("content-item", original)
|
|
613
|
+
# A settled replacement or advanced caller-supplied reference is
|
|
614
|
+
# not the owned source. Never overwrite it with the original bytes.
|
|
615
|
+
reference = original.get("body")
|
|
616
|
+
if reference is not None and reference["ref"] not in self._references:
|
|
617
|
+
continue
|
|
618
|
+
available = deepcopy(original)
|
|
619
|
+
available["selection"] = "body"
|
|
620
|
+
selected = project_content(available, selection, True)
|
|
621
|
+
if selected["selection"] != "body" or original["selection"] == "omit":
|
|
622
|
+
continue
|
|
623
|
+
uploader = upload if upload is not None else self.uploads.get(backend)
|
|
624
|
+
if uploader is None:
|
|
625
|
+
raise ProtocolError(
|
|
626
|
+
"Selected content requires an authorized backend upload callback"
|
|
627
|
+
)
|
|
628
|
+
data = await source.snapshot()
|
|
629
|
+
digest = hashlib.sha256(data).hexdigest()
|
|
630
|
+
if any(
|
|
631
|
+
key in original and original[key] != value
|
|
632
|
+
for key, value in (("size", len(data)), ("sha256", digest))
|
|
633
|
+
):
|
|
634
|
+
raise ProtocolError(
|
|
635
|
+
"Content source metadata disagrees with actual bytes"
|
|
636
|
+
)
|
|
637
|
+
reference = await uploader(data)
|
|
638
|
+
validator.validate("content-reference", reference)
|
|
639
|
+
identity = (len(data), digest)
|
|
640
|
+
if (reference["size"], reference["sha256"]) != identity:
|
|
641
|
+
raise ProtocolError("Uploaded descriptor does not match source bytes")
|
|
642
|
+
known = self._references.get(reference["ref"])
|
|
643
|
+
if known is not None and known != identity:
|
|
644
|
+
raise ProtocolError("Immutable content reference changed")
|
|
645
|
+
self._references[reference["ref"]] = identity
|
|
646
|
+
item.pop("gap", None)
|
|
647
|
+
item.update(selection="body", body=deepcopy(reference))
|
|
648
|
+
validator.validate("content-item", item)
|
|
649
|
+
return view
|
|
650
|
+
|
|
651
|
+
async def aclose(self) -> None:
|
|
652
|
+
if self._closed:
|
|
653
|
+
with anyio.move_on_after(1, shield=True):
|
|
654
|
+
await self._close_done.wait()
|
|
655
|
+
return
|
|
656
|
+
self._closed = True
|
|
657
|
+
try:
|
|
658
|
+
with anyio.CancelScope(shield=True):
|
|
659
|
+
async with anyio.create_task_group() as group:
|
|
660
|
+
for source in set(self.bindings.values()):
|
|
661
|
+
group.start_soon(source.aclose)
|
|
662
|
+
finally:
|
|
663
|
+
self._references.clear()
|
|
664
|
+
self._close_done.set()
|
|
665
|
+
|
|
666
|
+
async def __aenter__(self) -> ContentSources:
|
|
667
|
+
if self._closed:
|
|
668
|
+
raise ProtocolError("Content sources are closed")
|
|
669
|
+
return self
|
|
670
|
+
|
|
671
|
+
async def __aexit__(self, *_exc: Any) -> None:
|
|
672
|
+
await self.aclose()
|