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/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)
@@ -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
@@ -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")