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