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/http/session.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""A person's session with a knowledge base over its gateway.
|
|
2
|
+
|
|
3
|
+
`session_from_kept` builds one over a sign-in that is kept somewhere: the one
|
|
4
|
+
`semiont login` made, when it is kept in a `semiont.sign_in_store.SignInStore`,
|
|
5
|
+
or one this application kept earlier. `sign_in_device` makes a sign-in to
|
|
6
|
+
keep, from a process with no browser. `sign_out` ends one.
|
|
7
|
+
|
|
8
|
+
A session built here renews its token at the issuer its kept sign-in names.
|
|
9
|
+
Renewals that are asked for together are one request: a stream refused and a
|
|
10
|
+
request refused at the same moment spend one refresh token, not two, of which
|
|
11
|
+
an issuer that rotates them would refuse the second. And a renewal, once it
|
|
12
|
+
is under way, runs to its end whoever stops waiting for it: a refresh token
|
|
13
|
+
that has been spent is not left without the one it bought.
|
|
14
|
+
|
|
15
|
+
A person's token is one credential source. An agent's is another
|
|
16
|
+
(`semiont.http.agent`), which feeds a transport the same way.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
from collections.abc import AsyncGenerator, Callable, Sequence
|
|
21
|
+
from contextlib import asynccontextmanager
|
|
22
|
+
from typing import Final, final
|
|
23
|
+
|
|
24
|
+
from semiont.channels import BRIDGED_CHANNELS
|
|
25
|
+
from semiont.client import CachePersistence, SemiontClient
|
|
26
|
+
from semiont.errors import SessionError, SignInError
|
|
27
|
+
from semiont.http.oauth import DeviceCode, issuer_client, renew_kept, revoke_at_issuer, sign_in_with_device_grant
|
|
28
|
+
from semiont.http.transport import HttpTransport
|
|
29
|
+
from semiont.oauth_clients import SCRIPT_CLIENT_ID
|
|
30
|
+
from semiont.resume import CoupledBookmarks
|
|
31
|
+
from semiont.session import HeldSignIn, SemiontSession, SessionEndReason, SignInKept, Validate
|
|
32
|
+
from semiont.storage import SessionStorage
|
|
33
|
+
from semiont.types import UserResponse
|
|
34
|
+
from semiont.watched import Variable
|
|
35
|
+
|
|
36
|
+
__all__ = ["session_from_kept", "sign_in_device", "sign_out"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _heard(renewal: asyncio.Task[str | None]) -> None:
|
|
40
|
+
"""Take a renewal's failure as heard: each of its waiters was told, and there may be none."""
|
|
41
|
+
if not renewal.cancelled():
|
|
42
|
+
renewal.exception()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@final
|
|
46
|
+
class _Renewing:
|
|
47
|
+
"""A kept sign-in's renewals at its issuer, one at a time: a renewal asked for while one is under way is that one."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, kept: SignInKept) -> None:
|
|
50
|
+
self._kept: Final = kept
|
|
51
|
+
self._under_way: asyncio.Task[str | None] | None = None
|
|
52
|
+
|
|
53
|
+
async def __call__(self) -> str | None:
|
|
54
|
+
if self._under_way is None:
|
|
55
|
+
self._under_way = asyncio.create_task(self._once())
|
|
56
|
+
# A renewal everyone stopped waiting for has nobody to tell how it ended.
|
|
57
|
+
self._under_way.add_done_callback(_heard)
|
|
58
|
+
# Whoever stops waiting does not stop the renewal.
|
|
59
|
+
return await asyncio.shield(self._under_way)
|
|
60
|
+
|
|
61
|
+
async def _once(self) -> str | None:
|
|
62
|
+
try:
|
|
63
|
+
return await renew_kept(self._kept)
|
|
64
|
+
finally:
|
|
65
|
+
self._under_way = None
|
|
66
|
+
|
|
67
|
+
async def close(self) -> None:
|
|
68
|
+
"""End the renewal under way, when there is one: nobody is left to give its token to."""
|
|
69
|
+
under_way = self._under_way
|
|
70
|
+
if under_way is not None:
|
|
71
|
+
under_way.cancel()
|
|
72
|
+
await asyncio.gather(under_way, return_exceptions=True)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _asking(base_url: str) -> Validate:
|
|
76
|
+
"""Ask the gateway who a token is: one request, by a caller that holds nothing else.
|
|
77
|
+
|
|
78
|
+
No stream is opened for it, and nothing renews the token: a refusal is
|
|
79
|
+
the answer.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
async def ask(token: str) -> UserResponse:
|
|
83
|
+
asking = HttpTransport(base_url, token=Variable[str | None](token), channels=())
|
|
84
|
+
try:
|
|
85
|
+
return await asking.get_current_user()
|
|
86
|
+
finally:
|
|
87
|
+
await asking.close()
|
|
88
|
+
|
|
89
|
+
return ask
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@asynccontextmanager
|
|
93
|
+
async def session_from_kept(
|
|
94
|
+
base_url: str,
|
|
95
|
+
*,
|
|
96
|
+
kb_id: str,
|
|
97
|
+
kept: SignInKept,
|
|
98
|
+
validate: bool = True,
|
|
99
|
+
channels: Sequence[str] = BRIDGED_CHANNELS,
|
|
100
|
+
storage: SessionStorage | None = None,
|
|
101
|
+
on_auth_failed: Callable[[SessionEndReason], None] | None = None,
|
|
102
|
+
on_error: Callable[[SessionError], None] | None = None,
|
|
103
|
+
) -> AsyncGenerator[SemiontSession[HttpTransport]]:
|
|
104
|
+
"""A session with the knowledge base whose gateway is at `base_url`, over the sign-in `kept` holds.
|
|
105
|
+
|
|
106
|
+
Held with `async with`, and ready inside it: the session, its client and
|
|
107
|
+
the client's transport are open, and each is closed on the way out. A
|
|
108
|
+
token the gateway refuses is renewed through the session, so the transport
|
|
109
|
+
and the session never hold different ones.
|
|
110
|
+
|
|
111
|
+
With nothing kept, the session is signed out: its `token` is none, and a
|
|
112
|
+
request made through its client is refused. `validate` is whether the
|
|
113
|
+
gateway is asked who the token is; `channels` are the global channels the
|
|
114
|
+
transport's stream names.
|
|
115
|
+
|
|
116
|
+
With a `storage`, what the client's small queries hold is kept there
|
|
117
|
+
under the knowledge base's id, and the stream's place in each scope with
|
|
118
|
+
it: the next session over the same storage shows what was kept at once,
|
|
119
|
+
and is sent what was recorded since.
|
|
120
|
+
"""
|
|
121
|
+
token = Variable[str | None](None)
|
|
122
|
+
renewing = _Renewing(kept)
|
|
123
|
+
# The stream's place rides the caches' writes, and only when every cache is
|
|
124
|
+
# at rest: a place kept ahead of a cache still taking in the event it names
|
|
125
|
+
# would have the next session skip that event.
|
|
126
|
+
places = None if storage is None else CoupledBookmarks(storage, f"semiont.lastEventId.{kb_id}")
|
|
127
|
+
transport = HttpTransport(base_url, token=token, refresher=lambda: session.refresh(), channels=channels, bookmarks=places)
|
|
128
|
+
client = SemiontClient(
|
|
129
|
+
transport,
|
|
130
|
+
transport.content,
|
|
131
|
+
transport,
|
|
132
|
+
persistence=None if places is None else CachePersistence(storage=places.storage, key_prefix=kb_id),
|
|
133
|
+
)
|
|
134
|
+
if places is not None:
|
|
135
|
+
places.set_flush_gate(lambda: client.persistence_settled)
|
|
136
|
+
session: SemiontSession[HttpTransport] = SemiontSession(
|
|
137
|
+
kb_id=kb_id,
|
|
138
|
+
client=client,
|
|
139
|
+
token=token,
|
|
140
|
+
kept=kept,
|
|
141
|
+
refresh=renewing,
|
|
142
|
+
validate=_asking(base_url) if validate else None,
|
|
143
|
+
on_auth_failed=on_auth_failed,
|
|
144
|
+
on_error=on_error,
|
|
145
|
+
)
|
|
146
|
+
try:
|
|
147
|
+
async with transport, client, session:
|
|
148
|
+
await session.ready()
|
|
149
|
+
yield session
|
|
150
|
+
finally:
|
|
151
|
+
await renewing.close()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
async def sign_in_device(base_url: str, kept: SignInKept, on_code: Callable[[DeviceCode], None]) -> None:
|
|
155
|
+
"""Sign in as a person from a process with no browser, and keep the sign-in in `kept`.
|
|
156
|
+
|
|
157
|
+
The issuer the knowledge base at `base_url` trusts mints a code, `on_code`
|
|
158
|
+
shows the person where to approve it, and the tokens come back here: no
|
|
159
|
+
password passes through this process. Raises `SignInError` when the
|
|
160
|
+
person is not signed in.
|
|
161
|
+
"""
|
|
162
|
+
issuer, tokens = await sign_in_with_device_grant(base_url, on_code)
|
|
163
|
+
await kept.keep(
|
|
164
|
+
HeldSignIn(
|
|
165
|
+
access=tokens.access,
|
|
166
|
+
refresh=tokens.refresh,
|
|
167
|
+
client_id=SCRIPT_CLIENT_ID,
|
|
168
|
+
token_endpoint=issuer.token,
|
|
169
|
+
revocation_endpoint=issuer.revocation,
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
async def sign_out(kept: SignInKept) -> None:
|
|
175
|
+
"""End the sign-in `kept` holds: it is revoked at its issuer, when the issuer says where, and kept no more.
|
|
176
|
+
|
|
177
|
+
The sign-in is forgotten whatever the issuer answers: a person who signs
|
|
178
|
+
out is signed out here, and an issuer that could not be told forgets the
|
|
179
|
+
token when it expires.
|
|
180
|
+
"""
|
|
181
|
+
held = await kept.held()
|
|
182
|
+
if held is None:
|
|
183
|
+
return
|
|
184
|
+
try:
|
|
185
|
+
if held.revocation_endpoint is not None:
|
|
186
|
+
async with issuer_client() as http:
|
|
187
|
+
await revoke_at_issuer(http, held.revocation_endpoint, held.client_id, held.refresh)
|
|
188
|
+
except SignInError:
|
|
189
|
+
pass
|
|
190
|
+
finally:
|
|
191
|
+
await kept.forget()
|
semiont/http/sse.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""The stream's framing (`docs/protocol/TRANSPORT-HTTP.md` § Wire framing and client parser obligations).
|
|
2
|
+
|
|
3
|
+
`text/event-stream`: each event an `event` line, an `id` line and one `data`
|
|
4
|
+
line, ended by a blank line.
|
|
5
|
+
|
|
6
|
+
The parser holds its state across reads. One event can span many reads of the
|
|
7
|
+
connection, and a read can end anywhere: inside a line, between a line and its
|
|
8
|
+
blank line, inside a character. So it keeps bytes until a line is whole and
|
|
9
|
+
decodes a line only then, and it scans each read once, however long the line
|
|
10
|
+
it belongs to has grown.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import final
|
|
15
|
+
|
|
16
|
+
__all__ = ["SseEvent", "SseParser"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@final
|
|
20
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
21
|
+
class SseEvent:
|
|
22
|
+
"""One event of the stream."""
|
|
23
|
+
|
|
24
|
+
event: str
|
|
25
|
+
id: str | None
|
|
26
|
+
data: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@final
|
|
30
|
+
class SseParser:
|
|
31
|
+
"""Takes a stream's bytes as they are read, and gives the events they complete."""
|
|
32
|
+
|
|
33
|
+
def __init__(self) -> None:
|
|
34
|
+
self._line: list[bytes] = []
|
|
35
|
+
"""The pieces of the line not yet ended, joined once, when its newline arrives."""
|
|
36
|
+
self._event = ""
|
|
37
|
+
self._id: str | None = None
|
|
38
|
+
self._data: str | None = None
|
|
39
|
+
|
|
40
|
+
def feed(self, read: bytes) -> list[SseEvent]:
|
|
41
|
+
"""Take the next bytes read. Returns the events they complete."""
|
|
42
|
+
events: list[SseEvent] = []
|
|
43
|
+
start = 0
|
|
44
|
+
while (end := read.find(b"\n", start)) >= 0:
|
|
45
|
+
self._line.append(read[start:end])
|
|
46
|
+
start = end + 1
|
|
47
|
+
line = b"".join(self._line)
|
|
48
|
+
self._line.clear()
|
|
49
|
+
event = self._line_ended(line)
|
|
50
|
+
if event is not None:
|
|
51
|
+
events.append(event)
|
|
52
|
+
if start < len(read):
|
|
53
|
+
self._line.append(read[start:])
|
|
54
|
+
return events
|
|
55
|
+
|
|
56
|
+
def _line_ended(self, raw: bytes) -> SseEvent | None:
|
|
57
|
+
line = raw.decode("utf-8", errors="replace").removesuffix("\r")
|
|
58
|
+
if line == "":
|
|
59
|
+
event = SseEvent(event=self._event, id=self._id, data=self._data or "")
|
|
60
|
+
had_anything = event.event != "" or event.id is not None or event.data != ""
|
|
61
|
+
self._event, self._id, self._data = "", None, None
|
|
62
|
+
return event if had_anything else None
|
|
63
|
+
field, _, value = line.partition(":")
|
|
64
|
+
value = value.removeprefix(" ")
|
|
65
|
+
if field == "event":
|
|
66
|
+
self._event = value
|
|
67
|
+
elif field == "id":
|
|
68
|
+
self._id = value
|
|
69
|
+
elif field == "data":
|
|
70
|
+
self._data = value if self._data is None else f"{self._data}\n{value}"
|
|
71
|
+
return None
|