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/events.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""A sequence of events, as each of its readers is given it.
|
|
2
|
+
|
|
3
|
+
Every reader has a queue of its own, and whoever delivers never waits on one.
|
|
4
|
+
So a reader that falls behind loses nothing and delays nobody: what it has not
|
|
5
|
+
read yet waits for it, in order. A reader that stops reading says so
|
|
6
|
+
(`aclose`); one that is simply dropped is forgotten with it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import weakref
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from types import TracebackType
|
|
13
|
+
from typing import Final, Self, final
|
|
14
|
+
|
|
15
|
+
__all__ = ["Broadcast", "Events"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@final
|
|
19
|
+
class _Ended:
|
|
20
|
+
"""What a reader's queue holds last: there is nothing after it."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
_ENDED: Final = _Ended()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@final
|
|
27
|
+
class Events[T]:
|
|
28
|
+
"""One reader's events, from the moment it began to listen: each one, in order.
|
|
29
|
+
|
|
30
|
+
Made by a `Broadcast`. It is iterated (`async for`), and can be held with
|
|
31
|
+
`async with`, which stops listening on the way out.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, queue: asyncio.Queue[T | _Ended], leave: Callable[["Events[T]"], None]) -> None:
|
|
35
|
+
self._queue = queue
|
|
36
|
+
self._leave = leave
|
|
37
|
+
self._over = False
|
|
38
|
+
|
|
39
|
+
def __aiter__(self) -> Self:
|
|
40
|
+
return self
|
|
41
|
+
|
|
42
|
+
async def __anext__(self) -> T:
|
|
43
|
+
if self._over:
|
|
44
|
+
raise StopAsyncIteration
|
|
45
|
+
item = await self._queue.get()
|
|
46
|
+
if isinstance(item, _Ended):
|
|
47
|
+
self._over = True
|
|
48
|
+
raise StopAsyncIteration
|
|
49
|
+
return item
|
|
50
|
+
|
|
51
|
+
async def aclose(self) -> None:
|
|
52
|
+
"""Stop listening: nothing more is kept for this reader, and its iteration is over."""
|
|
53
|
+
self._leave(self)
|
|
54
|
+
self._over = True
|
|
55
|
+
|
|
56
|
+
async def __aenter__(self) -> Self:
|
|
57
|
+
return self
|
|
58
|
+
|
|
59
|
+
async def __aexit__(self, kind: type[BaseException] | None, error: BaseException | None, trace: TracebackType | None) -> None:
|
|
60
|
+
await self.aclose()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@final
|
|
64
|
+
class Broadcast[T]:
|
|
65
|
+
"""What is delivered to every reader listening when it is delivered."""
|
|
66
|
+
|
|
67
|
+
def __init__(self) -> None:
|
|
68
|
+
self._readers: weakref.WeakKeyDictionary[Events[T], asyncio.Queue[T | _Ended]] = weakref.WeakKeyDictionary()
|
|
69
|
+
self._closed = False
|
|
70
|
+
|
|
71
|
+
def listen(self) -> Events[T]:
|
|
72
|
+
"""A reader of what is delivered from now on. Of a closed broadcast, one that has already ended."""
|
|
73
|
+
queue: asyncio.Queue[T | _Ended] = asyncio.Queue()
|
|
74
|
+
events = Events(queue, self._leave)
|
|
75
|
+
if self._closed:
|
|
76
|
+
queue.put_nowait(_ENDED)
|
|
77
|
+
else:
|
|
78
|
+
self._readers[events] = queue
|
|
79
|
+
return events
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def listening(self) -> int:
|
|
83
|
+
"""How many readers there are now."""
|
|
84
|
+
return len(self._readers)
|
|
85
|
+
|
|
86
|
+
def deliver(self, item: T) -> int:
|
|
87
|
+
"""Give `item` to every reader. Returns how many there were."""
|
|
88
|
+
queues = list(self._readers.values())
|
|
89
|
+
for queue in queues:
|
|
90
|
+
queue.put_nowait(item)
|
|
91
|
+
return len(queues)
|
|
92
|
+
|
|
93
|
+
def close(self) -> None:
|
|
94
|
+
"""End every reader's events, once it has read what was delivered, and every later reader's at once."""
|
|
95
|
+
self._closed = True
|
|
96
|
+
for queue in list(self._readers.values()):
|
|
97
|
+
queue.put_nowait(_ENDED)
|
|
98
|
+
self._readers.clear()
|
|
99
|
+
|
|
100
|
+
def _leave(self, events: Events[T]) -> None:
|
|
101
|
+
self._readers.pop(events, None)
|
semiont/http/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Semiont over HTTP: the transport a client reaches a knowledge base's gateway by, and how it is signed in there."""
|
|
2
|
+
|
|
3
|
+
from semiont.http.agent import AgentToken, Credential, ServiceToken
|
|
4
|
+
from semiont.http.content import HttpContentTransport
|
|
5
|
+
from semiont.http.exchange import TokenRefresher
|
|
6
|
+
from semiont.http.oauth import DeviceCode
|
|
7
|
+
from semiont.http.session import session_from_kept, sign_in_device, sign_out
|
|
8
|
+
from semiont.http.stream import Bookmarks, Timing
|
|
9
|
+
from semiont.http.transport import HttpTransport
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"AgentToken",
|
|
13
|
+
"Bookmarks",
|
|
14
|
+
"Credential",
|
|
15
|
+
"DeviceCode",
|
|
16
|
+
"HttpContentTransport",
|
|
17
|
+
"HttpTransport",
|
|
18
|
+
"ServiceToken",
|
|
19
|
+
"Timing",
|
|
20
|
+
"TokenRefresher",
|
|
21
|
+
"session_from_kept",
|
|
22
|
+
"sign_in_device",
|
|
23
|
+
"sign_out",
|
|
24
|
+
]
|
semiont/http/agent.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""An agent's token at a knowledge base's gateway.
|
|
2
|
+
|
|
3
|
+
A service account signs in at its issuer (OIDC discovery, then the
|
|
4
|
+
client-credentials grant), and its token is exchanged at
|
|
5
|
+
`POST /api/tokens/agent` for the token of the software agent
|
|
6
|
+
`(provider, model)`, which names the work it does. The gateway decides how
|
|
7
|
+
long the agent token lives.
|
|
8
|
+
|
|
9
|
+
An `AgentToken` is a transport's token source, as a person's kept sign-in is:
|
|
10
|
+
it gives the current token and each one after it (`token`), renews the token
|
|
11
|
+
by the schedule every Semiont client keeps (`semiont.session`), and renews it
|
|
12
|
+
at once when the gateway refuses it (`refresh`, a transport's refresher).
|
|
13
|
+
|
|
14
|
+
It is not a session. A person whose token cannot be renewed is signed out,
|
|
15
|
+
and signs in again. An agent whose renewal fails keeps the token it has and
|
|
16
|
+
tries again: a process holding a token that still works does not stop working
|
|
17
|
+
over one bad round trip.
|
|
18
|
+
|
|
19
|
+
A failure names the issuer and the client, and never the secret.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import time
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from types import TracebackType
|
|
26
|
+
from typing import Final, Self, final
|
|
27
|
+
|
|
28
|
+
import httpx
|
|
29
|
+
from pydantic import ValidationError
|
|
30
|
+
|
|
31
|
+
from semiont.errors import SemiontError, SignInError, TransportError
|
|
32
|
+
from semiont.http.oauth import issuer_client, object_in, post_form, seconds_in, text_in
|
|
33
|
+
from semiont.retry import REFRESH, RetryFacts, retry_after_ms, retry_with_backoff
|
|
34
|
+
from semiont.session import refresh_delay, renew_when_due, renewal_delay
|
|
35
|
+
from semiont.timing import HTTP_REQUEST_TIMEOUT_MS, REFRESH_RETRY
|
|
36
|
+
from semiont.types import AgentTokenRequest, AgentTokenResponse
|
|
37
|
+
from semiont.watched import Variable, Watched
|
|
38
|
+
|
|
39
|
+
__all__ = ["AgentToken", "Credential", "ServiceToken"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@final
|
|
43
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
44
|
+
class Credential:
|
|
45
|
+
"""A service account at an issuer."""
|
|
46
|
+
|
|
47
|
+
issuer: str
|
|
48
|
+
client_id: str
|
|
49
|
+
client_secret: str = field(repr=False)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _within() -> asyncio.Timeout:
|
|
53
|
+
return asyncio.timeout(HTTP_REQUEST_TIMEOUT_MS / 1000)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@final
|
|
57
|
+
class ServiceToken:
|
|
58
|
+
"""A service account's token, fetched on first use and renewed when it is due.
|
|
59
|
+
|
|
60
|
+
It is kept until it is due for renewal by the schedule every Semiont
|
|
61
|
+
client keeps: half its lifetime before it expires, at most
|
|
62
|
+
`REFRESH_BEFORE_EXP_MS`. A service reaches another as itself with this
|
|
63
|
+
token.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, credential: Credential) -> None:
|
|
67
|
+
self._credential: Final = credential
|
|
68
|
+
self._token_endpoint: str | None = None
|
|
69
|
+
self._held: tuple[str, float] | None = None
|
|
70
|
+
"""The token, and until when it is used, by the loop's clock."""
|
|
71
|
+
# One grant at a time: those who ask while one is under way are given what it brings.
|
|
72
|
+
self._asking: Final = asyncio.Lock()
|
|
73
|
+
|
|
74
|
+
async def _endpoint(self, http: httpx.AsyncClient) -> str:
|
|
75
|
+
if self._token_endpoint is not None:
|
|
76
|
+
return self._token_endpoint
|
|
77
|
+
issuer = self._credential.issuer
|
|
78
|
+
url = f"{issuer if issuer.endswith('/') else issuer + '/'}.well-known/openid-configuration"
|
|
79
|
+
try:
|
|
80
|
+
async with _within():
|
|
81
|
+
response = await http.get(url)
|
|
82
|
+
except (TimeoutError, httpx.HTTPError) as error:
|
|
83
|
+
raise SignInError("discovery", f"OIDC discovery for {issuer} got no answer: {error!r}") from error
|
|
84
|
+
status = response.status_code
|
|
85
|
+
if not response.is_success:
|
|
86
|
+
raise SignInError("discovery", f"OIDC discovery for {issuer} failed: HTTP {status} from {url}", status=status)
|
|
87
|
+
endpoint = text_in(object_in(response.content), "token_endpoint")
|
|
88
|
+
if endpoint is None:
|
|
89
|
+
raise SignInError("discovery", f"OIDC discovery for {issuer} returned no `token_endpoint`", status=status)
|
|
90
|
+
self._token_endpoint = endpoint
|
|
91
|
+
return endpoint
|
|
92
|
+
|
|
93
|
+
async def authorization(self) -> str:
|
|
94
|
+
"""The `Authorization` header's value: the account's token, renewed when it is due.
|
|
95
|
+
|
|
96
|
+
Its own `exp` and `iat` say how long it lives, or failing those the
|
|
97
|
+
grant's `expires_in`; a token that says neither is not kept.
|
|
98
|
+
"""
|
|
99
|
+
loop = asyncio.get_running_loop()
|
|
100
|
+
async with self._asking:
|
|
101
|
+
if self._held is not None and loop.time() < self._held[1]:
|
|
102
|
+
return f"Bearer {self._held[0]}"
|
|
103
|
+
issuer, client_id = self._credential.issuer, self._credential.client_id
|
|
104
|
+
async with issuer_client() as http:
|
|
105
|
+
endpoint = await self._endpoint(http)
|
|
106
|
+
try:
|
|
107
|
+
status, answer = await post_form(
|
|
108
|
+
http,
|
|
109
|
+
endpoint,
|
|
110
|
+
[("grant_type", "client_credentials"), ("client_id", client_id), ("client_secret", self._credential.client_secret)],
|
|
111
|
+
)
|
|
112
|
+
except SignInError as error:
|
|
113
|
+
raise SignInError("exchange", f"Client-credentials grant for {client_id} at {issuer} got no answer") from error
|
|
114
|
+
if not 200 <= status < 300:
|
|
115
|
+
# The status, never the body: an error body can echo the secret.
|
|
116
|
+
raise SignInError("exchange", f"Client-credentials grant for {client_id} at {issuer} failed (HTTP {status})", status=status)
|
|
117
|
+
token = text_in(answer, "access_token")
|
|
118
|
+
if token is None:
|
|
119
|
+
raise SignInError("exchange", f"Token endpoint for {issuer} returned no `access_token`", status=status)
|
|
120
|
+
lifetime = seconds_in(answer, "expires_in")
|
|
121
|
+
# Used until it is due for renewal, and never past its expiry: the
|
|
122
|
+
# schedule's floor is for a timer, which a token already expired
|
|
123
|
+
# must not turn into a loop, and nothing here loops.
|
|
124
|
+
renew_in = refresh_delay(token, now=time.time())
|
|
125
|
+
if renew_in is None and lifetime is not None:
|
|
126
|
+
renew_in = renewal_delay(lifetime, lifetime)
|
|
127
|
+
if renew_in is None:
|
|
128
|
+
self._held = None
|
|
129
|
+
else:
|
|
130
|
+
self._held = (token, loop.time() + (renew_in if lifetime is None else min(renew_in, lifetime)))
|
|
131
|
+
return f"Bearer {token}"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _transient(error: SemiontError) -> bool:
|
|
135
|
+
"""Whether a renewal that failed this way is worth another attempt."""
|
|
136
|
+
return REFRESH.retryable(RetryFacts(status=error.status, method="POST"))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@final
|
|
140
|
+
class AgentToken:
|
|
141
|
+
"""The token of the agent `(provider, model)` at the gateway at `gateway`.
|
|
142
|
+
|
|
143
|
+
Held with `async with`: the agent is signed in on the way in, which raises
|
|
144
|
+
when it cannot be (`SignInError` when its service account could not sign
|
|
145
|
+
in, `TransportError` when the gateway did not make the exchange), and it
|
|
146
|
+
is kept signed in until the way out.
|
|
147
|
+
|
|
148
|
+
A transport is given `token` as its token and `refresh` as its refresher.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
def __init__(self, gateway: str, *, provider: str, model: str, service: ServiceToken) -> None:
|
|
152
|
+
self._gateway: Final = gateway.rstrip("/")
|
|
153
|
+
self._agent: Final = AgentTokenRequest(provider=provider, model=model)
|
|
154
|
+
self._service: Final = service
|
|
155
|
+
self._token: Final[Variable[str | None]] = Variable(None)
|
|
156
|
+
self._closing: Final = asyncio.Event()
|
|
157
|
+
self._keeping: asyncio.Task[None] | None = None
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def gateway(self) -> str:
|
|
161
|
+
"""The gateway the agent signs in at."""
|
|
162
|
+
return self._gateway
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def token(self) -> Watched[str | None]:
|
|
166
|
+
"""The agent token: the current one, and each one after it."""
|
|
167
|
+
return self._token
|
|
168
|
+
|
|
169
|
+
async def __aenter__(self) -> Self:
|
|
170
|
+
if self._keeping is not None or self._closing.is_set():
|
|
171
|
+
raise RuntimeError("an agent is signed in once")
|
|
172
|
+
await self._renew()
|
|
173
|
+
self._keeping = asyncio.create_task(renew_when_due(self._token, self._renew_within_budget))
|
|
174
|
+
return self
|
|
175
|
+
|
|
176
|
+
async def __aexit__(self, kind: type[BaseException] | None, error: BaseException | None, trace: TracebackType | None) -> None:
|
|
177
|
+
self._closing.set()
|
|
178
|
+
keeping = self._keeping
|
|
179
|
+
if keeping is not None:
|
|
180
|
+
keeping.cancel()
|
|
181
|
+
await asyncio.gather(keeping, return_exceptions=True)
|
|
182
|
+
# Whatever ended the keeping that was not this closing is raised here.
|
|
183
|
+
if not keeping.cancelled():
|
|
184
|
+
await keeping
|
|
185
|
+
|
|
186
|
+
async def refresh(self) -> str | None:
|
|
187
|
+
"""The gateway refused the token: exchange again. The new token, or nothing when the exchange failed."""
|
|
188
|
+
try:
|
|
189
|
+
return await self._renew()
|
|
190
|
+
except SemiontError:
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
async def _renew(self) -> str:
|
|
194
|
+
"""Exchange again, and make the answer the token."""
|
|
195
|
+
token = await self._exchange()
|
|
196
|
+
self._token.set(token)
|
|
197
|
+
return token
|
|
198
|
+
|
|
199
|
+
async def _renew_within_budget(self) -> None:
|
|
200
|
+
"""A due renewal.
|
|
201
|
+
|
|
202
|
+
One that fails is tried again inside the renewal budget when the
|
|
203
|
+
failure is transient (`semiont.retry.REFRESH`); spent or refused, the
|
|
204
|
+
token is left as it is and the next is due an interval later, by the
|
|
205
|
+
schedule's floor.
|
|
206
|
+
"""
|
|
207
|
+
try:
|
|
208
|
+
await retry_with_backoff(REFRESH_RETRY, self._renew, retryable=_transient, give_up=self._closing)
|
|
209
|
+
except SemiontError:
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
async def _exchange(self) -> str:
|
|
213
|
+
authorization = await self._service.authorization()
|
|
214
|
+
url = f"{self._gateway}/api/tokens/agent"
|
|
215
|
+
try:
|
|
216
|
+
async with issuer_client() as http, _within():
|
|
217
|
+
response = await http.post(
|
|
218
|
+
url,
|
|
219
|
+
content=self._agent.model_dump_json(),
|
|
220
|
+
headers={"Authorization": authorization, "Content-Type": "application/json"},
|
|
221
|
+
)
|
|
222
|
+
except (TimeoutError, httpx.HTTPError) as error:
|
|
223
|
+
raise TransportError.without_response(f"{url} got no answer: {error!r}") from error
|
|
224
|
+
status = response.status_code
|
|
225
|
+
if not response.is_success:
|
|
226
|
+
raise TransportError.of_status(
|
|
227
|
+
f"the gateway refused the agent token exchange (HTTP {status})", status, retry_after_ms(response.headers.get("retry-after"))
|
|
228
|
+
)
|
|
229
|
+
try:
|
|
230
|
+
return AgentTokenResponse.model_validate_json(response.content).token
|
|
231
|
+
except ValidationError as error:
|
|
232
|
+
raise TransportError("error", f"{url} did not answer an agent token: {error}", status=status) from error
|
semiont/http/content.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Bytes over the gateway's HTTP transport, as a `semiont.transport.ContentTransport`.
|
|
2
|
+
|
|
3
|
+
An upload (`POST /resources`), a resource's bytes whole or as a stream
|
|
4
|
+
(`GET /resources/{id}`), and its description as linked data
|
|
5
|
+
(`GET /resources/{id}/jsonld`). Bytes never ride the bus.
|
|
6
|
+
|
|
7
|
+
It makes its requests as the bus transport makes its own, so one here is
|
|
8
|
+
refused, tried again and reported as any other; and each is made in a span of
|
|
9
|
+
the telemetry table (`content.put`, `content.get`, `content.get_graph`).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import uuid
|
|
14
|
+
from collections.abc import AsyncGenerator, AsyncIterator, Callable, Sequence
|
|
15
|
+
from typing import Final, Self, final, override
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
from pydantic import TypeAdapter
|
|
19
|
+
|
|
20
|
+
from semiont import telemetry
|
|
21
|
+
from semiont.errors import TransportError
|
|
22
|
+
from semiont.http.exchange import Exchange
|
|
23
|
+
from semiont.identifiers import ResourceId
|
|
24
|
+
from semiont.transport import Content, ContentStream, ContentTransport, PutBinaryRequest, Upload, UploadProgress
|
|
25
|
+
from semiont.types import Agent, CreateResourceResponse, GetResourceResponse, ResourceUpload
|
|
26
|
+
|
|
27
|
+
__all__ = ["HttpContentTransport", "form_of"]
|
|
28
|
+
|
|
29
|
+
# How much of an upload's body is handed to the connection at a time: the grain its progress is reported in.
|
|
30
|
+
_UPLOAD_PIECE_BYTES: Final = 64 * 1024
|
|
31
|
+
|
|
32
|
+
# The field of an upload's form that holds its bytes.
|
|
33
|
+
_FILE: Final = "file"
|
|
34
|
+
# The fields that come before the bytes, so whatever reads the form knows what the bytes are before it meets them.
|
|
35
|
+
_BEFORE_THE_BYTES: Final = ("name", "format", "storage_uri")
|
|
36
|
+
|
|
37
|
+
_AGENT: Final = TypeAdapter[Agent](Agent)
|
|
38
|
+
_AGENTS: Final = TypeAdapter[list[Agent]](list[Agent])
|
|
39
|
+
_NAMES: Final = TypeAdapter[list[str]](list[str])
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _flag(value: bool | None) -> str | None:
|
|
43
|
+
return None if value is None else "true" if value else "false"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _generated_by(generator: Agent | Sequence[Agent] | None) -> str | None:
|
|
47
|
+
if generator is None:
|
|
48
|
+
return None
|
|
49
|
+
if isinstance(generator, Sequence):
|
|
50
|
+
return _AGENTS.dump_json(list(generator), by_alias=True, exclude_unset=True).decode()
|
|
51
|
+
return _AGENT.dump_json(generator, by_alias=True, exclude_unset=True).decode()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def form_of(request: PutBinaryRequest) -> ResourceUpload:
|
|
55
|
+
"""An upload as its form carries it: every field text but the bytes. A field with nothing to say is left out."""
|
|
56
|
+
return ResourceUpload(
|
|
57
|
+
name=request.name,
|
|
58
|
+
file=request.file,
|
|
59
|
+
format=request.format,
|
|
60
|
+
storage_uri=request.storage_uri,
|
|
61
|
+
language=request.language or None,
|
|
62
|
+
entity_types=_NAMES.dump_json(list(request.entity_types)).decode() if request.entity_types else None,
|
|
63
|
+
source_annotation_id=request.source_annotation_id,
|
|
64
|
+
source_resource_id=request.source_resource_id,
|
|
65
|
+
generation_prompt=request.generation_prompt or None,
|
|
66
|
+
generator=_generated_by(request.generator),
|
|
67
|
+
job_id=request.job_id,
|
|
68
|
+
is_draft=_flag(request.is_draft),
|
|
69
|
+
clone_token=request.clone_token or None,
|
|
70
|
+
archive_original=_flag(request.archive_original),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _quoted(text: str) -> str:
|
|
75
|
+
"""`text` as a part's header states it: what would end the value, or the line, written as `%XX`."""
|
|
76
|
+
return text.replace('"', "%22").replace("\r", "%0D").replace("\n", "%0A")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@final
|
|
80
|
+
class _Form:
|
|
81
|
+
"""A `multipart/form-data` body: each field under its own name, and the bytes as the part `file`."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, request: PutBinaryRequest) -> None:
|
|
84
|
+
self.boundary: Final = f"semiont-{uuid.uuid4().hex}"
|
|
85
|
+
form = form_of(request)
|
|
86
|
+
fields = ResourceUpload.model_fields
|
|
87
|
+
before: list[str] = []
|
|
88
|
+
after: list[str] = []
|
|
89
|
+
for name, field in fields.items():
|
|
90
|
+
value: object = getattr(form, name)
|
|
91
|
+
if name == _FILE or value is None:
|
|
92
|
+
continue
|
|
93
|
+
if not isinstance(value, str):
|
|
94
|
+
raise TypeError(f"the upload form's {name} is not text")
|
|
95
|
+
part = f'--{self.boundary}\r\nContent-Disposition: form-data; name="{_quoted(field.alias or name)}"\r\n\r\n{value}\r\n'
|
|
96
|
+
(before if name in _BEFORE_THE_BYTES else after).append(part)
|
|
97
|
+
named = f'name="{fields[_FILE].alias or _FILE}"; filename="{_quoted(form.name)}"'
|
|
98
|
+
before.append(f"--{self.boundary}\r\nContent-Disposition: form-data; {named}\r\nContent-Type: {form.format}\r\n\r\n")
|
|
99
|
+
# The parts before the bytes, the bytes, and what follows them: the bytes are not copied to make the body.
|
|
100
|
+
self._parts: Final = ("".join(before).encode(), form.file, ("\r\n" + "".join(after) + f"--{self.boundary}--\r\n").encode())
|
|
101
|
+
self.size: Final = sum(len(part) for part in self._parts)
|
|
102
|
+
|
|
103
|
+
def pieces(self) -> list[memoryview]:
|
|
104
|
+
"""The body, a piece at a time."""
|
|
105
|
+
return [
|
|
106
|
+
memoryview(part)[start : start + _UPLOAD_PIECE_BYTES]
|
|
107
|
+
for part in self._parts
|
|
108
|
+
for start in range(0, len(part), _UPLOAD_PIECE_BYTES)
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@final
|
|
113
|
+
class _Progress:
|
|
114
|
+
"""How much of an upload has been reported sent, over every sending of it.
|
|
115
|
+
|
|
116
|
+
A sending that repeats an earlier one, as the second attempt of a refused
|
|
117
|
+
upload does, reports only what goes past it: progress never goes back.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
def __init__(self, total: int, report: Callable[[UploadProgress], None]) -> None:
|
|
121
|
+
self._total = total
|
|
122
|
+
self._report = report
|
|
123
|
+
self._reported = 0
|
|
124
|
+
|
|
125
|
+
def sent(self, so_far: int) -> None:
|
|
126
|
+
if so_far > self._reported:
|
|
127
|
+
self._reported = so_far
|
|
128
|
+
self._report(UploadProgress(bytes_uploaded=so_far, total_bytes=self._total))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@final
|
|
132
|
+
class _Sending:
|
|
133
|
+
"""An upload's body as it is sent once: each piece reported as it is handed to the connection."""
|
|
134
|
+
|
|
135
|
+
def __init__(self, form: _Form, progress: _Progress) -> None:
|
|
136
|
+
self._pieces = iter(form.pieces())
|
|
137
|
+
self._sent = 0
|
|
138
|
+
self._progress = progress
|
|
139
|
+
|
|
140
|
+
def __aiter__(self) -> Self:
|
|
141
|
+
return self
|
|
142
|
+
|
|
143
|
+
async def __anext__(self) -> bytes:
|
|
144
|
+
piece = next(self._pieces, None)
|
|
145
|
+
if piece is None:
|
|
146
|
+
raise StopAsyncIteration
|
|
147
|
+
self._sent += len(piece)
|
|
148
|
+
self._progress.sent(self._sent)
|
|
149
|
+
return bytes(piece)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _path_of(resource_id: ResourceId) -> str:
|
|
153
|
+
"""Where a resource is read.
|
|
154
|
+
|
|
155
|
+
Its id is one segment of the path as it is: the rule an id is held to
|
|
156
|
+
admits nothing a path reads as its own.
|
|
157
|
+
"""
|
|
158
|
+
return f"/resources/{resource_id}"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _content_type(response: httpx.Response) -> str:
|
|
162
|
+
stated: str | None = response.headers.get("content-type")
|
|
163
|
+
return stated or "application/octet-stream"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _interrupted(resource_id: ResourceId, error: httpx.HTTPError) -> TransportError:
|
|
167
|
+
"""A read whose bytes stopped coming."""
|
|
168
|
+
return TransportError.without_response(f"GET {_path_of(resource_id)} ended before its bytes did: {error!r}")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@final
|
|
172
|
+
class HttpContentTransport(ContentTransport):
|
|
173
|
+
"""Content over the gateway an `HttpTransport` speaks to. It is that transport's `content`."""
|
|
174
|
+
|
|
175
|
+
def __init__(self, exchange: Exchange) -> None:
|
|
176
|
+
self._exchange = exchange
|
|
177
|
+
|
|
178
|
+
@override
|
|
179
|
+
def put_binary(self, request: PutBinaryRequest) -> Upload:
|
|
180
|
+
def start(report: Callable[[UploadProgress], None]) -> asyncio.Task[CreateResourceResponse]:
|
|
181
|
+
return self._exchange.run(self._put(request, report))
|
|
182
|
+
|
|
183
|
+
return Upload(start)
|
|
184
|
+
|
|
185
|
+
async def _put(self, request: PutBinaryRequest, report: Callable[[UploadProgress], None]) -> CreateResourceResponse:
|
|
186
|
+
form = _Form(request)
|
|
187
|
+
progress = _Progress(form.size, report)
|
|
188
|
+
with telemetry.putting(request.format, len(request.file)):
|
|
189
|
+
# No deadline: how long an upload takes is how large the resource is.
|
|
190
|
+
return await self._exchange.answer(
|
|
191
|
+
CreateResourceResponse,
|
|
192
|
+
"POST",
|
|
193
|
+
"/resources",
|
|
194
|
+
headers={"Content-Type": f"multipart/form-data; boundary={form.boundary}", "Content-Length": str(form.size)},
|
|
195
|
+
content=lambda: _Sending(form, progress),
|
|
196
|
+
at_length=True,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
@override
|
|
200
|
+
async def get_binary(self, resource_id: ResourceId) -> Content:
|
|
201
|
+
with telemetry.getting(resource_id, stream=False):
|
|
202
|
+
# The deadline is on the bytes beginning to arrive: how long they take after that is how many there are.
|
|
203
|
+
response = await self._exchange.begin("GET", _path_of(resource_id))
|
|
204
|
+
try:
|
|
205
|
+
data = await response.aread()
|
|
206
|
+
except httpx.HTTPError as error:
|
|
207
|
+
raise self._exchange.failed(_interrupted(resource_id, error)) from error
|
|
208
|
+
finally:
|
|
209
|
+
await response.aclose()
|
|
210
|
+
return Content(data=data, content_type=_content_type(response))
|
|
211
|
+
|
|
212
|
+
@override
|
|
213
|
+
async def get_binary_stream(self, resource_id: ResourceId) -> ContentStream:
|
|
214
|
+
with telemetry.getting(resource_id, stream=True):
|
|
215
|
+
response = await self._exchange.begin("GET", _path_of(resource_id))
|
|
216
|
+
return ContentStream(_content_type(response), self._arriving(resource_id, response))
|
|
217
|
+
|
|
218
|
+
async def _arriving(self, resource_id: ResourceId, response: httpx.Response) -> AsyncGenerator[bytes]:
|
|
219
|
+
pieces: AsyncIterator[bytes] = response.aiter_bytes()
|
|
220
|
+
try:
|
|
221
|
+
async for piece in pieces:
|
|
222
|
+
yield piece
|
|
223
|
+
except httpx.HTTPError as error:
|
|
224
|
+
raise self._exchange.failed(_interrupted(resource_id, error)) from error
|
|
225
|
+
finally:
|
|
226
|
+
# A read left early is ended here, by the one who left it.
|
|
227
|
+
if isinstance(pieces, AsyncGenerator):
|
|
228
|
+
await pieces.aclose()
|
|
229
|
+
await response.aclose()
|
|
230
|
+
|
|
231
|
+
@override
|
|
232
|
+
async def get_resource_graph(self, resource_id: ResourceId) -> GetResourceResponse:
|
|
233
|
+
with telemetry.getting_graph(resource_id):
|
|
234
|
+
return await self._exchange.answer(GetResourceResponse, "GET", f"{_path_of(resource_id)}/jsonld")
|