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/client.py ADDED
@@ -0,0 +1,161 @@
1
+ """A client of one knowledge base: its eleven namespaces over a transport, the bytes that never ride the bus, and the gateway's own answers.
2
+
3
+ A client owns a bus of its own (`bus`). Its transport delivers into that bus
4
+ every frame it receives, and what the client's own parts say to each other (a
5
+ viewer's signals) goes through it and never reaches the wire. The namespaces
6
+ are attributes: `client.browse`, `client.mark`, and so on, with `yield_` for
7
+ the one whose name Python keeps for itself.
8
+
9
+ A client keeps its queries true: what `browse`, `gather` and `match` answer
10
+ as live queries comes from its cache, and each event its transport delivers
11
+ refreshes what the cache holds (`semiont.cached`). With a `persistence`, the
12
+ small queries are kept there, and the next client of the same knowledge base
13
+ shows them at once.
14
+
15
+ A client is held with `async with`, and inside it listens for what keeps
16
+ its queries true. On the way out its live queries end, its followed jobs end,
17
+ every reader of its own bus is told there is no more, and the tasks it
18
+ started have ended. It does not open or close its transport: whoever opened
19
+ that closes it, after the client.
20
+
21
+ async with HttpTransport(origin, token=token) as transport:
22
+ async with SemiontClient(transport, transport.content, transport) as client:
23
+ resource = await client.browse.resource(resource_id).fresh()
24
+ """
25
+
26
+ from dataclasses import dataclass
27
+ from types import TracebackType
28
+ from typing import Final, Self, final
29
+
30
+ from semiont.bus import Bus
31
+ from semiont.event_bus import EventBus
32
+ from semiont.namespaces.auth import AuthNamespace
33
+ from semiont.namespaces.beckon import BeckonNamespace
34
+ from semiont.namespaces.bind import BindNamespace
35
+ from semiont.namespaces.browse import BrowseNamespace
36
+ from semiont.namespaces.frame import FrameNamespace
37
+ from semiont.namespaces.gather import GatherNamespace
38
+ from semiont.namespaces.job import JobNamespace
39
+ from semiont.namespaces.links import Links
40
+ from semiont.namespaces.live import CachePersistence, Live
41
+ from semiont.namespaces.mark import MarkNamespace
42
+ from semiont.namespaces.match import MatchNamespace
43
+ from semiont.namespaces.refresher import Refresher
44
+ from semiont.namespaces.system import SystemNamespace
45
+ from semiont.namespaces.yield_ import YieldNamespace
46
+ from semiont.timing import BUS_REQUEST_TIMEOUT_MS, INVALIDATION_WINDOW_MS, JOB_SILENCE_MS, JOB_STATUS_POLL_MS
47
+ from semiont.transport import ContentTransport, GatewayOperations, Transport
48
+
49
+ __all__ = ["CachePersistence", "ClientTiming", "SemiontClient"]
50
+
51
+
52
+ @final
53
+ @dataclass(frozen=True, slots=True, kw_only=True)
54
+ class ClientTiming:
55
+ """The waits a client keeps: `specs/src/client/timing.json`'s, unless a caller that must not wait them out says otherwise."""
56
+
57
+ bus_request_ms: int = BUS_REQUEST_TIMEOUT_MS
58
+ """How long a bus request waits for its reply."""
59
+ job_silence_ms: int = JOB_SILENCE_MS
60
+ """How long a followed job may say nothing before its status is asked for."""
61
+ job_status_poll_ms: int = JOB_STATUS_POLL_MS
62
+ """How often a silent job's status is asked for after that."""
63
+ invalidation_window_ms: int = INVALIDATION_WINDOW_MS
64
+ """How long the refetches events ask of one cached key are one refetch."""
65
+
66
+
67
+ @final
68
+ class SemiontClient[T: Transport]:
69
+ """See the module's documentation.
70
+
71
+ `content` carries the bytes and `gateway` answers for itself; over HTTP
72
+ the transport is all three. `transport` is kept as the kind of transport
73
+ it is, for what the namespaces do not cover: a frame on a channel by name,
74
+ a hold on a resource's scope.
75
+ """
76
+
77
+ def __init__(
78
+ self,
79
+ transport: T,
80
+ content: ContentTransport,
81
+ gateway: GatewayOperations,
82
+ *,
83
+ timing: ClientTiming | None = None,
84
+ persistence: CachePersistence | None = None,
85
+ ) -> None:
86
+ kept = ClientTiming() if timing is None else timing
87
+ self.transport: Final[T] = transport
88
+ self.timing: Final = kept
89
+ self.bus: Final = EventBus()
90
+ """The client's own bus: every frame the transport delivered, and every signal the client's parts gave each other."""
91
+ self.wire: Final = Bus(transport)
92
+ """The bus over the transport, typed by channel: an emit, a channel's frames, a request."""
93
+ transport.bridge_into(self.bus)
94
+ self._links: Final = Links(
95
+ wire=self.wire,
96
+ own=self.bus,
97
+ bus_request_ms=kept.bus_request_ms,
98
+ job_silence_ms=kept.job_silence_ms,
99
+ job_status_poll_ms=kept.job_status_poll_ms,
100
+ )
101
+ self._closed = False
102
+ self._held = False
103
+ self._live: Final = Live(self._links, persistence)
104
+ self._refresher: Final = Refresher(self._links, self._live, invalidation_window_ms=kept.invalidation_window_ms)
105
+
106
+ self.frame: Final = FrameNamespace(self._links)
107
+ """The vocabulary: what kinds of things exist."""
108
+ self.browse: Final = BrowseNamespace(self._links, content, self._live)
109
+ """Reads, and this viewer's own signals."""
110
+ self.mark: Final = MarkNamespace(self._links)
111
+ """Annotations, a resource's own metadata, and AI assistance."""
112
+ self.bind: Final = BindNamespace(self._links)
113
+ """Linking a reference to what it refers to."""
114
+ self.gather: Final = GatherNamespace(self._links, self._live)
115
+ """Assembling the context a model is given, and what refers to a resource."""
116
+ self.match: Final = MatchNamespace(self._links, self._live)
117
+ """Searching: for what a reference could refer to, and for resources by text."""
118
+ self.yield_: Final = YieldNamespace(self._links, content)
119
+ """Creating resources."""
120
+ self.beckon: Final = BeckonNamespace(self._links)
121
+ """Attention: driving the other participants' viewers."""
122
+ self.job: Final = JobNamespace(self._links)
123
+ """Jobs: their lifecycle, their status and their cancellation."""
124
+ self.auth: Final = AuthNamespace(gateway)
125
+ """The gateway's view of who is signed in."""
126
+ self.system: Final = SystemNamespace(gateway)
127
+ """What the knowledge base says about itself."""
128
+
129
+ async def __aenter__(self) -> Self:
130
+ if self._closed or self._held:
131
+ raise RuntimeError("a client is held once")
132
+ self._held = True
133
+ self._refresher.start()
134
+ self._live.kept_true()
135
+ return self
136
+
137
+ async def __aexit__(self, kind: type[BaseException] | None, error: BaseException | None, trace: TracebackType | None) -> None:
138
+ await self.close()
139
+
140
+ @property
141
+ def persistence_settled(self) -> bool:
142
+ """Whether every cache the client keeps is at rest: none is fetching, and none owes a save.
143
+
144
+ What is kept of the stream's place is kept only then (`semiont.resume`).
145
+ """
146
+ return self._live.persistence_settled
147
+
148
+ async def close(self) -> None:
149
+ """End the client.
150
+
151
+ The queries end first: every watcher's states end, a read asked for
152
+ after is refused as `bus.closed`, and what was owed to storage is
153
+ saved. Then its own bus ends, so every reader of it and every followed
154
+ job does, and then the tasks it started: a read still waiting for its
155
+ answer fails as `bus.closed`. Closing twice is closing once.
156
+ """
157
+ self._closed = True
158
+ self._refresher.dispose()
159
+ self._live.dispose()
160
+ self.bus.destroy()
161
+ await self._links.close()
semiont/error_codes.py ADDED
@@ -0,0 +1,232 @@
1
+ # Generated from specs/src/errors/codes.json; do not edit.
2
+ # Regenerate: node scripts/spec/generate-error-codes-python.mjs
3
+
4
+ """The codes a Semiont client reports a failure under.
5
+
6
+ Each vocabulary is a closed set: a `Literal` union, so a `match` over one that
7
+ ends in `assert_never` is checked to name every code, and a tuple of the same
8
+ codes to iterate.
9
+ """
10
+
11
+ from collections.abc import Mapping
12
+ from types import MappingProxyType
13
+ from typing import Final, Literal
14
+
15
+ __all__ = [
16
+ "BUS_REQUEST_CODE_BY_WIRE_CODE",
17
+ "BUS_REQUEST_ERROR_CODES",
18
+ "BusRequestErrorCode",
19
+ "IDENTITY_UNVERIFIABLE_REASONS",
20
+ "IdentityUnverifiableReason",
21
+ "JOB_ERROR_CODES",
22
+ "JobErrorCode",
23
+ "SEMIONT_SESSION_ERROR_CODES",
24
+ "SIGN_IN_ERROR_CODES",
25
+ "SemiontSessionErrorCode",
26
+ "SignInErrorCode",
27
+ "TRANSPORT_ERROR_CODES",
28
+ "TransportErrorCode",
29
+ "UNRECOGNIZED_FAILURE_CODE",
30
+ "transport_error_code_for_status",
31
+ ]
32
+
33
+ # Why a bus request did not resolve. Some members restate a failure the answering peer declared
34
+ # on the wire (`wire` names its `CommandError.code`); the rest are facts only the requesting
35
+ # side knows, which is why this vocabulary and the wire's stay separate.
36
+ type BusRequestErrorCode = Literal[
37
+ # No reply arrived within the time the request allowed.
38
+ "bus.timeout",
39
+ # The peer answered with a failure whose code this vocabulary does not name, or that states
40
+ # none.
41
+ "bus.rejected",
42
+ # The bus stopped before a reply came: its client was disposed, or its transport closed.
43
+ "bus.closed",
44
+ # The peer says this resource does not exist in its knowledge base.
45
+ "bus.not-found",
46
+ # THIS transport is not subscribed to the reply channel: a local misconfiguration, caught
47
+ # before emitting. Not to be confused with `bus.peer-unavailable`, which is the opposite
48
+ # end.
49
+ "bus.unsubscribed",
50
+ # The service that answers this channel is not connected. Transient by nature, a peer still
51
+ # starting, and therefore the one failure on this list worth retrying.
52
+ "bus.peer-unavailable",
53
+ # The peer says this caller may not do what it asked: authenticated, not permitted. Retrying
54
+ # under the same credential cannot succeed, so a consumer surfaces it and never spins on it.
55
+ "bus.unauthorized",
56
+ # The peer declined and nothing went wrong: a `job:claim` found no pending job of the
57
+ # requested types. The one member a consumer parks on.
58
+ "bus.none-pending",
59
+ ]
60
+
61
+ BUS_REQUEST_ERROR_CODES: Final[tuple[BusRequestErrorCode, ...]] = (
62
+ "bus.timeout",
63
+ "bus.rejected",
64
+ "bus.closed",
65
+ "bus.not-found",
66
+ "bus.unsubscribed",
67
+ "bus.peer-unavailable",
68
+ "bus.unauthorized",
69
+ "bus.none-pending",
70
+ )
71
+
72
+ # The bus code a failure's own wire code (`CommandError.code`) becomes.
73
+ BUS_REQUEST_CODE_BY_WIRE_CODE: Final[Mapping[str, BusRequestErrorCode]] = MappingProxyType(
74
+ {
75
+ "not-found": "bus.not-found",
76
+ "peer-unavailable": "bus.peer-unavailable",
77
+ "unauthorized": "bus.unauthorized",
78
+ "none-pending": "bus.none-pending",
79
+ }
80
+ )
81
+
82
+ # What a failure becomes when its code is one this vocabulary does not name, or absent.
83
+ UNRECOGNIZED_FAILURE_CODE: Final[BusRequestErrorCode] = "bus.rejected"
84
+
85
+ # Transport-neutral failure vocabulary. Every transport maps its native failures to one of
86
+ # these, so a routing layer matches on the code without knowing the wire kind. Over HTTP the
87
+ # mapping is by status: `status` is an exact status, `statusFrom` every status at or above it.
88
+ type TransportErrorCode = Literal[
89
+ # Authentication is required: the token is missing or expired.
90
+ "unauthorized",
91
+ # Authenticated, and lacking permission.
92
+ "forbidden",
93
+ # The resource is missing.
94
+ "not-found",
95
+ # A concurrent modification, a duplicate, or another conflict with current state.
96
+ "conflict",
97
+ # The request is malformed.
98
+ "bad-request",
99
+ # A limit refused it; the refusal's `Retry-After` says when to return.
100
+ "rate-limited",
101
+ # The gateway is unreachable, the network failed, or the server answered 5xx.
102
+ "unavailable",
103
+ # A failure no other member names.
104
+ "error",
105
+ ]
106
+
107
+ TRANSPORT_ERROR_CODES: Final[tuple[TransportErrorCode, ...]] = (
108
+ "unauthorized",
109
+ "forbidden",
110
+ "not-found",
111
+ "conflict",
112
+ "bad-request",
113
+ "rate-limited",
114
+ "unavailable",
115
+ "error",
116
+ )
117
+
118
+
119
+ def transport_error_code_for_status(status: int) -> TransportErrorCode:
120
+ """The transport code an HTTP status becomes."""
121
+ match status:
122
+ case 401:
123
+ return "unauthorized"
124
+ case 403:
125
+ return "forbidden"
126
+ case 404:
127
+ return "not-found"
128
+ case 409:
129
+ return "conflict"
130
+ case 400:
131
+ return "bad-request"
132
+ case 429:
133
+ return "rate-limited"
134
+ case _:
135
+ pass
136
+ if status >= 500:
137
+ return "unavailable"
138
+ return "error"
139
+
140
+
141
+ # Why a job its client follows ended without a result. A failure the queue will retry is not one
142
+ # of these: the job goes on, and its follower is told of the setback and keeps following.
143
+ type JobErrorCode = Literal[
144
+ # The job failed and will not be tried again: the failure its worker reported, or the one
145
+ # its status states.
146
+ "job.failed",
147
+ # The job was cancelled before it ended, as its status states. Nothing announces a
148
+ # cancellation: its follower learns of it when it asks for the job's status.
149
+ "job.cancelled",
150
+ # The job said nothing for longer than its follower allows. The follower has asked for that
151
+ # job to be cancelled.
152
+ "job.stalled",
153
+ ]
154
+
155
+ JOB_ERROR_CODES: Final[tuple[JobErrorCode, ...]] = (
156
+ "job.failed",
157
+ "job.cancelled",
158
+ "job.stalled",
159
+ )
160
+
161
+ # Why a session itself is unusable. Per-request failures stay with their caller.
162
+ type SemiontSessionErrorCode = Literal[
163
+ # A session for the knowledge base could not be built or made ready.
164
+ "session.construct-failed",
165
+ # The stored credential could not be validated at startup, for a reason other than the
166
+ # gateway refusing it.
167
+ "session.auth-failed",
168
+ # The session could not be renewed: the issuer refused, or stayed unreachable until the
169
+ # retry budget was spent. Not a prompt to retry: retrying already happened.
170
+ "session.refresh-exhausted",
171
+ # The gateway refused a token the issuer had just issued. The two disagree about who may
172
+ # sign in, and renewing again cannot change the answer: the session is over, and signing in
173
+ # again is what is left.
174
+ "session.credential-refused",
175
+ ]
176
+
177
+ SEMIONT_SESSION_ERROR_CODES: Final[tuple[SemiontSessionErrorCode, ...]] = (
178
+ "session.construct-failed",
179
+ "session.auth-failed",
180
+ "session.refresh-exhausted",
181
+ "session.credential-refused",
182
+ )
183
+
184
+ # Why a person could not be signed in at the issuer a knowledge base trusts, or a session could
185
+ # not be renewed there. Over the authorization-code grant, the device grant, the refresh grant
186
+ # and revocation alike.
187
+ type SignInErrorCode = Literal[
188
+ # The knowledge base trusts no external issuer: there is nowhere to sign in.
189
+ "no-issuer",
190
+ # The knowledge base did not say which issuer it trusts, or the issuer did not describe
191
+ # itself, or described another issuer, or offers no endpoint the grant needs.
192
+ "discovery",
193
+ # A sign-in response arrived and no sign-in is pending here: it was completed already, or
194
+ # begun somewhere else.
195
+ "no-pending",
196
+ # The sign-in response does not belong to the pending sign-in.
197
+ "state",
198
+ # The person, or the issuer, refused the sign-in.
199
+ "denied",
200
+ # The device code ran out before it was approved.
201
+ "expired",
202
+ # The sign-in was abandoned by whoever began it.
203
+ "aborted",
204
+ # The issuer refused a token request, a device authorization or a revocation, or answered
205
+ # without what was asked for.
206
+ "exchange",
207
+ ]
208
+
209
+ SIGN_IN_ERROR_CODES: Final[tuple[SignInErrorCode, ...]] = (
210
+ "no-issuer",
211
+ "discovery",
212
+ "no-pending",
213
+ "state",
214
+ "denied",
215
+ "expired",
216
+ "aborted",
217
+ "exchange",
218
+ )
219
+
220
+ # Why a knowledge base that was reached with a fresh sign-in could not be registered: it has to
221
+ # say who it is, and nothing stands in for its answer.
222
+ type IdentityUnverifiableReason = Literal[
223
+ # The knowledge base was not reached, so it said nothing.
224
+ "unreachable",
225
+ # The knowledge base answered that it cannot say what it is.
226
+ "not-reported",
227
+ ]
228
+
229
+ IDENTITY_UNVERIFIABLE_REASONS: Final[tuple[IdentityUnverifiableReason, ...]] = (
230
+ "unreachable",
231
+ "not-reported",
232
+ )
semiont/errors.py ADDED
@@ -0,0 +1,147 @@
1
+ """A failure, as this SDK reports one: under a code of `specs/src/errors/codes.json`.
2
+
3
+ Every failure is a `SemiontError`, and each vocabulary of codes has a class of
4
+ its own, so `except BusRequestError` reads as what it catches and a `match`
5
+ over `error.code` is checked to name every code.
6
+ """
7
+
8
+ from abc import ABC, abstractmethod
9
+ from collections.abc import Mapping
10
+ from typing import Final, final, override
11
+
12
+ from pydantic import JsonValue
13
+
14
+ from semiont.error_codes import (
15
+ BUS_REQUEST_CODE_BY_WIRE_CODE,
16
+ UNRECOGNIZED_FAILURE_CODE,
17
+ BusRequestErrorCode,
18
+ JobErrorCode,
19
+ SemiontSessionErrorCode,
20
+ SignInErrorCode,
21
+ TransportErrorCode,
22
+ transport_error_code_for_status,
23
+ )
24
+ from semiont.identifiers import JobId
25
+
26
+ __all__ = ["BusRequestError", "JobError", "SemiontError", "SessionError", "SignInError", "TransportError"]
27
+
28
+
29
+ class SemiontError(Exception, ABC):
30
+ """One failure. Its text is for a person; a program reads its `code`."""
31
+
32
+ def __init__(self, message: str, *, status: int | None = None, retry_after_ms: int | None = None) -> None:
33
+ super().__init__(message)
34
+ self.message: Final = message
35
+ self.status: Final = status
36
+ """The HTTP status, when a server stated one."""
37
+ self.retry_after_ms: Final = retry_after_ms
38
+ """The wait a refusal's `Retry-After` stated, in milliseconds, when it stated one."""
39
+
40
+ @property
41
+ @abstractmethod
42
+ def code(self) -> str:
43
+ """Which failure this is, in its vocabulary."""
44
+
45
+
46
+ @final
47
+ class TransportError(SemiontError):
48
+ """A request a server refused, or one that got no answer."""
49
+
50
+ def __init__(self, code: TransportErrorCode, message: str, *, status: int | None = None, retry_after_ms: int | None = None) -> None:
51
+ super().__init__(message, status=status, retry_after_ms=retry_after_ms)
52
+ self._code: Final = code
53
+
54
+ @property
55
+ @override
56
+ def code(self) -> TransportErrorCode:
57
+ return self._code
58
+
59
+ @classmethod
60
+ def of_status(cls, message: str, status: int, retry_after_ms: int | None) -> "TransportError":
61
+ """A server answered, and its status decides the code."""
62
+ return cls(transport_error_code_for_status(status), message, status=status, retry_after_ms=retry_after_ms)
63
+
64
+ @classmethod
65
+ def without_response(cls, message: str) -> "TransportError":
66
+ """Nothing answered: the connection failed, or the request's deadline passed."""
67
+ return cls("unavailable", message)
68
+
69
+
70
+ @final
71
+ class BusRequestError(SemiontError):
72
+ """A request over the bus that did not resolve with its response."""
73
+
74
+ def __init__(self, code: BusRequestErrorCode, message: str, *, failure: Mapping[str, JsonValue] | None = None) -> None:
75
+ super().__init__(message)
76
+ self._code: Final = code
77
+ self.failure: Final = failure
78
+ """What the peer answered on the operation's failure channel, when the peer answered."""
79
+
80
+ @property
81
+ @override
82
+ def code(self) -> BusRequestErrorCode:
83
+ return self._code
84
+
85
+ @classmethod
86
+ def answered(cls, failure: Mapping[str, JsonValue]) -> "BusRequestError":
87
+ """The failure a peer answered with, under the code its own code becomes.
88
+
89
+ A failure that states no code, or one this vocabulary does not name,
90
+ is `UNRECOGNIZED_FAILURE_CODE`: the peer may be newer than this build.
91
+ """
92
+ stated = failure.get("code")
93
+ said = failure.get("message")
94
+ code = (
95
+ BUS_REQUEST_CODE_BY_WIRE_CODE.get(stated, UNRECOGNIZED_FAILURE_CODE) if isinstance(stated, str) else UNRECOGNIZED_FAILURE_CODE
96
+ )
97
+ return cls(code, said if isinstance(said, str) else "Bus request rejected", failure=failure)
98
+
99
+
100
+ @final
101
+ class JobError(SemiontError):
102
+ """A job its follower saw end without completing: it failed for good, was cancelled, or said nothing for too long."""
103
+
104
+ def __init__(self, code: JobErrorCode, message: str, *, job_id: JobId | None) -> None:
105
+ super().__init__(message)
106
+ self._code: Final = code
107
+ self.job_id: Final = job_id
108
+ """The job, when its creation was answered: one that stalled before that has no id."""
109
+
110
+ @property
111
+ @override
112
+ def code(self) -> JobErrorCode:
113
+ return self._code
114
+
115
+
116
+ @final
117
+ class SignInError(SemiontError):
118
+ """A sign-in at the issuer a knowledge base trusts, or a renewal there, that did not succeed.
119
+
120
+ One that carries a status is the issuer's answer. One that carries none
121
+ got no answer: a refusal and an outage are different events.
122
+ """
123
+
124
+ def __init__(self, code: SignInErrorCode, message: str, *, status: int | None = None) -> None:
125
+ super().__init__(message, status=status)
126
+ self._code: Final = code
127
+
128
+ @property
129
+ @override
130
+ def code(self) -> SignInErrorCode:
131
+ return self._code
132
+
133
+
134
+ @final
135
+ class SessionError(SemiontError):
136
+ """A failure that makes a session itself unusable. A request's own failure stays with its caller."""
137
+
138
+ def __init__(self, code: SemiontSessionErrorCode, message: str, *, kb_id: str) -> None:
139
+ super().__init__(message)
140
+ self._code: Final = code
141
+ self.kb_id: Final = kb_id
142
+ """The knowledge base the session was with."""
143
+
144
+ @property
145
+ @override
146
+ def code(self) -> SemiontSessionErrorCode:
147
+ return self._code
semiont/event_bus.py ADDED
@@ -0,0 +1,108 @@
1
+ """A client's own bus: frames published and read inside one process.
2
+
3
+ A client makes one and hands it to its transport (`bridge_into`), which
4
+ delivers into it every frame it receives. What the client's own parts say to
5
+ each other goes through it too, and never reaches the wire.
6
+
7
+ A frame is published to everyone or into one resource's scope, and a reader
8
+ sees one or the other: a reader of a channel does not see a frame published
9
+ into a scope, and a scope's reader sees only its own.
10
+
11
+ A reader of one channel is given its frames in the order they were published.
12
+ Two readers give no order between them, so one that needs what was said on
13
+ several channels in the order it was said reads them as one (`frames_among`).
14
+
15
+ By type (`publish`, `frames`), the channel is one of `semiont.channels`'
16
+ constants and the payload is that channel's own, as on `semiont.bus.Bus`. By
17
+ name (`emit`, `frames_on`), a channel is its name and a payload a JSON object.
18
+ """
19
+
20
+ from collections.abc import Collection, Mapping
21
+ from typing import Final, final, override
22
+
23
+ from pydantic import JsonValue
24
+
25
+ from semiont.bus import Typed
26
+ from semiont.channel import Channel
27
+ from semiont.events import Broadcast, Events
28
+ from semiont.identifiers import ResourceId
29
+ from semiont.model import WireModel
30
+ from semiont.transport import Frame, FrameSink
31
+
32
+ __all__ = ["EventBus"]
33
+
34
+
35
+ @final
36
+ class EventBus(FrameSink):
37
+ """See the module's documentation."""
38
+
39
+ def __init__(self) -> None:
40
+ self._views: Final[dict[tuple[str, ResourceId | None], Broadcast[Frame]]] = {}
41
+ """The readers of each channel: of what is published to everyone, and of each scope."""
42
+ self._among: list[tuple[frozenset[str], Broadcast[Frame]]] = []
43
+ """The readers of several channels at once, each with the channels it reads."""
44
+ self._destroyed = False
45
+
46
+ @property
47
+ def destroyed(self) -> bool:
48
+ """Whether the bus has ended."""
49
+ return self._destroyed
50
+
51
+ @override
52
+ def deliver(self, frame: Frame) -> None:
53
+ """Publish a frame as it is: to everyone, or into the scope it names. A destroyed bus has no reader to give it to."""
54
+ if frame.scope is None:
55
+ for channels, readers in self._among:
56
+ if frame.channel in channels:
57
+ readers.deliver(frame)
58
+ view = self._views.get((frame.channel, frame.scope))
59
+ if view is not None:
60
+ view.deliver(frame)
61
+
62
+ def emit(
63
+ self, channel: str, payload: Mapping[str, JsonValue], *, scope: ResourceId | None = None, correlation_id: str | None = None
64
+ ) -> None:
65
+ """Publish one frame on the channel named `channel`: to everyone, or into `scope`."""
66
+ self.deliver(Frame(channel=channel, payload=payload, correlation_id=correlation_id, scope=scope))
67
+
68
+ def publish[P: WireModel](
69
+ self, channel: Channel[P], payload: P, *, scope: ResourceId | None = None, correlation_id: str | None = None
70
+ ) -> None:
71
+ """Publish one frame of `channel`: to everyone, or into `scope`."""
72
+ self.emit(channel.name, channel.encode(payload), scope=scope, correlation_id=correlation_id)
73
+
74
+ def frames_on(self, channel: str, *, scope: ResourceId | None = None) -> Events[Frame]:
75
+ """The frames published on the channel named `channel` from now on: to everyone, or into `scope`. Of a destroyed bus, none."""
76
+ view = self._views.get((channel, scope))
77
+ if view is None:
78
+ view = Broadcast[Frame]()
79
+ if self._destroyed:
80
+ view.close()
81
+ else:
82
+ self._views[channel, scope] = view
83
+ return view.listen()
84
+
85
+ def frames[P: WireModel](self, channel: Channel[P], *, scope: ResourceId | None = None) -> Typed[P]:
86
+ """The frames published on `channel` from now on, each with its payload decoded: to everyone, or into `scope`."""
87
+ return Typed(channel, self.frames_on(channel.name, scope=scope))
88
+
89
+ def frames_among(self, channels: Collection[str]) -> Events[Frame]:
90
+ """The frames published to everyone on any of `channels` from now on, in the order they were published."""
91
+ readers = Broadcast[Frame]()
92
+ if self._destroyed:
93
+ readers.close()
94
+ else:
95
+ # A view nobody reads any more is let go here, where the next one is made.
96
+ self._among = [(seen, kept) for seen, kept in self._among if kept.listening > 0]
97
+ self._among.append((frozenset(channels), readers))
98
+ return readers.listen()
99
+
100
+ def destroy(self) -> None:
101
+ """End every reader's frames, and every later reader's. Destroying twice is destroying once."""
102
+ self._destroyed = True
103
+ for view in self._views.values():
104
+ view.close()
105
+ for _, readers in self._among:
106
+ readers.close()
107
+ self._views.clear()
108
+ self._among.clear()