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
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()
|