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.
Files changed (61) hide show
  1. agenthooksprotocol/__init__.py +64 -0
  2. agenthooksprotocol/_boundaries.py +498 -0
  3. agenthooksprotocol/_content.py +672 -0
  4. agenthooksprotocol/_diagnostics.py +93 -0
  5. agenthooksprotocol/_grants.py +597 -0
  6. agenthooksprotocol/_hooks.py +925 -0
  7. agenthooksprotocol/_models/__init__.py +18414 -0
  8. agenthooksprotocol/_models/capability.py +282 -0
  9. agenthooksprotocol/_models/content.py +58 -0
  10. agenthooksprotocol/_models/effect.py +52 -0
  11. agenthooksprotocol/_models/event.py +230 -0
  12. agenthooksprotocol/_models/registration.py +20 -0
  13. agenthooksprotocol/_models/subscription.py +24 -0
  14. agenthooksprotocol/_models/tool.py +132 -0
  15. agenthooksprotocol/_models/transport.py +8 -0
  16. agenthooksprotocol/ahp-codegen.lock.json +123 -0
  17. agenthooksprotocol/auth.py +343 -0
  18. agenthooksprotocol/candidate.py +10 -0
  19. agenthooksprotocol/capability.py +193 -0
  20. agenthooksprotocol/catalogue.py +201 -0
  21. agenthooksprotocol/codec.py +32 -0
  22. agenthooksprotocol/compaction.py +211 -0
  23. agenthooksprotocol/content.py +131 -0
  24. agenthooksprotocol/diagnostics.py +15 -0
  25. agenthooksprotocol/effect.py +160 -0
  26. agenthooksprotocol/elicitation.py +250 -0
  27. agenthooksprotocol/event.py +114 -0
  28. agenthooksprotocol/generated.py +11595 -0
  29. agenthooksprotocol/integrations/__init__.py +1 -0
  30. agenthooksprotocol/integrations/pydantic.py +28 -0
  31. agenthooksprotocol/interop.py +1075 -0
  32. agenthooksprotocol/lifecycle.py +294 -0
  33. agenthooksprotocol/lifecycle_client.py +417 -0
  34. agenthooksprotocol/lifecycle_server.py +490 -0
  35. agenthooksprotocol/lineage.py +115 -0
  36. agenthooksprotocol/observation_chain.py +151 -0
  37. agenthooksprotocol/path.py +4 -0
  38. agenthooksprotocol/permission.py +2 -0
  39. agenthooksprotocol/py.typed +0 -0
  40. agenthooksprotocol/registration.py +148 -0
  41. agenthooksprotocol/runtime.py +354 -0
  42. agenthooksprotocol/schemas.json +10557 -0
  43. agenthooksprotocol/server/__init__.py +8 -0
  44. agenthooksprotocol/server/_framing.py +33 -0
  45. agenthooksprotocol/server/_json.py +18 -0
  46. agenthooksprotocol/server/asgi.py +66 -0
  47. agenthooksprotocol/server/attachments.py +205 -0
  48. agenthooksprotocol/server/hooks.py +163 -0
  49. agenthooksprotocol/server/stdio.py +176 -0
  50. agenthooksprotocol/state.py +34 -0
  51. agenthooksprotocol/subscription.py +11 -0
  52. agenthooksprotocol/tool.py +63 -0
  53. agenthooksprotocol/transport.py +6 -0
  54. agenthooksprotocol/transports/__init__.py +14 -0
  55. agenthooksprotocol/transports/http.py +144 -0
  56. agenthooksprotocol/transports/stdio.py +92 -0
  57. agenthooksprotocol-0.1.0.dist-info/METADATA +370 -0
  58. agenthooksprotocol-0.1.0.dist-info/RECORD +61 -0
  59. agenthooksprotocol-0.1.0.dist-info/WHEEL +5 -0
  60. agenthooksprotocol-0.1.0.dist-info/licenses/LICENSE +202 -0
  61. 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()