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.
- semiont/__init__.py +20 -0
- semiont/bus.py +236 -0
- semiont/cache.py +345 -0
- semiont/cached.py +152 -0
- semiont/channel.py +81 -0
- semiont/channels.py +1306 -0
- semiont/client.py +161 -0
- semiont/error_codes.py +232 -0
- semiont/errors.py +147 -0
- semiont/event_bus.py +108 -0
- semiont/events.py +101 -0
- semiont/http/__init__.py +24 -0
- semiont/http/agent.py +232 -0
- semiont/http/content.py +234 -0
- semiont/http/exchange.py +200 -0
- semiont/http/oauth.py +346 -0
- semiont/http/session.py +191 -0
- semiont/http/sse.py +71 -0
- semiont/http/stream.py +683 -0
- semiont/http/transport.py +326 -0
- semiont/identifiers.py +114 -0
- semiont/identity.py +59 -0
- semiont/media_types.py +69 -0
- semiont/media_types_table.py +89 -0
- semiont/model.py +33 -0
- semiont/namespaces/__init__.py +19 -0
- semiont/namespaces/auth.py +32 -0
- semiont/namespaces/beckon.py +47 -0
- semiont/namespaces/bind.py +38 -0
- semiont/namespaces/browse.py +338 -0
- semiont/namespaces/follow.py +238 -0
- semiont/namespaces/frame.py +35 -0
- semiont/namespaces/gather.py +82 -0
- semiont/namespaces/job.py +94 -0
- semiont/namespaces/links.py +66 -0
- semiont/namespaces/live.py +301 -0
- semiont/namespaces/mark.py +174 -0
- semiont/namespaces/match.py +67 -0
- semiont/namespaces/refresher.py +283 -0
- semiont/namespaces/system.py +24 -0
- semiont/namespaces/yield_.py +101 -0
- semiont/oauth_clients.py +27 -0
- semiont/operations.py +337 -0
- semiont/py.typed +0 -0
- semiont/refresh.py +188 -0
- semiont/resume.py +95 -0
- semiont/retry.py +164 -0
- semiont/running.py +110 -0
- semiont/session.py +493 -0
- semiont/sign_in.py +54 -0
- semiont/sign_in_store.py +283 -0
- semiont/storage.py +139 -0
- semiont/telemetry.py +116 -0
- semiont/telemetry_table.py +121 -0
- semiont/testing/__init__.py +62 -0
- semiont/testing/client.py +53 -0
- semiont/testing/content.py +124 -0
- semiont/testing/gateway.py +85 -0
- semiont/testing/transport.py +341 -0
- semiont/timing.py +187 -0
- semiont/transport.py +531 -0
- semiont/types.py +5902 -0
- semiont/watched.py +111 -0
- semiont-0.6.12.dev2.dist-info/METADATA +533 -0
- semiont-0.6.12.dev2.dist-info/RECORD +66 -0
- 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
|
+
...
|