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/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Semiont's Python SDK.
|
|
2
|
+
|
|
3
|
+
What the protocol states, as Python types: the kinds of id
|
|
4
|
+
(`semiont.identifiers`), the shapes it sends and answers with
|
|
5
|
+
(`semiont.types`), the channels of its bus and the requests made over them
|
|
6
|
+
(`semiont.channels`, `semiont.operations`), and the tables a client keeps to
|
|
7
|
+
(`semiont.error_codes`, `semiont.timing`, `semiont.refresh`,
|
|
8
|
+
`semiont.telemetry_table`). All of it is generated from `specs/`.
|
|
9
|
+
|
|
10
|
+
The wire itself: what a client needs of whatever carries its bus, its content
|
|
11
|
+
and a gateway's own answers (`semiont.transport`), the bus typed by channel
|
|
12
|
+
(`semiont.bus`), a knowledge base's gateway as the carrier, and how a process
|
|
13
|
+
signs in there (`semiont.http`), and what is told to OpenTelemetry of it
|
|
14
|
+
(`semiont.telemetry`).
|
|
15
|
+
|
|
16
|
+
And the client every Semiont SDK has (`semiont.client`): its namespaces
|
|
17
|
+
(`semiont.namespaces`), each method a row of `specs/src/client/surface.json`,
|
|
18
|
+
its own bus (`semiont.event_bus`), and a session with one knowledge base
|
|
19
|
+
(`semiont.session`).
|
|
20
|
+
"""
|
semiont/bus.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""A client of a knowledge base's bus, over any `Transport`: emit, read, and request.
|
|
2
|
+
|
|
3
|
+
**By type** (`Bus`), a channel is one of `semiont.channels`' constants and a
|
|
4
|
+
payload is that channel's own, so a type checker refuses a wrong payload for a
|
|
5
|
+
channel, gives a frame its payload's type, and gives a request its
|
|
6
|
+
operation's reply:
|
|
7
|
+
|
|
8
|
+
bus = Bus(transport)
|
|
9
|
+
await bus.emit(BECKON_FOCUS, BeckonFocusEvent(annotation_id=annotation))
|
|
10
|
+
async for frame in bus.frames(MARK_ADDED): ... # frame.payload is that channel's
|
|
11
|
+
created = await bus.request(MARK_CREATE_REQUEST, command)
|
|
12
|
+
|
|
13
|
+
**By name** (`request`, and the transport's own `emit` and `frames`), a
|
|
14
|
+
channel is the registry's name and a payload a JSON object: for what relays
|
|
15
|
+
frames it does not read, or is told its channels at run time.
|
|
16
|
+
|
|
17
|
+
A request (`docs/protocol/TRANSPORT-CONTRACT.md` § Requests) is one emit that
|
|
18
|
+
carries a correlation id of this client's making
|
|
19
|
+
beside its payload, answered on its operation's result channel or its failure
|
|
20
|
+
channel. It is not sent before the stream that carries its reply is open. Its
|
|
21
|
+
id is tracked until it settles, so a reply published while the stream was down
|
|
22
|
+
is sent again. And it settles once: with its result, with a failure under this
|
|
23
|
+
vocabulary's code, or as a timeout.
|
|
24
|
+
|
|
25
|
+
A caller abandons a request by cancelling the task that awaits it. What was
|
|
26
|
+
sent stays sent, the reply stops being tracked, and one that comes anyway
|
|
27
|
+
reaches nobody. Cancelled while it waits for the stream, it sends nothing.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import asyncio
|
|
31
|
+
import logging
|
|
32
|
+
import uuid
|
|
33
|
+
from collections.abc import Mapping
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from types import TracebackType
|
|
36
|
+
from typing import Final, Self, final
|
|
37
|
+
|
|
38
|
+
from pydantic import JsonValue, ValidationError
|
|
39
|
+
|
|
40
|
+
from semiont.channel import AnyOperation, Channel, Operation
|
|
41
|
+
from semiont.errors import BusRequestError, TransportError
|
|
42
|
+
from semiont.events import Events
|
|
43
|
+
from semiont.identifiers import ResourceId, UserId
|
|
44
|
+
from semiont.model import WireModel
|
|
45
|
+
from semiont.timing import BUS_REQUEST_TIMEOUT_MS
|
|
46
|
+
from semiont.transport import ConnectionState, Frame, TraceContext, Transport
|
|
47
|
+
from semiont.watched import NeverReached, reached
|
|
48
|
+
|
|
49
|
+
__all__ = ["Bus", "Delivered", "Typed", "decoded", "reply_channels_for", "request"]
|
|
50
|
+
|
|
51
|
+
_LOG: Final = logging.getLogger("semiont.bus")
|
|
52
|
+
|
|
53
|
+
# What the gateway stamps on a payload to say who emitted it.
|
|
54
|
+
_EMITTED_BY: Final = "_userId"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def reply_channels_for(*operations: AnyOperation) -> tuple[str, ...]:
|
|
58
|
+
"""The result and failure channels of `operations`, each once.
|
|
59
|
+
|
|
60
|
+
What a process that awaits only those operations names as its transport's
|
|
61
|
+
channels: it hears its replies and nothing else.
|
|
62
|
+
"""
|
|
63
|
+
replies: dict[str, None] = {}
|
|
64
|
+
for operation in operations:
|
|
65
|
+
replies[operation.result.name] = None
|
|
66
|
+
replies[operation.failure.name] = None
|
|
67
|
+
return tuple(replies)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _settled(state: ConnectionState) -> bool:
|
|
71
|
+
return state in ("open", "closed")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def request(
|
|
75
|
+
transport: Transport,
|
|
76
|
+
operation: AnyOperation,
|
|
77
|
+
payload: Mapping[str, JsonValue],
|
|
78
|
+
*,
|
|
79
|
+
timeout_ms: int = BUS_REQUEST_TIMEOUT_MS,
|
|
80
|
+
) -> Mapping[str, JsonValue]:
|
|
81
|
+
"""Send `payload` as the request of `operation`, and wait up to `timeout_ms` for its reply.
|
|
82
|
+
|
|
83
|
+
Returns the payload of the reply on the operation's result channel.
|
|
84
|
+
Raises `BusRequestError` when it is answered with a failure, when no
|
|
85
|
+
reply comes in time, when the transport is closed, and when the
|
|
86
|
+
transport's stream does not carry the operation's replies; and
|
|
87
|
+
`TransportError` when the emit itself is refused.
|
|
88
|
+
"""
|
|
89
|
+
asked, result, failure = operation.request.name, operation.result.name, operation.failure.name
|
|
90
|
+
for channel in (result, failure):
|
|
91
|
+
if not transport.is_subscribed(channel):
|
|
92
|
+
raise BusRequestError(
|
|
93
|
+
"bus.unsubscribed",
|
|
94
|
+
f"Transport is not subscribed to reply channel {channel}: a reply to {asked} can never arrive. "
|
|
95
|
+
"Add this operation's reply channels to the transport's channels.",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
deadline = asyncio.get_running_loop().time() + timeout_ms / 1000
|
|
99
|
+
|
|
100
|
+
def timed_out() -> BusRequestError:
|
|
101
|
+
return BusRequestError("bus.timeout", f"Bus request timed out after {timeout_ms}ms on {result}")
|
|
102
|
+
|
|
103
|
+
# No request before its reply can arrive: wait, inside the request's own
|
|
104
|
+
# deadline, for the stream to be open. Only `open` delivers. A closed bus
|
|
105
|
+
# fails at once, and does not spend the deadline.
|
|
106
|
+
try:
|
|
107
|
+
async with asyncio.timeout_at(deadline):
|
|
108
|
+
state = await reached(transport.state, _settled)
|
|
109
|
+
except TimeoutError:
|
|
110
|
+
raise timed_out() from None
|
|
111
|
+
except NeverReached:
|
|
112
|
+
# A state that will change no more, and is not open, will not deliver.
|
|
113
|
+
state = "closed"
|
|
114
|
+
if state != "open":
|
|
115
|
+
raise BusRequestError("bus.closed", f"Bus closed before emit on {asked}")
|
|
116
|
+
|
|
117
|
+
# Tracked before the emit, so a stream opened while the emit is in flight
|
|
118
|
+
# already names the reply. The emit is not cut short by the deadline: a
|
|
119
|
+
# request half sent is one nobody can account for.
|
|
120
|
+
correlation_id = str(uuid.uuid4())
|
|
121
|
+
with transport.track_reply(correlation_id, (result, failure)) as pending:
|
|
122
|
+
await transport.emit(asked, payload, correlation_id=correlation_id)
|
|
123
|
+
try:
|
|
124
|
+
async with asyncio.timeout_at(deadline):
|
|
125
|
+
reply = await pending.frame()
|
|
126
|
+
except TimeoutError:
|
|
127
|
+
raise timed_out() from None
|
|
128
|
+
|
|
129
|
+
if reply is None:
|
|
130
|
+
raise BusRequestError("bus.closed", f"Bus closed before a reply on {result}")
|
|
131
|
+
if reply.channel == result:
|
|
132
|
+
return reply.payload
|
|
133
|
+
raise BusRequestError.answered(reply.payload)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ── By type ─────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def decoded[P: WireModel](channel: Channel[P], payload: Mapping[str, JsonValue]) -> P:
|
|
140
|
+
"""A payload as `channel` types it. Raises `ValidationError` for one that is not that channel's.
|
|
141
|
+
|
|
142
|
+
What the gateway stamps on a payload (a member whose name begins `_`) is
|
|
143
|
+
the bus's, not the payload's: one the channel's type does not declare is
|
|
144
|
+
left out before the payload is read.
|
|
145
|
+
"""
|
|
146
|
+
declared = {field.alias or name for name, field in channel.payload.model_fields.items()}
|
|
147
|
+
return channel.decode({name: value for name, value in payload.items() if not name.startswith("_") or name in declared})
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@final
|
|
151
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
152
|
+
class Delivered[P: WireModel]:
|
|
153
|
+
"""A frame of one channel, its payload decoded."""
|
|
154
|
+
|
|
155
|
+
payload: P
|
|
156
|
+
correlation_id: str | None = None
|
|
157
|
+
scope: ResourceId | None = None
|
|
158
|
+
user_id: UserId | None = None
|
|
159
|
+
"""Who emitted it, as the gateway stamped it."""
|
|
160
|
+
trace: TraceContext | None = None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@final
|
|
164
|
+
class Typed[P: WireModel]:
|
|
165
|
+
"""One channel's frames from now on, each with its payload decoded.
|
|
166
|
+
|
|
167
|
+
Iterated (`async for`), and can be held with `async with`, which stops
|
|
168
|
+
listening on the way out. A frame whose payload is not the channel's is
|
|
169
|
+
nobody's to act on: it is not given, and is said so in the log.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
def __init__(self, channel: Channel[P], frames: Events[Frame]) -> None:
|
|
173
|
+
self._channel: Final = channel
|
|
174
|
+
self._frames: Final = frames
|
|
175
|
+
|
|
176
|
+
def __aiter__(self) -> Self:
|
|
177
|
+
return self
|
|
178
|
+
|
|
179
|
+
async def __anext__(self) -> Delivered[P]:
|
|
180
|
+
while True:
|
|
181
|
+
frame = await anext(self._frames)
|
|
182
|
+
try:
|
|
183
|
+
payload = decoded(self._channel, frame.payload)
|
|
184
|
+
except ValidationError as error:
|
|
185
|
+
_LOG.warning("a payload on %s is not that channel's: %s", self._channel.name, error)
|
|
186
|
+
continue
|
|
187
|
+
stamped = frame.payload.get(_EMITTED_BY)
|
|
188
|
+
return Delivered(
|
|
189
|
+
payload=payload,
|
|
190
|
+
correlation_id=frame.correlation_id,
|
|
191
|
+
scope=frame.scope,
|
|
192
|
+
user_id=UserId.parse(stamped) if isinstance(stamped, str) else None,
|
|
193
|
+
trace=frame.trace,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
async def aclose(self) -> None:
|
|
197
|
+
"""Stop listening."""
|
|
198
|
+
await self._frames.aclose()
|
|
199
|
+
|
|
200
|
+
async def __aenter__(self) -> Self:
|
|
201
|
+
return self
|
|
202
|
+
|
|
203
|
+
async def __aexit__(self, kind: type[BaseException] | None, error: BaseException | None, trace: TracebackType | None) -> None:
|
|
204
|
+
await self.aclose()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@final
|
|
208
|
+
class Bus:
|
|
209
|
+
"""The bus over `transport`, typed by channel. See the module's documentation."""
|
|
210
|
+
|
|
211
|
+
def __init__(self, transport: Transport) -> None:
|
|
212
|
+
self.transport: Final = transport
|
|
213
|
+
|
|
214
|
+
async def emit[P: WireModel](
|
|
215
|
+
self, channel: Channel[P], payload: P, *, scope: ResourceId | None = None, correlation_id: str | None = None
|
|
216
|
+
) -> int | None:
|
|
217
|
+
"""Send one frame on `channel`. How many subscribers the gateway reached, or nothing when it kept no count."""
|
|
218
|
+
return await self.transport.emit(channel.name, channel.encode(payload), scope=scope, correlation_id=correlation_id)
|
|
219
|
+
|
|
220
|
+
def frames[P: WireModel](self, channel: Channel[P]) -> Typed[P]:
|
|
221
|
+
"""The frames delivered on `channel` from now on. Refused, as `bus.unsubscribed`, for a channel this transport can never deliver."""
|
|
222
|
+
return Typed(channel, self.transport.frames(channel.name))
|
|
223
|
+
|
|
224
|
+
async def request[Q: WireModel, R: WireModel, F: WireModel](
|
|
225
|
+
self, operation: Operation[Q, R, F], payload: Q, *, timeout_ms: int = BUS_REQUEST_TIMEOUT_MS
|
|
226
|
+
) -> R:
|
|
227
|
+
"""Send `payload` as the request of `operation`, and wait up to `timeout_ms` for its result.
|
|
228
|
+
|
|
229
|
+
Raises as `request` does, and `TransportError` when the result that
|
|
230
|
+
came is not the operation's.
|
|
231
|
+
"""
|
|
232
|
+
reply = await request(self.transport, operation, operation.request.encode(payload), timeout_ms=timeout_ms)
|
|
233
|
+
try:
|
|
234
|
+
return decoded(operation.result, reply)
|
|
235
|
+
except ValidationError as error:
|
|
236
|
+
raise TransportError("error", f"a payload on {operation.result.name} is not that channel's: {error}") from error
|
semiont/cache.py
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""A read-through cache: what a client's live queries answer from (`docs/protocol/CACHE-SEMANTICS.md`, B1 to B20).
|
|
2
|
+
|
|
3
|
+
**A key has one state, and every observer of the key holds it**: `Pending`
|
|
4
|
+
(no value yet; a fetch may be in flight), `Ready` (a value, which may be the
|
|
5
|
+
one shown while a newer is fetched), or `Failed` (a key with no value whose
|
|
6
|
+
fetch, and its one retry, failed). `Failed` is a state, not the end of
|
|
7
|
+
anything: an observer lives through it, and the next act on the key tries
|
|
8
|
+
again.
|
|
9
|
+
|
|
10
|
+
Two ways to read:
|
|
11
|
+
|
|
12
|
+
- `observe` is the live view. The first observer of a key with nothing starts
|
|
13
|
+
a fetch; later ones are given what is there. A failed fetch is tried once
|
|
14
|
+
more; a key with a value keeps showing it whatever its refetches do.
|
|
15
|
+
- `fetch` is the one-shot read. It always asks, gives what it was answered or
|
|
16
|
+
raises the failure it met, and never retries: its caller sees the failure
|
|
17
|
+
and decides.
|
|
18
|
+
|
|
19
|
+
`invalidate` asks again and keeps showing the value meanwhile; `set` writes a
|
|
20
|
+
value that is known; `remove` ends a key whose entity is gone. `dispose` is
|
|
21
|
+
terminal: every observer's states end, and nothing is fetched, retried or
|
|
22
|
+
failed after it.
|
|
23
|
+
|
|
24
|
+
A cache with a `CachePersister` begins with what was kept and saves its values
|
|
25
|
+
as they change. A value that was kept is shown at once and asked for again the
|
|
26
|
+
first time it is observed: nothing says it is still true.
|
|
27
|
+
|
|
28
|
+
An observer that falls behind is given the latest state, not each one it
|
|
29
|
+
missed: a state is what is true now.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
import asyncio
|
|
33
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Coroutine, Hashable, Mapping
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
from typing import Final, Literal, Protocol, final
|
|
36
|
+
|
|
37
|
+
from semiont.errors import BusRequestError, SemiontError
|
|
38
|
+
from semiont.watched import Variable
|
|
39
|
+
|
|
40
|
+
__all__ = ["SAVE_DEBOUNCE_MS", "Cache", "CachePersister", "CacheState", "Failed", "Pending", "Ready", "Runs", "closed"]
|
|
41
|
+
|
|
42
|
+
SAVE_DEBOUNCE_MS: Final = 50
|
|
43
|
+
"""How long a cache waits, after its values change, before it saves them: a burst of changes is one save."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@final
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class Pending:
|
|
49
|
+
"""No value yet."""
|
|
50
|
+
|
|
51
|
+
status: Literal["pending"] = field(default="pending", init=False)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@final
|
|
55
|
+
@dataclass(frozen=True, slots=True)
|
|
56
|
+
class Ready[T]:
|
|
57
|
+
"""A value."""
|
|
58
|
+
|
|
59
|
+
value: T
|
|
60
|
+
status: Literal["ready"] = field(default="ready", init=False)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@final
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class Failed:
|
|
66
|
+
"""No value, and the fetch for one failed, and failed again."""
|
|
67
|
+
|
|
68
|
+
error: SemiontError
|
|
69
|
+
status: Literal["failed"] = field(default="failed", init=False)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
type CacheState[T] = Pending | Ready[T] | Failed
|
|
73
|
+
"""The state of a key. A `match` over one names all three."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class CachePersister[K, V](Protocol):
|
|
77
|
+
"""Where a cache keeps its values so a later one begins with them."""
|
|
78
|
+
|
|
79
|
+
def load(self) -> Mapping[K, V] | None:
|
|
80
|
+
"""What was kept, asked once, when the cache is built."""
|
|
81
|
+
...
|
|
82
|
+
|
|
83
|
+
def save(self, entries: Mapping[K, V]) -> None:
|
|
84
|
+
"""Keep these: the cache's values, after they changed."""
|
|
85
|
+
...
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class Runs(Protocol):
|
|
89
|
+
"""What runs a cache's fetches: tasks that have ended by the time whoever owns them has closed."""
|
|
90
|
+
|
|
91
|
+
def run[T](self, work: Coroutine[object, object, T]) -> asyncio.Task[T]:
|
|
92
|
+
"""Run `work` as a task."""
|
|
93
|
+
...
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def closed() -> BusRequestError:
|
|
97
|
+
"""What a read of a closed client fails as: what a request of a closed bus does."""
|
|
98
|
+
return BusRequestError("bus.closed", "The client is closed")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@final
|
|
102
|
+
class _Key[V]:
|
|
103
|
+
def __init__(self, state: CacheState[V]) -> None:
|
|
104
|
+
self.state: Final = Variable[CacheState[V]](state)
|
|
105
|
+
self.in_flight: tuple[int, asyncio.Task[V]] | None = None
|
|
106
|
+
"""The fetch in flight for the key: its number, and its task. An
|
|
107
|
+
`invalidate` lets go of it without waiting, so a fetch whose answer
|
|
108
|
+
will never come cannot hold the key."""
|
|
109
|
+
|
|
110
|
+
def known(self) -> bool:
|
|
111
|
+
"""Whether anything asked for the key and has not let go of it."""
|
|
112
|
+
return self.in_flight is not None or not isinstance(self.state.value, Pending)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def _joined[V](fetch: asyncio.Task[V]) -> V:
|
|
116
|
+
"""What a fetch gives, to one of those waiting on it: whoever stops waiting does not stop the fetch."""
|
|
117
|
+
try:
|
|
118
|
+
return await asyncio.shield(fetch)
|
|
119
|
+
except asyncio.CancelledError:
|
|
120
|
+
# The fetch was ended by its client's closing, and its caller was not: that is a failure to hear, not a cancellation.
|
|
121
|
+
task = asyncio.current_task()
|
|
122
|
+
if fetch.cancelled() and (task is None or task.cancelling() == 0):
|
|
123
|
+
raise closed() from None
|
|
124
|
+
raise
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@final
|
|
128
|
+
class _Nothing[T]:
|
|
129
|
+
"""The states of a key of a disposed cache: none."""
|
|
130
|
+
|
|
131
|
+
def __aiter__(self) -> "_Nothing[T]":
|
|
132
|
+
return self
|
|
133
|
+
|
|
134
|
+
async def __anext__(self) -> T:
|
|
135
|
+
raise StopAsyncIteration
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@final
|
|
139
|
+
class Cache[K: Hashable, V]:
|
|
140
|
+
"""See the module's documentation.
|
|
141
|
+
|
|
142
|
+
`fetch` asks the service for a key's value. `tasks` runs the fetches.
|
|
143
|
+
With a `persister`, the cache begins with what it kept, and saves its
|
|
144
|
+
values there once they have stopped changing for `SAVE_DEBOUNCE_MS`.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
def __init__(
|
|
148
|
+
self,
|
|
149
|
+
fetch: Callable[[K], Awaitable[V]],
|
|
150
|
+
*,
|
|
151
|
+
tasks: Runs,
|
|
152
|
+
persister: CachePersister[K, V] | None = None,
|
|
153
|
+
) -> None:
|
|
154
|
+
self._fetch: Final = fetch
|
|
155
|
+
self._tasks: Final = tasks
|
|
156
|
+
self._persister: Final = persister
|
|
157
|
+
self._disposed = False
|
|
158
|
+
self._fetches = 0
|
|
159
|
+
self._saving: asyncio.TimerHandle | None = None
|
|
160
|
+
"""The wait before the next save, while one is owed."""
|
|
161
|
+
kept = None if persister is None else persister.load()
|
|
162
|
+
self._keys: Final[dict[K, _Key[V]]] = {key: _Key(Ready(value)) for key, value in (kept or {}).items()}
|
|
163
|
+
self._kept: Final[set[K]] = set(self._keys)
|
|
164
|
+
"""Keys whose value was kept by an earlier cache and has not been asked for by this one."""
|
|
165
|
+
|
|
166
|
+
def _held(self, key: K) -> _Key[V]:
|
|
167
|
+
held = self._keys.get(key)
|
|
168
|
+
if held is None:
|
|
169
|
+
held = self._keys[key] = _Key(Pending())
|
|
170
|
+
return held
|
|
171
|
+
|
|
172
|
+
def _values(self) -> dict[K, V]:
|
|
173
|
+
return {key: held.state.value.value for key, held in self._keys.items() if isinstance(held.state.value, Ready)}
|
|
174
|
+
|
|
175
|
+
def _owe_a_save(self) -> None:
|
|
176
|
+
"""The values changed: save them once they have stopped changing."""
|
|
177
|
+
if self._persister is None:
|
|
178
|
+
return
|
|
179
|
+
if self._saving is not None:
|
|
180
|
+
self._saving.cancel()
|
|
181
|
+
self._saving = asyncio.get_running_loop().call_later(SAVE_DEBOUNCE_MS / 1000, self._save)
|
|
182
|
+
|
|
183
|
+
def _save(self) -> None:
|
|
184
|
+
self._saving = None
|
|
185
|
+
if self._persister is not None:
|
|
186
|
+
self._persister.save(self._values())
|
|
187
|
+
|
|
188
|
+
def _fetching(self, key: K) -> asyncio.Task[V]:
|
|
189
|
+
"""Start a fetch for `key`, or join the one in flight."""
|
|
190
|
+
# Asked for by this cache now, whichever path asked.
|
|
191
|
+
self._kept.discard(key)
|
|
192
|
+
held = self._held(key)
|
|
193
|
+
if held.in_flight is not None:
|
|
194
|
+
return held.in_flight[1]
|
|
195
|
+
self._fetches += 1
|
|
196
|
+
fetch = self._tasks.run(self._fetched(key, self._fetches))
|
|
197
|
+
held.in_flight = (self._fetches, fetch)
|
|
198
|
+
return fetch
|
|
199
|
+
|
|
200
|
+
async def _fetched(self, key: K, number: int) -> V:
|
|
201
|
+
try:
|
|
202
|
+
value = await self._fetch(key)
|
|
203
|
+
finally:
|
|
204
|
+
# Only its own, and on every way out: an `invalidate` may have put a newer fetch in its place.
|
|
205
|
+
held = self._keys.get(key)
|
|
206
|
+
if held is not None and held.in_flight is not None and held.in_flight[0] == number:
|
|
207
|
+
held.in_flight = None
|
|
208
|
+
# A value from any fetch is written, the newest last: each is at least
|
|
209
|
+
# as new as what was there. A failure writes nothing. One answered
|
|
210
|
+
# after the cache was disposed has no key to write to.
|
|
211
|
+
held = self._keys.get(key)
|
|
212
|
+
if held is not None:
|
|
213
|
+
held.state.set(Ready(value))
|
|
214
|
+
self._owe_a_save()
|
|
215
|
+
return value
|
|
216
|
+
|
|
217
|
+
def _revalidate(self, key: K) -> None:
|
|
218
|
+
"""Fetch for the live view: a failure is tried once more, and a key with no value whose retry also fails becomes `Failed`."""
|
|
219
|
+
self._tasks.run(self._chain(key, self._fetching(key)))
|
|
220
|
+
|
|
221
|
+
async def _chain(self, key: K, first: asyncio.Task[V]) -> None:
|
|
222
|
+
try:
|
|
223
|
+
await _joined(first)
|
|
224
|
+
except SemiontError:
|
|
225
|
+
pass
|
|
226
|
+
else:
|
|
227
|
+
return
|
|
228
|
+
if self._disposed:
|
|
229
|
+
return
|
|
230
|
+
try:
|
|
231
|
+
await _joined(self._fetching(key))
|
|
232
|
+
except SemiontError as failure:
|
|
233
|
+
held = self._keys.get(key)
|
|
234
|
+
if held is not None and not isinstance(held.state.value, Ready):
|
|
235
|
+
held.state.set(Failed(failure))
|
|
236
|
+
|
|
237
|
+
def observe(self, key: K) -> AsyncIterator[CacheState[V]]:
|
|
238
|
+
"""The key's state, now and as it changes, until the cache is disposed.
|
|
239
|
+
|
|
240
|
+
Observing is what asks: a key with nothing is fetched, a failed key
|
|
241
|
+
is tried again, and a value an earlier cache kept is asked for anew
|
|
242
|
+
while it is shown.
|
|
243
|
+
"""
|
|
244
|
+
if self._disposed:
|
|
245
|
+
return _Nothing[CacheState[V]]()
|
|
246
|
+
was_kept = key in self._kept
|
|
247
|
+
held = self._held(key)
|
|
248
|
+
state = held.state.value
|
|
249
|
+
if isinstance(state, Failed):
|
|
250
|
+
# An observer arriving at a failed key starts over, for everyone: a fetch is in flight for all of them.
|
|
251
|
+
held.state.set(Pending())
|
|
252
|
+
asks = True
|
|
253
|
+
elif isinstance(state, Ready):
|
|
254
|
+
asks = was_kept
|
|
255
|
+
else:
|
|
256
|
+
asks = held.in_flight is None
|
|
257
|
+
observing = aiter(held.state)
|
|
258
|
+
if asks:
|
|
259
|
+
self._revalidate(key)
|
|
260
|
+
return observing
|
|
261
|
+
|
|
262
|
+
async def fetch(self, key: K) -> V:
|
|
263
|
+
"""Ask for the key's value now.
|
|
264
|
+
|
|
265
|
+
What the service answers, which every observer of the key is given
|
|
266
|
+
too; or the failure, which only this caller is. Concurrent asks for
|
|
267
|
+
one key share one fetch.
|
|
268
|
+
"""
|
|
269
|
+
if self._disposed:
|
|
270
|
+
raise closed()
|
|
271
|
+
return await _joined(self._fetching(key))
|
|
272
|
+
|
|
273
|
+
def get(self, key: K) -> V | None:
|
|
274
|
+
"""The key's value now, asking for nothing."""
|
|
275
|
+
held = self._keys.get(key)
|
|
276
|
+
state = None if held is None else held.state.value
|
|
277
|
+
return state.value if isinstance(state, Ready) else None
|
|
278
|
+
|
|
279
|
+
def known(self, key: K) -> bool:
|
|
280
|
+
"""Whether anything has asked for the key and not let go of it: it has a value or a failure, or a fetch for it is in flight."""
|
|
281
|
+
held = self._keys.get(key)
|
|
282
|
+
return held is not None and held.known()
|
|
283
|
+
|
|
284
|
+
def keys(self) -> list[K]:
|
|
285
|
+
"""Every key the cache knows."""
|
|
286
|
+
return [key for key, held in self._keys.items() if held.known()]
|
|
287
|
+
|
|
288
|
+
@property
|
|
289
|
+
def persistence_pending(self) -> bool:
|
|
290
|
+
"""Whether a cache that keeps its values has not yet kept what it knows: a fetch is in flight, or a save is owed."""
|
|
291
|
+
if self._persister is None:
|
|
292
|
+
return False
|
|
293
|
+
return self._saving is not None or any(held.in_flight is not None for held in self._keys.values())
|
|
294
|
+
|
|
295
|
+
def invalidate(self, key: K) -> None:
|
|
296
|
+
"""The key's value is out of date: ask again, showing what there is meanwhile.
|
|
297
|
+
|
|
298
|
+
A fetch already in flight is not waited on: its answer may never come.
|
|
299
|
+
"""
|
|
300
|
+
if self._disposed:
|
|
301
|
+
return
|
|
302
|
+
held = self._held(key)
|
|
303
|
+
held.in_flight = None
|
|
304
|
+
if isinstance(held.state.value, Failed):
|
|
305
|
+
held.state.set(Pending())
|
|
306
|
+
self._revalidate(key)
|
|
307
|
+
|
|
308
|
+
def invalidate_all(self) -> None:
|
|
309
|
+
"""`invalidate`, of every key the cache knows."""
|
|
310
|
+
for key in self.keys():
|
|
311
|
+
self.invalidate(key)
|
|
312
|
+
|
|
313
|
+
def remove(self, key: K, gone: SemiontError) -> None:
|
|
314
|
+
"""The key's entity is gone: its value is dropped and the key is failed with `gone`, at once, for every observer.
|
|
315
|
+
|
|
316
|
+
Nothing is asked for.
|
|
317
|
+
"""
|
|
318
|
+
if self._disposed:
|
|
319
|
+
return
|
|
320
|
+
held = self._held(key)
|
|
321
|
+
held.in_flight = None
|
|
322
|
+
held.state.set(Failed(gone))
|
|
323
|
+
self._owe_a_save()
|
|
324
|
+
|
|
325
|
+
def set(self, key: K, value: V) -> None:
|
|
326
|
+
"""The key's value is known: write it, asking for nothing."""
|
|
327
|
+
if self._disposed:
|
|
328
|
+
return
|
|
329
|
+
self._kept.discard(key)
|
|
330
|
+
self._held(key).state.set(Ready(value))
|
|
331
|
+
self._owe_a_save()
|
|
332
|
+
|
|
333
|
+
def dispose(self) -> None:
|
|
334
|
+
"""End the cache: a save that was owed is made now, every observer's states end, and nothing is fetched or written after.
|
|
335
|
+
|
|
336
|
+
Disposing twice is disposing once.
|
|
337
|
+
"""
|
|
338
|
+
if self._saving is not None:
|
|
339
|
+
self._saving.cancel()
|
|
340
|
+
self._save()
|
|
341
|
+
self._disposed = True
|
|
342
|
+
for held in self._keys.values():
|
|
343
|
+
held.state.end()
|
|
344
|
+
self._keys.clear()
|
|
345
|
+
self._kept.clear()
|