semiont 0.6.12.dev2__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 (66) hide show
  1. semiont/__init__.py +20 -0
  2. semiont/bus.py +236 -0
  3. semiont/cache.py +345 -0
  4. semiont/cached.py +152 -0
  5. semiont/channel.py +81 -0
  6. semiont/channels.py +1306 -0
  7. semiont/client.py +161 -0
  8. semiont/error_codes.py +232 -0
  9. semiont/errors.py +147 -0
  10. semiont/event_bus.py +108 -0
  11. semiont/events.py +101 -0
  12. semiont/http/__init__.py +24 -0
  13. semiont/http/agent.py +232 -0
  14. semiont/http/content.py +234 -0
  15. semiont/http/exchange.py +200 -0
  16. semiont/http/oauth.py +346 -0
  17. semiont/http/session.py +191 -0
  18. semiont/http/sse.py +71 -0
  19. semiont/http/stream.py +683 -0
  20. semiont/http/transport.py +326 -0
  21. semiont/identifiers.py +114 -0
  22. semiont/identity.py +59 -0
  23. semiont/media_types.py +69 -0
  24. semiont/media_types_table.py +89 -0
  25. semiont/model.py +33 -0
  26. semiont/namespaces/__init__.py +19 -0
  27. semiont/namespaces/auth.py +32 -0
  28. semiont/namespaces/beckon.py +47 -0
  29. semiont/namespaces/bind.py +38 -0
  30. semiont/namespaces/browse.py +338 -0
  31. semiont/namespaces/follow.py +238 -0
  32. semiont/namespaces/frame.py +35 -0
  33. semiont/namespaces/gather.py +82 -0
  34. semiont/namespaces/job.py +94 -0
  35. semiont/namespaces/links.py +66 -0
  36. semiont/namespaces/live.py +301 -0
  37. semiont/namespaces/mark.py +174 -0
  38. semiont/namespaces/match.py +67 -0
  39. semiont/namespaces/refresher.py +283 -0
  40. semiont/namespaces/system.py +24 -0
  41. semiont/namespaces/yield_.py +101 -0
  42. semiont/oauth_clients.py +27 -0
  43. semiont/operations.py +337 -0
  44. semiont/py.typed +0 -0
  45. semiont/refresh.py +188 -0
  46. semiont/resume.py +95 -0
  47. semiont/retry.py +164 -0
  48. semiont/running.py +110 -0
  49. semiont/session.py +493 -0
  50. semiont/sign_in.py +54 -0
  51. semiont/sign_in_store.py +283 -0
  52. semiont/storage.py +139 -0
  53. semiont/telemetry.py +116 -0
  54. semiont/telemetry_table.py +121 -0
  55. semiont/testing/__init__.py +62 -0
  56. semiont/testing/client.py +53 -0
  57. semiont/testing/content.py +124 -0
  58. semiont/testing/gateway.py +85 -0
  59. semiont/testing/transport.py +341 -0
  60. semiont/timing.py +187 -0
  61. semiont/transport.py +531 -0
  62. semiont/types.py +5902 -0
  63. semiont/watched.py +111 -0
  64. semiont-0.6.12.dev2.dist-info/METADATA +533 -0
  65. semiont-0.6.12.dev2.dist-info/RECORD +66 -0
  66. semiont-0.6.12.dev2.dist-info/WHEEL +4 -0
