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
@@ -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