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,283 @@
1
+ """What keeps a client's queries true: `specs/src/client/refresh.json` (`semiont.refresh`), applied to the client's caches.
2
+
3
+ Each event on the bus, and the reopening of a dropped stream, asks again for
4
+ the queries its row names, writes the ones whose new value the event carries,
5
+ and ends the ones whose entity is gone.
6
+
7
+ A client has one refresher. It states what each event names (its resource,
8
+ its annotation, the value it carries) and, for each query of the table, which
9
+ cache answers it. Both are a `match` over the table's own names, so a trigger
10
+ or a query added to the table does not pass a type checker until it is
11
+ answered here.
12
+
13
+ An event acts only on a key its cache holds, and the refetches one key is
14
+ asked for inside a window are one refetch (`docs/protocol/CACHE-SEMANTICS.md`
15
+ B12 to B13b, B19, B20).
16
+ """
17
+
18
+ import asyncio
19
+ from collections.abc import Callable, Hashable, Mapping
20
+ from typing import Final, assert_never, final
21
+
22
+ from pydantic import JsonValue, ValidationError
23
+
24
+ from semiont.bus import decoded
25
+ from semiont.cache import Cache
26
+ from semiont.channel import Channel
27
+ from semiont.channels import (
28
+ BUS_RESUME_GAP,
29
+ FRAME_ENTITY_TYPE_ADDED,
30
+ FRAME_TAG_SCHEMA_ADDED,
31
+ MARK_ADDED,
32
+ MARK_ARCHIVED,
33
+ MARK_BODY_UPDATED,
34
+ MARK_DELETE_OK,
35
+ MARK_ENTITY_TAG_ADDED,
36
+ MARK_ENTITY_TAG_REMOVED,
37
+ MARK_REMOVED,
38
+ MARK_UNARCHIVED,
39
+ YIELD_CLONED,
40
+ YIELD_CREATED,
41
+ YIELD_MOVED,
42
+ YIELD_UPDATED,
43
+ )
44
+ from semiont.events import Events
45
+ from semiont.identifiers import AnnotationId, ResourceId
46
+ from semiont.namespaces.links import Links
47
+ from semiont.namespaces.live import WHOLE, Live, Subject
48
+ from semiont.refresh import CACHE_QUERIES, CACHE_REFRESH, CacheQuery, CacheRefreshReach, CacheRefreshTrigger
49
+ from semiont.transport import ConnectionState, Frame
50
+ from semiont.types import StoredEventResponse
51
+ from semiont.watched import Watched
52
+
53
+ __all__ = ["Refresher"]
54
+
55
+ # The triggers that are events, by the channel each is.
56
+ _EVENTS: Final[Mapping[str, CacheRefreshTrigger]] = {trigger: trigger for trigger in CACHE_REFRESH if trigger != "reopened"}
57
+
58
+
59
+ @final
60
+ class _Window:
61
+ def __init__(self, refetch: Callable[[], None], closing: asyncio.TimerHandle) -> None:
62
+ self.refetch: Final = refetch
63
+ """What asks again for the window's key."""
64
+ self.closing: Final = closing
65
+ self.owed = False
66
+ """Whether it was asked for again while the window was open."""
67
+
68
+
69
+ @final
70
+ class _Windows:
71
+ """B19: per key, the first refetch an event asks for runs at once and opens a window.
72
+
73
+ Any more inside it are owed, and run as one when it closes, which opens
74
+ the next. So a storm of events costs a refetch per key per window, and
75
+ the last event is always reflected.
76
+ """
77
+
78
+ def __init__(self, lasting_ms: int) -> None:
79
+ self._lasting: Final = lasting_ms / 1000
80
+ self._open: Final[dict[str, _Window]] = {}
81
+
82
+ def run(self, key: str, refetch: Callable[[], None]) -> None:
83
+ """Ask again for `key`, by `refetch`: what asks for one key is the same whichever event asked."""
84
+ window = self._open.get(key)
85
+ if window is not None:
86
+ window.owed = True
87
+ return
88
+ self._open[key] = _Window(refetch, asyncio.get_running_loop().call_later(self._lasting, self._close, key))
89
+ refetch()
90
+
91
+ def _close(self, key: str) -> None:
92
+ window = self._open.pop(key)
93
+ if window.owed:
94
+ self.run(key, window.refetch)
95
+
96
+ def dispose(self) -> None:
97
+ """Every window closes, and what each owed is dropped."""
98
+ for window in self._open.values():
99
+ window.closing.cancel()
100
+ self._open.clear()
101
+
102
+
103
+ def _annotation_named(payload: Mapping[str, JsonValue]) -> AnnotationId | None:
104
+ named = payload.get("annotationId")
105
+ return AnnotationId.parse(named) if isinstance(named, str) else None
106
+
107
+
108
+ def _recorded(channel: Channel[StoredEventResponse], payload: Mapping[str, JsonValue]) -> Subject:
109
+ return Subject(resource=decoded(channel, payload).resource_id)
110
+
111
+
112
+ def _named(trigger: CacheRefreshTrigger, payload: Mapping[str, JsonValue]) -> Subject:
113
+ """What an event names. Each arm states it for its trigger, and a trigger with no arm does not pass a type checker.
114
+
115
+ Raises `ValidationError` for a payload that is not its channel's.
116
+ """
117
+ match trigger:
118
+ case "reopened":
119
+ # No event: the stream's reopening names nothing.
120
+ return Subject()
121
+ case "bus:resume-gap":
122
+ return Subject(resource=decoded(BUS_RESUME_GAP, payload).scope)
123
+ case "mark:added":
124
+ return Subject(resource=decoded(MARK_ADDED, payload).resource_id)
125
+ case "mark:removed":
126
+ removed = decoded(MARK_REMOVED, payload)
127
+ return Subject(resource=removed.resource_id, annotation=_annotation_named(removed.payload))
128
+ case "mark:delete-ok":
129
+ return Subject(annotation=decoded(MARK_DELETE_OK, payload).response.annotation_id)
130
+ case "mark:body-updated":
131
+ updated = decoded(MARK_BODY_UPDATED, payload)
132
+ if updated.annotation is not None:
133
+ return Subject(when="enriched", resource=updated.resource_id, annotation=updated.annotation.id, written=updated.annotation)
134
+ return Subject(when="unenriched", resource=updated.resource_id, annotation=_annotation_named(updated.payload))
135
+ case "mark:entity-tag-added":
136
+ return _recorded(MARK_ENTITY_TAG_ADDED, payload)
137
+ case "mark:entity-tag-removed":
138
+ return _recorded(MARK_ENTITY_TAG_REMOVED, payload)
139
+ case "mark:archived":
140
+ return _recorded(MARK_ARCHIVED, payload)
141
+ case "mark:unarchived":
142
+ return _recorded(MARK_UNARCHIVED, payload)
143
+ # What is heard by every client: its resource is the one it records, not a scope the client holds.
144
+ case "yield:created":
145
+ return _recorded(YIELD_CREATED, payload)
146
+ case "yield:updated":
147
+ return _recorded(YIELD_UPDATED, payload)
148
+ case "yield:cloned":
149
+ return _recorded(YIELD_CLONED, payload)
150
+ case "yield:moved":
151
+ return _recorded(YIELD_MOVED, payload)
152
+ case "frame:entity-type-added":
153
+ decoded(FRAME_ENTITY_TYPE_ADDED, payload)
154
+ return Subject()
155
+ case "frame:tag-schema-added":
156
+ decoded(FRAME_TAG_SCHEMA_ADDED, payload)
157
+ return Subject()
158
+ case _:
159
+ assert_never(trigger)
160
+
161
+
162
+ @final
163
+ class Refresher:
164
+ """See the module's documentation. It listens from `start` until its client closes."""
165
+
166
+ def __init__(self, links: Links, live: Live, *, invalidation_window_ms: int) -> None:
167
+ self._links: Final = links
168
+ self._live: Final = live
169
+ self._windows: Final = _Windows(invalidation_window_ms)
170
+
171
+ def start(self) -> None:
172
+ """Listen, from now on. A client starts it once: when it is held."""
173
+ # Listening before the task that reads has had its first turn: nothing said meanwhile is missed.
174
+ self._links.run(self._listen(self._links.own.frames_among(tuple(_EVENTS))))
175
+ self._links.run(self._reopenings(self._links.wire.transport.state))
176
+
177
+ async def _listen(self, events: Events[Frame]) -> None:
178
+ async for frame in events:
179
+ # Its reader is given the frames of these channels and no others.
180
+ trigger = _EVENTS[frame.channel]
181
+ try:
182
+ subject = _named(trigger, frame.payload)
183
+ except ValidationError:
184
+ # An event that cannot be read: something changed and nothing
185
+ # says what. Everything held is asked for again, as after a gap.
186
+ self._everything_held()
187
+ continue
188
+ self._refresh(trigger, subject)
189
+
190
+ async def _reopenings(self, state: Watched[ConnectionState]) -> None:
191
+ """B13: the stream is open again having left `open`, which only a drop does.
192
+
193
+ A changed subscription is handed over with the state still `open`,
194
+ and misses nothing.
195
+ """
196
+ opened = left = False
197
+ # A reopening is seen as one because a transport waits on its
198
+ # connection between leaving `open` and reaching it again, and this
199
+ # task runs in that wait.
200
+ async for now in state:
201
+ if now == "open":
202
+ if left:
203
+ self._refresh("reopened", Subject())
204
+ opened, left = True, False
205
+ else:
206
+ left = opened
207
+
208
+ def _refresh(self, trigger: CacheRefreshTrigger, subject: Subject) -> None:
209
+ row = next((row for row in CACHE_REFRESH[trigger] if row.when == subject.when), None)
210
+ if row is None:
211
+ return
212
+ for query in row.writes:
213
+ self._live.write(query, subject)
214
+ for query in row.removes:
215
+ self._live.remove(query, subject)
216
+ for query in row.refetches:
217
+ self._refetch(query, subject, row.reach)
218
+
219
+ def _everything_held(self) -> None:
220
+ for query in CACHE_QUERIES:
221
+ self._refetch(query, Subject(), "held")
222
+
223
+ def _again[K: Hashable, V](self, cache: Cache[K, V], key: K, window: str) -> None:
224
+ """Ask again for one key, when the cache holds it (B20), through the key's window (B19)."""
225
+ if cache.known(key):
226
+ self._windows.run(window, lambda: cache.invalidate(key))
227
+
228
+ def _refetch(self, query: CacheQuery, subject: Subject, reach: CacheRefreshReach) -> None:
229
+ """B7: ask again, for the keys the row reaches, showing what there is meanwhile.
230
+
231
+ Each arm names the cache that answers its query, and a query with no
232
+ arm does not pass a type checker.
233
+ """
234
+ live = self._live
235
+
236
+ def reached(held: list[ResourceId]) -> list[ResourceId]:
237
+ match reach:
238
+ case "held":
239
+ return held
240
+ case "subject":
241
+ return [] if subject.resource is None else [subject.resource]
242
+
243
+ match query:
244
+ case "resource":
245
+ for resource in reached(live.resource.keys()):
246
+ self._again(live.resource, resource, f"resource/{resource}")
247
+ case "annotations":
248
+ for resource in reached(live.annotations.keys()):
249
+ self._again(live.annotations, resource, f"annotations/{resource}")
250
+ case "events":
251
+ for resource in reached(live.events.keys()):
252
+ self._again(live.events, resource, f"events/{resource}")
253
+ case "referencedBy":
254
+ for resource in reached(live.referenced_by.keys()):
255
+ self._again(live.referenced_by, resource, f"referenced-by/{resource}")
256
+ case "annotation":
257
+ # The annotation the event names; when it names none, each one held of the resource it names.
258
+ if reach == "held":
259
+ annotations = live.annotation.keys()
260
+ elif subject.annotation is not None:
261
+ annotations = [subject.annotation]
262
+ else:
263
+ annotations = [annotation for annotation, of in live.annotation_of.items() if of == subject.resource]
264
+ for annotation in annotations:
265
+ self._again(live.annotation, annotation, f"annotation/{annotation}")
266
+ # An event does not say which lists it changes, nor which searches would now find its resource.
267
+ case "resources":
268
+ self._windows.run("resource-lists", live.lists.invalidate_all)
269
+ case "matchedResources":
270
+ self._windows.run("matched-resources", live.searches.invalidate_all)
271
+ case "entityTypes":
272
+ self._again(live.entity_types, WHOLE, "entity-types")
273
+ case "tagSchemas":
274
+ self._again(live.tag_schemas, WHOLE, "tag-schemas")
275
+ case "agents":
276
+ if live.agents.known(WHOLE):
277
+ self._windows.run("agents", live.invalidate_agents)
278
+ case _:
279
+ assert_never(query)
280
+
281
+ def dispose(self) -> None:
282
+ """Stop: what a window owed is dropped, and no window opens after. The client's close ends the listening."""
283
+ self._windows.dispose()
@@ -0,0 +1,24 @@
1
+ """System: what the knowledge base says about itself."""
2
+
3
+ from typing import Final, final
4
+
5
+ from semiont.transport import GatewayOperations
6
+ from semiont.types import HealthResponse, StatusResponse
7
+
8
+ __all__ = ["SystemNamespace"]
9
+
10
+
11
+ @final
12
+ class SystemNamespace:
13
+ """See the module's documentation."""
14
+
15
+ def __init__(self, gateway: GatewayOperations) -> None:
16
+ self._gateway: Final = gateway
17
+
18
+ async def health_check(self) -> HealthResponse:
19
+ """Whether the gateway is up."""
20
+ return await self._gateway.health_check()
21
+
22
+ async def status(self) -> StatusResponse:
23
+ """The knowledge base's version and features, and who the caller is to it."""
24
+ return await self._gateway.get_status()
@@ -0,0 +1,101 @@
1
+ """Yield: creating resources, by upload, by generation, and by cloning."""
2
+
3
+ from typing import Final, final
4
+
5
+ from semiont.channel import Empty
6
+ from semiont.channels import YIELD_CLONE
7
+ from semiont.identifiers import ResourceId
8
+ from semiont.media_types import clone_format, primary_media_type, storage_uri
9
+ from semiont.model import written
10
+ from semiont.namespaces.follow import JobEvent, follow
11
+ from semiont.namespaces.links import Links
12
+ from semiont.operations import YIELD_CLONE_RESOURCE_REQUESTED, YIELD_CLONE_TOKEN_REQUESTED
13
+ from semiont.running import Running
14
+ from semiont.timing import GENERATION_STALL_ASSUMED_TOKENS_COUNT, GENERATION_STALL_FLOOR_MS, GENERATION_STALL_PER_TOKEN_MS
15
+ from semiont.transport import ContentTransport, PutBinaryRequest, Upload
16
+ from semiont.types import (
17
+ CloneResourceWithTokenResponse,
18
+ CreateResourceResponse,
19
+ GatheredContextFocusResource,
20
+ GenerationJobParams,
21
+ JobCreateCommand,
22
+ ResourceDescriptor,
23
+ YieldCloneResourceRequest,
24
+ YieldCloneTokenRequest,
25
+ )
26
+
27
+ __all__ = ["YieldNamespace", "generation_stall_deadline_ms"]
28
+
29
+
30
+ def generation_stall_deadline_ms(max_tokens: float | None) -> int:
31
+ """How long a followed generation may say nothing before its follower gives up on it.
32
+
33
+ A floor, and a wait that grows with the length asked for: a generation
34
+ says nothing while its model writes, so the silence it is allowed depends
35
+ on how much it was asked to write.
36
+ """
37
+ tokens = GENERATION_STALL_ASSUMED_TOKENS_COUNT if max_tokens is None else max(max_tokens, 0)
38
+ return max(GENERATION_STALL_FLOOR_MS, round(GENERATION_STALL_PER_TOKEN_MS * tokens))
39
+
40
+
41
+ @final
42
+ class YieldNamespace:
43
+ """See the module's documentation."""
44
+
45
+ def __init__(self, links: Links, content: ContentTransport) -> None:
46
+ self._links: Final = links
47
+ self._content: Final = content
48
+
49
+ def resource(self, data: PutBinaryRequest) -> Upload:
50
+ """Upload bytes as a new resource: the upload's progress, and the resource created."""
51
+ return self._content.put_binary(data)
52
+
53
+ def from_context(self, params: GenerationJobParams, *, stall_deadline_ms: int | None = None) -> Running[JobEvent]:
54
+ """Generate a resource from a gathered context: the job's progress and its completion.
55
+
56
+ The context's focus says what the job is about, so the job names no
57
+ resource. A follower that hears nothing for `stall_deadline_ms`, or
58
+ for `generation_stall_deadline_ms(params.max_tokens)` when none is
59
+ stated, asks for that job to be cancelled and ends as stalled.
60
+ """
61
+ focus = params.context.focus
62
+ about = focus.resource.id if isinstance(focus, GatheredContextFocusResource) else focus.source_resource.id
63
+ within = generation_stall_deadline_ms(params.max_tokens) if stall_deadline_ms is None else stall_deadline_ms
64
+ create = JobCreateCommand(job_type="generation", params=written(params))
65
+ return follow(self._links, create, resource_id=about, stall_ms=within)
66
+
67
+ async def clone_token(self, resource_id: ResourceId) -> CloneResourceWithTokenResponse:
68
+ """A token another resource can be created from: a clone of this one."""
69
+ return (await self._links.request(YIELD_CLONE_TOKEN_REQUESTED, YieldCloneTokenRequest(resource_id=resource_id))).response
70
+
71
+ async def from_token(self, token: str) -> ResourceDescriptor:
72
+ """The resource a clone token was made from."""
73
+ answer = await self._links.request(YIELD_CLONE_RESOURCE_REQUESTED, YieldCloneResourceRequest(token=token))
74
+ return answer.response.source_resource
75
+
76
+ async def create_from_token(
77
+ self, *, token: str, name: str, content: str, archive_original: bool | None = None
78
+ ) -> CreateResourceResponse:
79
+ """Create a resource as a clone of the one `token` was made from.
80
+
81
+ The source is read first. The clone's content then goes by the upload
82
+ path, in the format its source's allows
83
+ (`semiont.media_types.clone_format`) and under a name made from its
84
+ own. `archive_original` archives the source once the clone exists.
85
+ """
86
+ source = await self.from_token(token)
87
+ format_ = clone_format(primary_media_type(source))
88
+ return await self._content.put_binary(
89
+ PutBinaryRequest(
90
+ name=name,
91
+ file=content.encode(),
92
+ format=format_.media_type,
93
+ storage_uri=storage_uri(name, format_),
94
+ clone_token=token,
95
+ archive_original=archive_original,
96
+ )
97
+ )
98
+
99
+ def clone(self) -> None:
100
+ """Signal: a clone of the open resource is wanted."""
101
+ self._links.signal(YIELD_CLONE, Empty())
@@ -0,0 +1,27 @@
1
+ # Generated from specs/src/session/oauth.json; do not edit.
2
+ # Regenerate: node scripts/spec/generate-oauth-clients-python.mjs
3
+
4
+ """How a Semiont client presents itself to the issuer a knowledge base trusts:
5
+ the clients its realm registers, and the scope every sign-in asks for.
6
+ """
7
+
8
+ from typing import Final
9
+
10
+ __all__ = [
11
+ "BROWSER_CLIENT_ID",
12
+ "SCRIPT_CLIENT_ID",
13
+ "SIGN_IN_SCOPE",
14
+ ]
15
+
16
+ # An application a person uses: the authorization-code grant with PKCE (RFC 7636), at a redirect
17
+ # address the registration lists.
18
+ BROWSER_CLIENT_ID: Final = "semiont-browser"
19
+
20
+ # A process with no browser of its own, and the launcher: the device authorization grant (RFC
21
+ # 8628).
22
+ SCRIPT_CLIENT_ID: Final = "semiont-cli"
23
+
24
+ # What every sign-in asks for. `offline_access` asks for a refresh token that outlives the
25
+ # issuer's own browser session: a session with a knowledge base is renewed for weeks, not
26
+ # minutes.
27
+ SIGN_IN_SCOPE: Final = "openid email profile offline_access"