semiont/transport.py ADDED
@@ -0,0 +1,531 @@
1
+ """The transport contract (`docs/protocol/TRANSPORT-CONTRACT.md`): what a client needs of whatever carries the bus.
2
+
3
+ A `Transport` is the bus over some carrier: an emit out, frames in, the
4
+ connection's state, the failures it met, the resource scopes it holds and the
5
+ replies it awaits. It carries names and JSON objects. What a channel's payload
6
+ is, as a type, is `semiont.channels`' to say, above this.
7
+
8
+ Beside the contract are the parts every transport builds it from: the frames
9
+ of each channel as their readers are given them (`FrameHub`), and the replies
10
+ its requests still await (`ReplyRouter`).
11
+ """
12
+
13
+ import asyncio
14
+ from collections.abc import AsyncGenerator, AsyncIterator, Callable, Collection, Generator, Mapping, Sequence
15
+ from dataclasses import dataclass, replace
16
+ from types import TracebackType
17
+ from typing import Final, Literal, Protocol, Self, final
18
+
19
+ from pydantic import JsonValue
20
+
21
+ from semiont.errors import BusRequestError, SemiontError
22
+ from semiont.events import Broadcast, Events
23
+ from semiont.identifiers import AnnotationId, JobId, ResourceId
24
+ from semiont.types import (
25
+ Agent,
26
+ CreateResourceResponse,
27
+ GetResourceResponse,
28
+ HealthResponse,
29
+ MediaTokenResponse,
30
+ ProtectedResourceMetadata,
31
+ StatusResponse,
32
+ UserResponse,
33
+ )
34
+ from semiont.watched import Watched
35
+
36
+ __all__ = [
37
+ "CONNECTION_STATES",
38
+ "CONNECTION_STATE_MAY_BECOME",
39
+ "ConnectionState",
40
+ "Content",
41
+ "ContentStream",
42
+ "ContentTransport",
43
+ "Frame",
44
+ "FrameHub",
45
+ "FrameSink",
46
+ "GatewayOperations",
47
+ "PendingReply",
48
+ "PutBinaryRequest",
49
+ "ReplyRouter",
50
+ "ResourceHold",
51
+ "TraceContext",
52
+ "Transport",
53
+ "Upload",
54
+ "UploadProgress",
55
+ "unsubscribed",
56
+ ]
57
+
58
+ type ConnectionState = Literal["initial", "connecting", "open", "reconnecting", "degraded", "unauthenticated", "closed"]
59
+ """Whether the bus can deliver (`docs/protocol/TRANSPORT-HTTP.md` § Connection lifecycle).
60
+
61
+ - `initial`: before the transport has started its stream.
62
+ - `connecting`: a connect is in flight and no stream is live.
63
+ - `open`: a stream is live. It is left only when the stream drops: a transport
64
+ that changes what its stream carries without missing anything stays open, so
65
+ `open` reached again always means something may have been missed.
66
+ - `reconnecting`: the stream dropped, or a connect failed with none live.
67
+ - `degraded`: has been reconnecting for longer than `DEGRADED_THRESHOLD_MS`.
68
+ - `unauthenticated`: not attempting. There is no credential, or the one there
69
+ is was refused. Ends when a different credential appears.
70
+ - `closed`: the transport was closed. Terminal.
71
+ """
72
+
73
+ CONNECTION_STATES: Final[tuple[ConnectionState, ...]] = (
74
+ "initial",
75
+ "connecting",
76
+ "open",
77
+ "reconnecting",
78
+ "degraded",
79
+ "unauthenticated",
80
+ "closed",
81
+ )
82
+
83
+ CONNECTION_STATE_MAY_BECOME: Final[Mapping[ConnectionState, frozenset[ConnectionState]]] = {
84
+ "initial": frozenset({"connecting", "unauthenticated", "closed"}),
85
+ "connecting": frozenset({"open", "reconnecting", "unauthenticated", "closed"}),
86
+ "open": frozenset({"reconnecting", "closed"}),
87
+ "reconnecting": frozenset({"connecting", "degraded", "unauthenticated", "closed"}),
88
+ "degraded": frozenset({"connecting", "unauthenticated", "closed"}),
89
+ "unauthenticated": frozenset({"connecting", "closed"}),
90
+ "closed": frozenset(),
91
+ }
92
+ """The states each state may be followed by."""
93
+
94
+
95
+ @final
96
+ @dataclass(frozen=True, slots=True, kw_only=True)
97
+ class TraceContext:
98
+ """A W3C trace context, as the wire carries one."""
99
+
100
+ traceparent: str
101
+ tracestate: str | None = None
102
+
103
+
104
+ @final
105
+ @dataclass(frozen=True, slots=True, kw_only=True)
106
+ class Frame:
107
+ """A frame as a transport delivers it: a payload, and what is beside it, never inside it."""
108
+
109
+ channel: str
110
+ payload: Mapping[str, JsonValue]
111
+ correlation_id: str | None = None
112
+ """What pairs a reply with its request."""
113
+ scope: ResourceId | None = None
114
+ """The resource whose scope the frame was delivered on."""
115
+ trace: TraceContext | None = None
116
+ """The trace the work done for this frame continues, when the frame was sent under one."""
117
+
118
+
119
+ def unsubscribed(channel: str) -> BusRequestError:
120
+ """The refusal a transport gives for a channel it can never deliver."""
121
+ return BusRequestError(
122
+ "bus.unsubscribed",
123
+ f"Transport is not subscribed to {channel}: a frame on it can never arrive on this connection. "
124
+ "Add the channel to this client's channels.",
125
+ )
126
+
127
+
128
+ class FrameSink(Protocol):
129
+ """Where a transport delivers every frame it receives: its client's own bus."""
130
+
131
+ def deliver(self, frame: Frame) -> None:
132
+ """Take one frame, as the transport delivered it."""
133
+ ...
134
+
135
+
136
+ @final
137
+ class FrameHub:
138
+ """The frames of every channel anybody listens to, a broadcast per channel, and the buses every frame is bridged into."""
139
+
140
+ def __init__(self) -> None:
141
+ self._channels: dict[str, Broadcast[Frame]] = {}
142
+ self._bridged: list[FrameSink] = []
143
+ self._closed = False
144
+
145
+ def bridge(self, bus: FrameSink) -> None:
146
+ """Deliver into `bus` every frame delivered here from now on, whatever its channel.
147
+
148
+ Each is published to everyone there, whichever scope it came on: a
149
+ client's bus keeps its scopes for what the client's own parts say to
150
+ each other.
151
+ """
152
+ if not self._closed:
153
+ self._bridged.append(bus)
154
+
155
+ def frames(self, channel: str) -> Events[Frame]:
156
+ """The frames delivered on `channel` from now on. Of a closed hub, events that have ended."""
157
+ broadcast = self._channels.get(channel)
158
+ if broadcast is None:
159
+ broadcast = Broadcast[Frame]()
160
+ if self._closed:
161
+ broadcast.close()
162
+ else:
163
+ self._channels[channel] = broadcast
164
+ return broadcast.listen()
165
+
166
+ def deliver(self, frame: Frame) -> int:
167
+ """Give `frame` to its channel's readers, and to every bus bridged into. Returns how many readers there were."""
168
+ for bus in self._bridged:
169
+ bus.deliver(frame if frame.scope is None else replace(frame, scope=None))
170
+ broadcast = self._channels.get(frame.channel)
171
+ return 0 if broadcast is None else broadcast.deliver(frame)
172
+
173
+ def close(self) -> None:
174
+ """End the frames of every reader, and of every later one."""
175
+ self._closed = True
176
+ for broadcast in self._channels.values():
177
+ broadcast.close()
178
+ self._channels.clear()
179
+ self._bridged.clear()
180
+
181
+
182
+ @final
183
+ class PendingReply:
184
+ """A reply a request awaits. Held with `with`: leaving stops the wait.
185
+
186
+ The transport then names the reply no more, and one that comes anyway
187
+ reaches nobody. A task cancelled while it waits leaves the same way.
188
+ """
189
+
190
+ def __init__(self, reply: asyncio.Future[Frame | None], forget: Callable[[], None]) -> None:
191
+ self._reply = reply
192
+ self._forget = forget
193
+
194
+ async def frame(self) -> Frame | None:
195
+ """The reply. Nothing when the transport closed before one came."""
196
+ return await self._reply
197
+
198
+ def __enter__(self) -> Self:
199
+ return self
200
+
201
+ def __exit__(self, kind: type[BaseException] | None, error: BaseException | None, trace: TracebackType | None) -> None:
202
+ self._forget()
203
+
204
+
205
+ @final
206
+ @dataclass(frozen=True, slots=True)
207
+ class _Awaited:
208
+ channels: frozenset[str]
209
+ reply: asyncio.Future[Frame | None]
210
+
211
+
212
+ @final
213
+ class ReplyRouter:
214
+ """The replies a transport's requests still await.
215
+
216
+ A reply is handed, by its correlation id, to the one request that awaits
217
+ it, so no amount of other traffic can cost a request its reply. And the ids
218
+ here are what a wire transport names when it opens a stream, so a reply
219
+ published while the stream was down is sent again.
220
+ """
221
+
222
+ def __init__(self) -> None:
223
+ self._awaited: dict[str, _Awaited] = {}
224
+ self._closed = False
225
+
226
+ def track(self, correlation_id: str, reply_channels: Collection[str]) -> PendingReply:
227
+ """Await the reply to `correlation_id` on one of `reply_channels`."""
228
+ reply: asyncio.Future[Frame | None] = asyncio.get_running_loop().create_future()
229
+ if self._closed:
230
+ reply.set_result(None)
231
+ else:
232
+ self._awaited[correlation_id] = _Awaited(frozenset(reply_channels), reply)
233
+
234
+ def forget() -> None:
235
+ awaited = self._awaited.get(correlation_id)
236
+ if awaited is not None and awaited.reply is reply:
237
+ del self._awaited[correlation_id]
238
+
239
+ return PendingReply(reply, forget)
240
+
241
+ def route(self, frame: Frame) -> None:
242
+ """Hand `frame` to the request that awaits it, if one does."""
243
+ if frame.correlation_id is None:
244
+ return
245
+ awaited = self._awaited.get(frame.correlation_id)
246
+ if awaited is None or frame.channel not in awaited.channels:
247
+ return
248
+ del self._awaited[frame.correlation_id]
249
+ if not awaited.reply.done():
250
+ awaited.reply.set_result(frame)
251
+
252
+ def awaited(self) -> list[str]:
253
+ """The correlation ids still awaited, in name order."""
254
+ return sorted(self._awaited)
255
+
256
+ def close(self) -> None:
257
+ """End every wait: each request still pending learns that no reply will come."""
258
+ self._closed = True
259
+ for awaited in self._awaited.values():
260
+ if not awaited.reply.done():
261
+ awaited.reply.set_result(None)
262
+ self._awaited.clear()
263
+
264
+
265
+ @final
266
+ class ResourceHold:
267
+ """One hold on a resource's scope. Let go by `release`, or by leaving a `with`. Letting go twice is letting go once."""
268
+
269
+ def __init__(self, release: Callable[[], None]) -> None:
270
+ self._release: Callable[[], None] | None = release
271
+
272
+ def release(self) -> None:
273
+ """Let go of the hold."""
274
+ release, self._release = self._release, None
275
+ if release is not None:
276
+ release()
277
+
278
+ def __enter__(self) -> Self:
279
+ return self
280
+
281
+ def __exit__(self, kind: type[BaseException] | None, error: BaseException | None, trace: TracebackType | None) -> None:
282
+ self.release()
283
+
284
+
285
+ class Transport(Protocol):
286
+ """The bus, over whatever carries it."""
287
+
288
+ @property
289
+ def base_url(self) -> str:
290
+ """What the transport speaks to: an origin over HTTP, a name for one with no wire."""
291
+ ...
292
+
293
+ async def emit(
294
+ self,
295
+ channel: str,
296
+ payload: Mapping[str, JsonValue],
297
+ *,
298
+ scope: ResourceId | None = None,
299
+ correlation_id: str | None = None,
300
+ ) -> int | None:
301
+ """Send one frame.
302
+
303
+ Returns when it has been accepted, with the number of subscribers it
304
+ reached, or with nothing when there is no count: an absent count is
305
+ never a zero. `scope` makes it a broadcast on that resource's scope.
306
+ Raises `TransportError` when it is refused, or never answered.
307
+ """
308
+ ...
309
+
310
+ def frames(self, channel: str) -> Events[Frame]:
311
+ """The frames delivered on `channel` from now on.
312
+
313
+ Raises `BusRequestError`, as `bus.unsubscribed`, for a channel this
314
+ transport can never deliver.
315
+ """
316
+ ...
317
+
318
+ def is_subscribed(self, channel: str) -> bool:
319
+ """Whether the transport's stream delivers `channel` with no scope held: whether a reply published there can reach this client."""
320
+ ...
321
+
322
+ def subscribe_to_resource(self, resource_id: ResourceId) -> ResourceHold:
323
+ """Take one hold on a resource's scope. The scope is on the stream from its first hold to its last release."""
324
+ ...
325
+
326
+ @property
327
+ def state(self) -> Watched[ConnectionState]:
328
+ """The connection's state: what it is, and what it becomes."""
329
+ ...
330
+
331
+ def failures(self) -> Events[SemiontError]:
332
+ """The failures the transport meets from now on: everything a server refused, and every request never answered."""
333
+ ...
334
+
335
+ def bridge_into(self, bus: FrameSink) -> None:
336
+ """Publish into `bus` every frame this transport delivers. The bus is its client's: a transport never makes one."""
337
+ ...
338
+
339
+ def track_reply(self, correlation_id: str, reply_channels: Collection[str]) -> PendingReply:
340
+ """Await the reply to `correlation_id` on one of `reply_channels`.
341
+
342
+ A request calls this before it emits, so its reply cannot arrive
343
+ unawaited.
344
+ """
345
+ ...
346
+
347
+ async def close(self) -> None:
348
+ """Stop: the state becomes `closed`, every reader's events end, and every request still pending fails as closed.
349
+
350
+ Closing twice is closing once.
351
+ """
352
+ ...
353
+
354
+
355
+ # ── Content ─────────────────────────────────────────────────────────────
356
+
357
+
358
+ @final
359
+ @dataclass(frozen=True, slots=True, kw_only=True)
360
+ class PutBinaryRequest:
361
+ """An upload: the bytes, and each field the resource is created with."""
362
+
363
+ name: str
364
+ file: bytes
365
+ format: str
366
+ """The media type of the bytes."""
367
+ storage_uri: str
368
+ entity_types: Sequence[str] = ()
369
+ language: str | None = None
370
+ source_annotation_id: AnnotationId | None = None
371
+ source_resource_id: ResourceId | None = None
372
+ generation_prompt: str | None = None
373
+ generator: Agent | Sequence[Agent] | None = None
374
+ """The agent or agents that generated it."""
375
+ job_id: JobId | None = None
376
+ """The job this resource fulfils, when a worker is creating it."""
377
+ is_draft: bool | None = None
378
+ clone_token: str | None = None
379
+ """A clone's provenance: with it, the resource is created as a clone."""
380
+ archive_original: bool | None = None
381
+ """Of a clone: archive the source once the clone exists."""
382
+
383
+
384
+ @final
385
+ @dataclass(frozen=True, slots=True, kw_only=True)
386
+ class UploadProgress:
387
+ """How much of an upload has been sent."""
388
+
389
+ bytes_uploaded: int
390
+ total_bytes: int
391
+ """The size of the whole request."""
392
+
393
+
394
+ @final
395
+ class Upload:
396
+ """An upload. Awaited, it gives the resource created; iterated, how much of it has been sent, ending when it has.
397
+
398
+ It begins when it is first awaited or iterated, and each is done once. Its
399
+ caller abandons it by cancelling the task that awaits it or reads its
400
+ progress: nothing more is sent, and nothing is reported.
401
+ """
402
+
403
+ def __init__(self, start: Callable[[Callable[[UploadProgress], None]], "asyncio.Task[CreateResourceResponse]"]) -> None:
404
+ self._start = start
405
+ self._sending: asyncio.Task[CreateResourceResponse] | None = None
406
+ self._reports: asyncio.Queue[UploadProgress | None] = asyncio.Queue()
407
+ self._awaited = False
408
+ self._iterated = False
409
+
410
+ def _begun(self) -> "asyncio.Task[CreateResourceResponse]":
411
+ if self._sending is None:
412
+ self._sending = self._start(self._reports.put_nowait)
413
+ self._sending.add_done_callback(self._ended)
414
+ return self._sending
415
+
416
+ def _ended(self, sending: "asyncio.Task[CreateResourceResponse]") -> None:
417
+ # What it reported before it ended has been queued, and it reports no more.
418
+ self._reports.put_nowait(None)
419
+ if not sending.cancelled():
420
+ # Its failure is its awaiter's to hear. Read here, it is not also said to be unheard.
421
+ sending.exception()
422
+
423
+ async def _outcome(self) -> CreateResourceResponse:
424
+ return await self._begun()
425
+
426
+ def __await__(self) -> Generator[object, None, CreateResourceResponse]:
427
+ if self._awaited:
428
+ raise RuntimeError("an upload is awaited once")
429
+ self._awaited = True
430
+ return self._outcome().__await__()
431
+
432
+ def __aiter__(self) -> AsyncIterator[UploadProgress]:
433
+ if self._iterated:
434
+ raise RuntimeError("an upload's progress is read once")
435
+ self._iterated = True
436
+ return self
437
+
438
+ async def __anext__(self) -> UploadProgress:
439
+ sending = self._begun()
440
+ try:
441
+ progress = await self._reports.get()
442
+ except asyncio.CancelledError:
443
+ sending.cancel()
444
+ raise
445
+ if progress is None:
446
+ # Left for a later read, which ends the same way.
447
+ self._reports.put_nowait(None)
448
+ raise StopAsyncIteration
449
+ return progress
450
+
451
+
452
+ @final
453
+ @dataclass(frozen=True, slots=True, kw_only=True)
454
+ class Content:
455
+ """A resource's bytes, with their media type."""
456
+
457
+ data: bytes
458
+ content_type: str
459
+
460
+
461
+ @final
462
+ class ContentStream:
463
+ """A resource's bytes as they arrive, with their media type.
464
+
465
+ Held with `async with`, which ends the read on the way out, whether or
466
+ not every byte was taken.
467
+ """
468
+
469
+ def __init__(self, content_type: str, pieces: AsyncGenerator[bytes]) -> None:
470
+ self.content_type: Final = content_type
471
+ self._pieces = pieces
472
+
473
+ def __aiter__(self) -> AsyncIterator[bytes]:
474
+ return self._pieces
475
+
476
+ async def aclose(self) -> None:
477
+ """End the read: nothing more is taken from the gateway."""
478
+ await self._pieces.aclose()
479
+
480
+ async def __aenter__(self) -> Self:
481
+ return self
482
+
483
+ async def __aexit__(self, kind: type[BaseException] | None, error: BaseException | None, trace: TracebackType | None) -> None:
484
+ await self.aclose()
485
+
486
+
487
+ class ContentTransport(Protocol):
488
+ """Bytes, which never ride the bus, and a resource's description as linked data."""
489
+
490
+ def put_binary(self, request: PutBinaryRequest) -> Upload:
491
+ """Upload `request`'s bytes as a new resource."""
492
+ ...
493
+
494
+ async def get_binary(self, resource_id: ResourceId) -> Content:
495
+ """A resource's bytes, unchanged, with their media type."""
496
+ ...
497
+
498
+ async def get_binary_stream(self, resource_id: ResourceId) -> ContentStream:
499
+ """The same, as a stream."""
500
+ ...
501
+
502
+ async def get_resource_graph(self, resource_id: ResourceId) -> GetResourceResponse:
503
+ """A resource's description: itself, its annotations and the references to it."""
504
+ ...
505
+
506
+
507
+ # ── The gateway's own operations ────────────────────────────────────────
508
+
509
+
510
+ class GatewayOperations(Protocol):
511
+ """What a gateway answers for itself. A transport with no gateway behind it does not offer it."""
512
+
513
+ async def get_current_user(self) -> UserResponse:
514
+ """Who the gateway says this token is."""
515
+ ...
516
+
517
+ async def get_media_token(self, resource_id: ResourceId) -> MediaTokenResponse:
518
+ """A token that lets a browser fetch one resource's bytes."""
519
+ ...
520
+
521
+ async def get_protected_resource_metadata(self) -> ProtectedResourceMetadata:
522
+ """Which issuer the knowledge base trusts (RFC 9728). Public: read before any token exists."""
523
+ ...
524
+
525
+ async def health_check(self) -> HealthResponse:
526
+ """Whether the gateway is serving."""
527
+ ...
528
+
529
+ async def get_status(self) -> StatusResponse:
530
+ """What the gateway is, and who it takes its caller to be."""
531
+ ...