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
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""A knowledge base's bus over its gateway's HTTP transport (`docs/protocol/TRANSPORT-HTTP.md`), as a `semiont.transport.Transport`.
|
|
2
|
+
|
|
3
|
+
One stream, `POST /bus/subscribe`, naming the client's global channels and an
|
|
4
|
+
entry per resource scope it holds; and `POST /bus/emit` for what it sends. The
|
|
5
|
+
stream is `semiont.http.stream`'s. The gateway's own operations ride plain
|
|
6
|
+
request and response, and so does content (`HttpTransport.content`).
|
|
7
|
+
|
|
8
|
+
What crosses the wire is told to OpenTelemetry as the telemetry table says:
|
|
9
|
+
each emit is counted and sent in a span whose trace travels with it, and each
|
|
10
|
+
frame's arrival is marked in the trace the frame was sent under.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import uuid
|
|
15
|
+
from collections.abc import Collection, Mapping, Sequence
|
|
16
|
+
from types import TracebackType
|
|
17
|
+
from typing import Final, Self, final, override
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
from pydantic import JsonValue, ValidationError
|
|
21
|
+
|
|
22
|
+
from semiont import telemetry
|
|
23
|
+
from semiont.channels import BRIDGED_CHANNELS, RESOURCE_SCOPED_CHANNELS
|
|
24
|
+
from semiont.errors import SemiontError, TransportError
|
|
25
|
+
from semiont.events import Broadcast, Events
|
|
26
|
+
from semiont.http.content import HttpContentTransport
|
|
27
|
+
from semiont.http.exchange import Exchange, TokenRefresher
|
|
28
|
+
from semiont.http.stream import Bookmarks, Stream, Timing
|
|
29
|
+
from semiont.identifiers import ResourceId
|
|
30
|
+
from semiont.retry import BOOT, RetryFacts, retry_after_ms, retry_with_backoff
|
|
31
|
+
from semiont.timing import EMIT_TIMEOUT_MS
|
|
32
|
+
from semiont.transport import (
|
|
33
|
+
ConnectionState,
|
|
34
|
+
ContentTransport,
|
|
35
|
+
Frame,
|
|
36
|
+
FrameHub,
|
|
37
|
+
FrameSink,
|
|
38
|
+
GatewayOperations,
|
|
39
|
+
PendingReply,
|
|
40
|
+
ReplyRouter,
|
|
41
|
+
ResourceHold,
|
|
42
|
+
Transport,
|
|
43
|
+
unsubscribed,
|
|
44
|
+
)
|
|
45
|
+
from semiont.types import (
|
|
46
|
+
BusEmitAccepted,
|
|
47
|
+
BusEmitRequest,
|
|
48
|
+
HealthResponse,
|
|
49
|
+
MediaTokenRequest,
|
|
50
|
+
MediaTokenResponse,
|
|
51
|
+
ProtectedResourceMetadata,
|
|
52
|
+
StatusResponse,
|
|
53
|
+
UserResponse,
|
|
54
|
+
)
|
|
55
|
+
from semiont.watched import Watched
|
|
56
|
+
|
|
57
|
+
__all__ = ["HttpTransport"]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _worth_another_emit(error: SemiontError) -> bool:
|
|
61
|
+
"""Whether an emit that failed this way is made again: nothing answered, or the gateway said "not now"."""
|
|
62
|
+
return error.status is None or BOOT.retryable(RetryFacts(status=error.status, method="POST"))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@final
|
|
66
|
+
class HttpTransport(Transport, GatewayOperations):
|
|
67
|
+
"""A transport to one gateway. Held with `async with`: its stream opens inside, once there is a token, and ends on the way out.
|
|
68
|
+
|
|
69
|
+
`token` is the token every request carries: the present one, and each one
|
|
70
|
+
after it. With none the transport sends nothing and waits for one.
|
|
71
|
+
`refresher` is asked for another when the gateway refuses the one it has;
|
|
72
|
+
with none, a refused token is waited out until `token` changes.
|
|
73
|
+
`channels` are the global channels its stream names: every channel a
|
|
74
|
+
client hears, unless a process that awaits only some operations names
|
|
75
|
+
their reply channels and no others. With `bookmarks`, each scope's stream
|
|
76
|
+
begins where they say the client's last life left it; with none, at the
|
|
77
|
+
present.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
base_url: str,
|
|
83
|
+
*,
|
|
84
|
+
token: Watched[str | None],
|
|
85
|
+
refresher: TokenRefresher | None = None,
|
|
86
|
+
channels: Sequence[str] = BRIDGED_CHANNELS,
|
|
87
|
+
timing: Timing | None = None,
|
|
88
|
+
bookmarks: Bookmarks | None = None,
|
|
89
|
+
) -> None:
|
|
90
|
+
self._base_url: Final = base_url.rstrip("/")
|
|
91
|
+
self._token: Final = token
|
|
92
|
+
self._global: Final = tuple(channels)
|
|
93
|
+
self._timing: Final = Timing() if timing is None else timing
|
|
94
|
+
# This client's address for correlated replies: one per transport, not
|
|
95
|
+
# per connection, so both streams of a handoff present the same one.
|
|
96
|
+
self._client_id: Final = str(uuid.uuid4())
|
|
97
|
+
self._hub: Final = FrameHub()
|
|
98
|
+
self._router: Final = ReplyRouter()
|
|
99
|
+
self._failures: Final[Broadcast[SemiontError]] = Broadcast()
|
|
100
|
+
# It reads no environment variable: no proxy and no certificate file it was not given.
|
|
101
|
+
self._http: Final = httpx.AsyncClient(timeout=None, trust_env=False)
|
|
102
|
+
self._stream: Final = Stream(
|
|
103
|
+
base_url=self._base_url,
|
|
104
|
+
http=self._http,
|
|
105
|
+
token=token,
|
|
106
|
+
refresher=refresher,
|
|
107
|
+
client_id=self._client_id,
|
|
108
|
+
channels=self._global,
|
|
109
|
+
timing=self._timing,
|
|
110
|
+
hub=self._hub,
|
|
111
|
+
router=self._router,
|
|
112
|
+
failures=self._failures,
|
|
113
|
+
bookmarks=bookmarks,
|
|
114
|
+
)
|
|
115
|
+
self._closing: Final = asyncio.Event()
|
|
116
|
+
self._exchange: Final = Exchange(
|
|
117
|
+
base_url=self._base_url,
|
|
118
|
+
http=self._http,
|
|
119
|
+
token=token,
|
|
120
|
+
refresher=refresher,
|
|
121
|
+
failures=self._failures,
|
|
122
|
+
deadline_ms=self._timing.http_request_ms,
|
|
123
|
+
closing=self._closing,
|
|
124
|
+
)
|
|
125
|
+
self._content: Final = HttpContentTransport(self._exchange)
|
|
126
|
+
self._holds: Final[dict[ResourceId, int]] = {}
|
|
127
|
+
"""How many holds each resource's scope has."""
|
|
128
|
+
self._running: asyncio.Task[None] | None = None
|
|
129
|
+
self._closed: Final = asyncio.Event()
|
|
130
|
+
self._emitting = 0
|
|
131
|
+
self._quiet: Final = asyncio.Event()
|
|
132
|
+
"""Set while no emit of this transport is under way."""
|
|
133
|
+
self._quiet.set()
|
|
134
|
+
|
|
135
|
+
async def __aenter__(self) -> Self:
|
|
136
|
+
if self._running is not None or self._closing.is_set():
|
|
137
|
+
raise RuntimeError("a transport is opened once")
|
|
138
|
+
self._running = asyncio.create_task(self._run())
|
|
139
|
+
return self
|
|
140
|
+
|
|
141
|
+
async def __aexit__(self, kind: type[BaseException] | None, error: BaseException | None, trace: TracebackType | None) -> None:
|
|
142
|
+
await self.close()
|
|
143
|
+
|
|
144
|
+
async def _run(self) -> None:
|
|
145
|
+
"""The transport's life: its stream, until it is closed; then the emits under way, each to its end."""
|
|
146
|
+
try:
|
|
147
|
+
await self._stream.run()
|
|
148
|
+
await self._quiet.wait()
|
|
149
|
+
finally:
|
|
150
|
+
# A failure is reported for as long as an emit can fail: to the end.
|
|
151
|
+
self._failures.close()
|
|
152
|
+
try:
|
|
153
|
+
# Whatever else is on the wire ends here, as a request that got no answer.
|
|
154
|
+
await self._http.aclose()
|
|
155
|
+
await self._exchange.ended()
|
|
156
|
+
finally:
|
|
157
|
+
self._closed.set()
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
@override
|
|
161
|
+
def base_url(self) -> str:
|
|
162
|
+
return self._base_url
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
@override
|
|
166
|
+
def state(self) -> Watched[ConnectionState]:
|
|
167
|
+
return self._stream.state
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def content(self) -> ContentTransport:
|
|
171
|
+
"""Bytes, and a resource's description, from the same gateway."""
|
|
172
|
+
return self._content
|
|
173
|
+
|
|
174
|
+
@override
|
|
175
|
+
def failures(self) -> Events[SemiontError]:
|
|
176
|
+
return self._failures.listen()
|
|
177
|
+
|
|
178
|
+
@override
|
|
179
|
+
def is_subscribed(self, channel: str) -> bool:
|
|
180
|
+
return channel in self._global
|
|
181
|
+
|
|
182
|
+
@override
|
|
183
|
+
def frames(self, channel: str) -> Events[Frame]:
|
|
184
|
+
# A channel this stream can never carry would be a reader that is
|
|
185
|
+
# never given anything, which reads as a quiet system: refused at the
|
|
186
|
+
# call. A resource-scoped channel passes with no scope held yet, since
|
|
187
|
+
# frames flow the moment one is.
|
|
188
|
+
if not self.is_subscribed(channel) and channel not in RESOURCE_SCOPED_CHANNELS:
|
|
189
|
+
raise unsubscribed(channel)
|
|
190
|
+
return self._hub.frames(channel)
|
|
191
|
+
|
|
192
|
+
@override
|
|
193
|
+
def track_reply(self, correlation_id: str, reply_channels: Collection[str]) -> PendingReply:
|
|
194
|
+
return self._router.track(correlation_id, reply_channels)
|
|
195
|
+
|
|
196
|
+
@override
|
|
197
|
+
def bridge_into(self, bus: FrameSink) -> None:
|
|
198
|
+
self._hub.bridge(bus)
|
|
199
|
+
|
|
200
|
+
@override
|
|
201
|
+
def subscribe_to_resource(self, resource_id: ResourceId) -> ResourceHold:
|
|
202
|
+
held = self._holds.get(resource_id, 0)
|
|
203
|
+
self._holds[resource_id] = held + 1
|
|
204
|
+
if held == 0:
|
|
205
|
+
self._stream.take_scope(resource_id)
|
|
206
|
+
|
|
207
|
+
def release() -> None:
|
|
208
|
+
remaining = self._holds[resource_id] - 1
|
|
209
|
+
if remaining > 0:
|
|
210
|
+
self._holds[resource_id] = remaining
|
|
211
|
+
return
|
|
212
|
+
del self._holds[resource_id]
|
|
213
|
+
self._stream.let_go_of_scope(resource_id)
|
|
214
|
+
|
|
215
|
+
return ResourceHold(release)
|
|
216
|
+
|
|
217
|
+
@override
|
|
218
|
+
async def emit(
|
|
219
|
+
self,
|
|
220
|
+
channel: str,
|
|
221
|
+
payload: Mapping[str, JsonValue],
|
|
222
|
+
*,
|
|
223
|
+
scope: ResourceId | None = None,
|
|
224
|
+
correlation_id: str | None = None,
|
|
225
|
+
) -> int | None:
|
|
226
|
+
if self._running is None:
|
|
227
|
+
raise RuntimeError("the transport is not open: hold it with `async with`")
|
|
228
|
+
body = BusEmitRequest(
|
|
229
|
+
channel=channel,
|
|
230
|
+
payload=dict(payload),
|
|
231
|
+
scope=scope,
|
|
232
|
+
client_id=self._client_id,
|
|
233
|
+
correlation_id=correlation_id,
|
|
234
|
+
).model_dump_json(exclude_none=True)
|
|
235
|
+
self._emitting += 1
|
|
236
|
+
self._quiet.clear()
|
|
237
|
+
try:
|
|
238
|
+
with telemetry.emitting(channel, scope):
|
|
239
|
+
return await retry_with_backoff(
|
|
240
|
+
self._timing.emit_retry,
|
|
241
|
+
lambda: self._emit_once(body),
|
|
242
|
+
retryable=_worth_another_emit,
|
|
243
|
+
give_up=self._closing,
|
|
244
|
+
)
|
|
245
|
+
except TransportError as error:
|
|
246
|
+
# A failed emit is reported where every failure is, as its caller hears it.
|
|
247
|
+
self._failures.deliver(error)
|
|
248
|
+
raise
|
|
249
|
+
finally:
|
|
250
|
+
self._emitting -= 1
|
|
251
|
+
if self._emitting == 0:
|
|
252
|
+
self._quiet.set()
|
|
253
|
+
|
|
254
|
+
async def _emit_once(self, body: str) -> int | None:
|
|
255
|
+
"""One attempt of an emit: `POST /bus/emit`, answered within `EMIT_TIMEOUT_MS`."""
|
|
256
|
+
if self._closing.is_set():
|
|
257
|
+
raise TransportError("error", "/bus/emit was not sent: the transport is closed")
|
|
258
|
+
try:
|
|
259
|
+
async with asyncio.timeout(EMIT_TIMEOUT_MS / 1000):
|
|
260
|
+
response = await self._http.post(
|
|
261
|
+
f"{self._base_url}/bus/emit",
|
|
262
|
+
content=body,
|
|
263
|
+
headers={
|
|
264
|
+
**telemetry.trace_headers(),
|
|
265
|
+
"Authorization": f"Bearer {self._token.value or ''}",
|
|
266
|
+
"Content-Type": "application/json",
|
|
267
|
+
},
|
|
268
|
+
)
|
|
269
|
+
except TimeoutError:
|
|
270
|
+
raise TransportError.without_response(f"/bus/emit got no answer within {EMIT_TIMEOUT_MS // 1000}s") from None
|
|
271
|
+
except httpx.HTTPError as error:
|
|
272
|
+
raise TransportError.without_response(f"/bus/emit got no answer: {error!r}") from error
|
|
273
|
+
if not response.is_success:
|
|
274
|
+
status = response.status_code
|
|
275
|
+
detail = response.text[:500]
|
|
276
|
+
raise TransportError.of_status(
|
|
277
|
+
f"/bus/emit {status}: {detail}" if detail else f"/bus/emit {status}",
|
|
278
|
+
status,
|
|
279
|
+
retry_after_ms(response.headers.get("retry-after")),
|
|
280
|
+
)
|
|
281
|
+
# No count is reported as no count, never as a zero: an absent
|
|
282
|
+
# `subscribers` is the gateway saying it could not count, and a body
|
|
283
|
+
# that is not the declared one says nothing at all.
|
|
284
|
+
try:
|
|
285
|
+
return BusEmitAccepted.model_validate_json(response.content).subscribers
|
|
286
|
+
except ValidationError:
|
|
287
|
+
return None
|
|
288
|
+
|
|
289
|
+
@override
|
|
290
|
+
async def get_current_user(self) -> UserResponse:
|
|
291
|
+
return await self._exchange.answer(UserResponse, "GET", "/api/users/me")
|
|
292
|
+
|
|
293
|
+
@override
|
|
294
|
+
async def get_media_token(self, resource_id: ResourceId) -> MediaTokenResponse:
|
|
295
|
+
return await self._exchange.answer(
|
|
296
|
+
MediaTokenResponse,
|
|
297
|
+
"POST",
|
|
298
|
+
"/api/tokens/media",
|
|
299
|
+
headers={"Content-Type": "application/json"},
|
|
300
|
+
content=MediaTokenRequest(resource_id=resource_id).model_dump_json().encode(),
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
@override
|
|
304
|
+
async def get_protected_resource_metadata(self) -> ProtectedResourceMetadata:
|
|
305
|
+
return await self._exchange.answer(ProtectedResourceMetadata, "GET", "/.well-known/oauth-protected-resource", authenticated=False)
|
|
306
|
+
|
|
307
|
+
@override
|
|
308
|
+
async def health_check(self) -> HealthResponse:
|
|
309
|
+
return await self._exchange.answer(HealthResponse, "GET", "/api/health")
|
|
310
|
+
|
|
311
|
+
@override
|
|
312
|
+
async def get_status(self) -> StatusResponse:
|
|
313
|
+
return await self._exchange.answer(StatusResponse, "GET", "/api/status")
|
|
314
|
+
|
|
315
|
+
@override
|
|
316
|
+
async def close(self) -> None:
|
|
317
|
+
if not self._closing.is_set():
|
|
318
|
+
self._closing.set()
|
|
319
|
+
self._stream.stop()
|
|
320
|
+
if self._running is None:
|
|
321
|
+
# Never opened: its stream's task runs only to end.
|
|
322
|
+
self._running = asyncio.create_task(self._run())
|
|
323
|
+
await self._closed.wait()
|
|
324
|
+
if self._running is not None:
|
|
325
|
+
# Whatever ended the stream's task that was not its closing is raised here.
|
|
326
|
+
await self._running
|
semiont/identifiers.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# Generated from specs/src/identifiers/kinds.json and each kind's schema; do not edit.
|
|
2
|
+
# Regenerate: node scripts/spec/generate-identifiers-python.mjs
|
|
3
|
+
|
|
4
|
+
"""The kinds of id.
|
|
5
|
+
|
|
6
|
+
A value of one of these types is text that passed its kind's rule, which is the
|
|
7
|
+
schema's pattern. It is made here and nowhere else: by the kind's constructor,
|
|
8
|
+
which raises `InvalidIdentifier` for text the rule refuses, by `parse`, which
|
|
9
|
+
answers `None` instead, and by decoding, which goes through the same rule. It
|
|
10
|
+
is a `str`, so it reads as the text it is. One kind is not another: a type
|
|
11
|
+
checker refuses a `ResourceId` where an `AnnotationId` is wanted.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from typing import ClassVar, Final, Self, final
|
|
16
|
+
|
|
17
|
+
from pydantic import GetCoreSchemaHandler
|
|
18
|
+
from pydantic_core import CoreSchema, core_schema
|
|
19
|
+
|
|
20
|
+
__all__ = ["AnnotationId", "InvalidIdentifier", "JobId", "ResourceId", "UserId"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@final
|
|
24
|
+
class InvalidIdentifier(ValueError):
|
|
25
|
+
"""Text that is not an id of the kind it was to be."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, kind: str, pattern: str, value: str) -> None:
|
|
28
|
+
super().__init__(f"{value!r} is not a {kind}: it does not match {pattern}")
|
|
29
|
+
self.kind: Final = kind
|
|
30
|
+
self.pattern: Final = pattern
|
|
31
|
+
self.value: Final = value
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _Identifier(str):
|
|
35
|
+
"""What the kinds share: text held to a rule, wherever it is made."""
|
|
36
|
+
|
|
37
|
+
__slots__ = ()
|
|
38
|
+
PATTERN: ClassVar[str]
|
|
39
|
+
"""The kind's rule, as its schema states it."""
|
|
40
|
+
_RULE: ClassVar[re.Pattern[str]]
|
|
41
|
+
|
|
42
|
+
def __new__(cls, text: str) -> Self:
|
|
43
|
+
if cls._RULE.fullmatch(text) is None:
|
|
44
|
+
raise InvalidIdentifier(cls.__name__, cls.PATTERN, text)
|
|
45
|
+
return super().__new__(cls, text)
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def parse(cls, text: str) -> Self | None:
|
|
49
|
+
"""`text` as this kind of id, or `None` when the kind's rule refuses it.
|
|
50
|
+
|
|
51
|
+
For where text that is not an id is an ordinary answer and not a fault:
|
|
52
|
+
an address someone typed, a key read from storage.
|
|
53
|
+
"""
|
|
54
|
+
return super().__new__(cls, text) if cls._RULE.fullmatch(text) is not None else None
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def __get_pydantic_core_schema__(cls, source: object, handler: GetCoreSchemaHandler) -> CoreSchema:
|
|
58
|
+
# Decoding goes through the constructor: an id in an answer passed the
|
|
59
|
+
# same rule as one a caller made.
|
|
60
|
+
return core_schema.no_info_after_validator_function(cls, core_schema.str_schema())
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@final
|
|
64
|
+
class ResourceId(_Identifier):
|
|
65
|
+
"""A resource's id: a name, never the resource's URI or a path. 1 to 128 of the letters `A`–`Z`
|
|
66
|
+
and `a`–`z`, the digits, `_` and `-`. It is one segment of a URL and one name in a file
|
|
67
|
+
system, and it is held to that wherever it enters: a gateway refuses a payload that carries
|
|
68
|
+
anything else. How one is made is no part of the rule. `__system__` is the one that names no
|
|
69
|
+
resource: the scope events about the knowledge base itself are logged under.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
__slots__ = ()
|
|
73
|
+
PATTERN: ClassVar[str] = "^[A-Za-z0-9_-]{1,128}$"
|
|
74
|
+
_RULE: ClassVar[re.Pattern[str]] = re.compile("[A-Za-z0-9_-]{1,128}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@final
|
|
78
|
+
class AnnotationId(_Identifier):
|
|
79
|
+
"""An annotation's id: a name, never the annotation's URI. 1 to 128 of the letters `A`–`Z` and
|
|
80
|
+
`a`–`z`, the digits, `_` and `-`. It is one segment of a URL and one name in a file system,
|
|
81
|
+
and it is held to that wherever it enters: a gateway refuses a payload that carries anything
|
|
82
|
+
else. How one is made is no part of the rule.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
__slots__ = ()
|
|
86
|
+
PATTERN: ClassVar[str] = "^[A-Za-z0-9_-]{1,128}$"
|
|
87
|
+
_RULE: ClassVar[re.Pattern[str]] = re.compile("[A-Za-z0-9_-]{1,128}")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@final
|
|
91
|
+
class JobId(_Identifier):
|
|
92
|
+
"""A job's id. 1 to 128 of the letters `A`–`Z` and `a`–`z`, the digits, `_` and `-`. It is one
|
|
93
|
+
segment of a URL and one name in a file system, and it is held to that wherever it enters: a
|
|
94
|
+
gateway refuses a payload that carries anything else. How one is made is no part of the
|
|
95
|
+
rule.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
__slots__ = ()
|
|
99
|
+
PATTERN: ClassVar[str] = "^[A-Za-z0-9_-]{1,128}$"
|
|
100
|
+
_RULE: ClassVar[re.Pattern[str]] = re.compile("[A-Za-z0-9_-]{1,128}")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@final
|
|
104
|
+
class UserId(_Identifier):
|
|
105
|
+
"""The identity of whoever did something, a person or a software agent alike: a DID
|
|
106
|
+
(`did:web:<domain>:users:<subject>`, `did:web:<domain>:agents:<provider>:<model>`), with no
|
|
107
|
+
whitespace in it. Never a name, an address, or a row in a table. What follows `did:` is not
|
|
108
|
+
constrained further: a subject is the issuer's, percent-encoded, and that encoding leaves
|
|
109
|
+
some punctuation as it is.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
__slots__ = ()
|
|
113
|
+
PATTERN: ClassVar[str] = "^did:\\S+$"
|
|
114
|
+
_RULE: ClassVar[re.Pattern[str]] = re.compile("did:\\S+")
|
semiont/identity.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""How a knowledge base is named, and the principals who act in it.
|
|
2
|
+
|
|
3
|
+
Every function here is held to a case table every implementation runs: the
|
|
4
|
+
knowledge base's own names to `specs/src/kb-identity/cases.json`, the rest to
|
|
5
|
+
`specs/src/principals/cases.json`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from typing import Final
|
|
10
|
+
from urllib.parse import quote
|
|
11
|
+
|
|
12
|
+
from semiont.identifiers import UserId
|
|
13
|
+
|
|
14
|
+
__all__ = ["agent_address", "agent_did", "agent_name", "encode_uri_component", "kb_did", "kb_resource", "person_did"]
|
|
15
|
+
|
|
16
|
+
# What ECMAScript's `encodeURIComponent` leaves alone, beside letters and digits.
|
|
17
|
+
_UNRESERVED: Final = "-_.!~*'()"
|
|
18
|
+
_NOT_OF_AN_ADDRESS: Final = re.compile(r"[^A-Za-z0-9]+")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def encode_uri_component(value: str) -> str:
|
|
22
|
+
"""`value` as `encodeURIComponent` writes it: each UTF-8 byte of every other character as `%XX`, in uppercase hex."""
|
|
23
|
+
return quote(value, safe=_UNRESERVED)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def kb_did(domain: str) -> UserId:
|
|
27
|
+
"""The knowledge base's DID: `did:web:` and its domain as it is, which is already a colon path."""
|
|
28
|
+
return UserId(f"did:web:{domain}")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def kb_resource(domain: str) -> str:
|
|
32
|
+
"""The knowledge base's resource identifier, the audience its tokens carry: its DID as an https URL."""
|
|
33
|
+
return f"https://{domain.replace(':', '/')}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def person_did(domain: str, subject: str) -> UserId:
|
|
37
|
+
"""A person, named by the subject the issuer asserted."""
|
|
38
|
+
return UserId(f"did:web:{domain}:users:{encode_uri_component(subject)}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def agent_did(domain: str, provider: str, model: str) -> UserId:
|
|
42
|
+
"""A software agent: one per provider and model, under the knowledge base's domain."""
|
|
43
|
+
return UserId(f"did:web:{domain}:agents:{encode_uri_component(provider)}:{encode_uri_component(model)}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def agent_address(domain: str, provider: str, model: str) -> str:
|
|
47
|
+
"""An agent's address.
|
|
48
|
+
|
|
49
|
+
`provider:model`, with every run of characters outside `[A-Za-z0-9-]` one
|
|
50
|
+
hyphen and none at either end, at `agents.` and the domain up to its first
|
|
51
|
+
colon.
|
|
52
|
+
"""
|
|
53
|
+
slug = re.sub("-+", "-", _NOT_OF_AN_ADDRESS.sub("-", f"{provider}:{model}")).strip("-")
|
|
54
|
+
return f"{slug}@agents.{domain.split(':', 1)[0]}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def agent_name(provider: str, model: str) -> str:
|
|
58
|
+
"""An agent's name."""
|
|
59
|
+
return f"{provider} {model}"
|
semiont/media_types.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""The rules read from the media types a knowledge base admits.
|
|
2
|
+
|
|
3
|
+
The table is `specs/src/media-types/registry.json`
|
|
4
|
+
(`semiont.media_types_table`), and the rules are the ones
|
|
5
|
+
`specs/src/media-types/cases.json` holds in every SDK: the format a clone
|
|
6
|
+
takes, and the name content is stored under.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Final
|
|
10
|
+
|
|
11
|
+
from semiont.media_types_table import MEDIA_TYPES, MediaType
|
|
12
|
+
from semiont.types import Representation, ResourceDescriptor
|
|
13
|
+
|
|
14
|
+
__all__ = ["base_media_type", "capabilities_of", "clone_format", "primary_media_type", "storage_file_name", "storage_uri"]
|
|
15
|
+
|
|
16
|
+
_BY_MEDIA_TYPE: Final = {row.media_type: row for row in MEDIA_TYPES}
|
|
17
|
+
_PLAIN_TEXT: Final = _BY_MEDIA_TYPE["text/plain"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def base_media_type(format: str) -> str:
|
|
21
|
+
"""A format without its parameters (`; charset=…`), in lower case."""
|
|
22
|
+
return format.split(";", 1)[0].strip().lower()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def capabilities_of(format: str) -> MediaType | None:
|
|
26
|
+
"""The table's row for `format`, whose parameters are not read.
|
|
27
|
+
|
|
28
|
+
A stored type may be one the table does not have: what was imported keeps
|
|
29
|
+
the type it came with.
|
|
30
|
+
"""
|
|
31
|
+
return _BY_MEDIA_TYPE.get(base_media_type(format))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def primary_media_type(resource: ResourceDescriptor) -> str | None:
|
|
35
|
+
"""The media type of a resource's first representation."""
|
|
36
|
+
stated = resource.representations
|
|
37
|
+
if isinstance(stated, Representation):
|
|
38
|
+
return stated.media_type
|
|
39
|
+
return stated[0].media_type if stated else None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def clone_format(source: str | None) -> MediaType:
|
|
43
|
+
"""The format a clone of a resource takes: its source's, when that is one a person can author, and plain text otherwise.
|
|
44
|
+
|
|
45
|
+
A clone opens where a person writes.
|
|
46
|
+
"""
|
|
47
|
+
row = None if source is None else capabilities_of(source)
|
|
48
|
+
return row if row is not None and row.authorable else _PLAIN_TEXT
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def storage_file_name(name: str, format: MediaType) -> str:
|
|
52
|
+
"""The name a resource's content is stored under.
|
|
53
|
+
|
|
54
|
+
Its title in lower case, every run of characters that are not a to z or 0
|
|
55
|
+
to 9 made one hyphen, none at either end, and then the format's
|
|
56
|
+
extension.
|
|
57
|
+
"""
|
|
58
|
+
kept: list[str] = []
|
|
59
|
+
for character in name.lower():
|
|
60
|
+
if character.isascii() and (character.islower() or character.isdigit()):
|
|
61
|
+
kept.append(character)
|
|
62
|
+
elif not kept or kept[-1] != "-":
|
|
63
|
+
kept.append("-")
|
|
64
|
+
return "".join(kept).strip("-") + format.extension
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def storage_uri(name: str, format: MediaType) -> str:
|
|
68
|
+
"""The `file://` URI of `storage_file_name`: where the content is stored, at the root of the working tree."""
|
|
69
|
+
return f"file://{storage_file_name(name, format)}"
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# Generated from specs/src/media-types/registry.json; do not edit.
|
|
2
|
+
# Regenerate: node scripts/spec/generate-media-types-python.mjs
|
|
3
|
+
|
|
4
|
+
"""The media types a knowledge base admits, with what this SDK reads of each."""
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Final, final
|
|
8
|
+
|
|
9
|
+
__all__ = ["MEDIA_TYPES", "MediaType"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@final
|
|
13
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
14
|
+
class MediaType:
|
|
15
|
+
"""One media type a knowledge base admits."""
|
|
16
|
+
|
|
17
|
+
media_type: str
|
|
18
|
+
extension: str
|
|
19
|
+
"""The extension a stored name takes, with its dot."""
|
|
20
|
+
authorable: bool
|
|
21
|
+
"""Offered where a person writes a resource."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# The registry, in its own order: 63 media types.
|
|
25
|
+
MEDIA_TYPES: Final[tuple[MediaType, ...]] = (
|
|
26
|
+
MediaType(media_type="text/markdown", extension=".md", authorable=True),
|
|
27
|
+
MediaType(media_type="text/plain", extension=".txt", authorable=True),
|
|
28
|
+
MediaType(media_type="text/html", extension=".html", authorable=True),
|
|
29
|
+
MediaType(media_type="application/json", extension=".json", authorable=False),
|
|
30
|
+
MediaType(media_type="image/png", extension=".png", authorable=False),
|
|
31
|
+
MediaType(media_type="image/jpeg", extension=".jpg", authorable=False),
|
|
32
|
+
MediaType(media_type="application/pdf", extension=".pdf", authorable=False),
|
|
33
|
+
MediaType(media_type="text/css", extension=".css", authorable=False),
|
|
34
|
+
MediaType(media_type="text/csv", extension=".csv", authorable=False),
|
|
35
|
+
MediaType(media_type="text/xml", extension=".xml", authorable=False),
|
|
36
|
+
MediaType(media_type="application/xml", extension=".xml", authorable=False),
|
|
37
|
+
MediaType(media_type="application/yaml", extension=".yaml", authorable=False),
|
|
38
|
+
MediaType(media_type="application/x-yaml", extension=".yaml", authorable=False),
|
|
39
|
+
MediaType(media_type="text/javascript", extension=".js", authorable=False),
|
|
40
|
+
MediaType(media_type="application/javascript", extension=".js", authorable=False),
|
|
41
|
+
MediaType(media_type="text/x-typescript", extension=".ts", authorable=False),
|
|
42
|
+
MediaType(media_type="application/typescript", extension=".ts", authorable=False),
|
|
43
|
+
MediaType(media_type="text/x-python", extension=".py", authorable=False),
|
|
44
|
+
MediaType(media_type="text/x-java", extension=".java", authorable=False),
|
|
45
|
+
MediaType(media_type="text/x-c", extension=".c", authorable=False),
|
|
46
|
+
MediaType(media_type="text/x-c++", extension=".cpp", authorable=False),
|
|
47
|
+
MediaType(media_type="text/x-csharp", extension=".cs", authorable=False),
|
|
48
|
+
MediaType(media_type="text/x-go", extension=".go", authorable=False),
|
|
49
|
+
MediaType(media_type="text/x-rust", extension=".rs", authorable=False),
|
|
50
|
+
MediaType(media_type="text/x-ruby", extension=".rb", authorable=False),
|
|
51
|
+
MediaType(media_type="text/x-php", extension=".php", authorable=False),
|
|
52
|
+
MediaType(media_type="text/x-swift", extension=".swift", authorable=False),
|
|
53
|
+
MediaType(media_type="text/x-kotlin", extension=".kt", authorable=False),
|
|
54
|
+
MediaType(media_type="text/x-shell", extension=".sh", authorable=False),
|
|
55
|
+
MediaType(media_type="application/msword", extension=".doc", authorable=False),
|
|
56
|
+
MediaType(media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", extension=".docx", authorable=False),
|
|
57
|
+
MediaType(media_type="application/vnd.ms-excel", extension=".xls", authorable=False),
|
|
58
|
+
MediaType(media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", extension=".xlsx", authorable=False),
|
|
59
|
+
MediaType(media_type="application/vnd.ms-powerpoint", extension=".ppt", authorable=False),
|
|
60
|
+
MediaType(media_type="application/vnd.openxmlformats-officedocument.presentationml.presentation", extension=".pptx", authorable=False),
|
|
61
|
+
MediaType(media_type="application/zip", extension=".zip", authorable=False),
|
|
62
|
+
MediaType(media_type="application/gzip", extension=".gz", authorable=False),
|
|
63
|
+
MediaType(media_type="application/x-tar", extension=".tar", authorable=False),
|
|
64
|
+
MediaType(media_type="application/x-7z-compressed", extension=".7z", authorable=False),
|
|
65
|
+
MediaType(media_type="application/octet-stream", extension=".bin", authorable=False),
|
|
66
|
+
MediaType(media_type="application/wasm", extension=".wasm", authorable=False),
|
|
67
|
+
MediaType(media_type="image/gif", extension=".gif", authorable=False),
|
|
68
|
+
MediaType(media_type="image/webp", extension=".webp", authorable=False),
|
|
69
|
+
MediaType(media_type="image/svg+xml", extension=".svg", authorable=False),
|
|
70
|
+
MediaType(media_type="image/bmp", extension=".bmp", authorable=False),
|
|
71
|
+
MediaType(media_type="image/tiff", extension=".tiff", authorable=False),
|
|
72
|
+
MediaType(media_type="image/x-icon", extension=".ico", authorable=False),
|
|
73
|
+
MediaType(media_type="video/mp4", extension=".mp4", authorable=False),
|
|
74
|
+
MediaType(media_type="video/mpeg", extension=".mpeg", authorable=False),
|
|
75
|
+
MediaType(media_type="video/webm", extension=".webm", authorable=False),
|
|
76
|
+
MediaType(media_type="video/ogg", extension=".ogv", authorable=False),
|
|
77
|
+
MediaType(media_type="video/quicktime", extension=".mov", authorable=False),
|
|
78
|
+
MediaType(media_type="video/x-msvideo", extension=".avi", authorable=False),
|
|
79
|
+
MediaType(media_type="audio/mpeg", extension=".mp3", authorable=False),
|
|
80
|
+
MediaType(media_type="audio/wav", extension=".wav", authorable=False),
|
|
81
|
+
MediaType(media_type="audio/ogg", extension=".ogg", authorable=False),
|
|
82
|
+
MediaType(media_type="audio/webm", extension=".webm", authorable=False),
|
|
83
|
+
MediaType(media_type="audio/aac", extension=".aac", authorable=False),
|
|
84
|
+
MediaType(media_type="audio/flac", extension=".flac", authorable=False),
|
|
85
|
+
MediaType(media_type="font/woff", extension=".woff", authorable=False),
|
|
86
|
+
MediaType(media_type="font/woff2", extension=".woff2", authorable=False),
|
|
87
|
+
MediaType(media_type="font/ttf", extension=".ttf", authorable=False),
|
|
88
|
+
MediaType(media_type="font/otf", extension=".otf", authorable=False),
|
|
89
|
+
)
|