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
@@ -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
+ )