semiont 0.6.12.dev2__tar.gz

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 (67) hide show
  1. semiont-0.6.12.dev2/.gitignore +126 -0
  2. semiont-0.6.12.dev2/PKG-INFO +533 -0
  3. semiont-0.6.12.dev2/README.md +509 -0
  4. semiont-0.6.12.dev2/pyproject.toml +133 -0
  5. semiont-0.6.12.dev2/src/semiont/__init__.py +20 -0
  6. semiont-0.6.12.dev2/src/semiont/bus.py +236 -0
  7. semiont-0.6.12.dev2/src/semiont/cache.py +345 -0
  8. semiont-0.6.12.dev2/src/semiont/cached.py +152 -0
  9. semiont-0.6.12.dev2/src/semiont/channel.py +81 -0
  10. semiont-0.6.12.dev2/src/semiont/channels.py +1306 -0
  11. semiont-0.6.12.dev2/src/semiont/client.py +161 -0
  12. semiont-0.6.12.dev2/src/semiont/error_codes.py +232 -0
  13. semiont-0.6.12.dev2/src/semiont/errors.py +147 -0
  14. semiont-0.6.12.dev2/src/semiont/event_bus.py +108 -0
  15. semiont-0.6.12.dev2/src/semiont/events.py +101 -0
  16. semiont-0.6.12.dev2/src/semiont/http/__init__.py +24 -0
  17. semiont-0.6.12.dev2/src/semiont/http/agent.py +232 -0
  18. semiont-0.6.12.dev2/src/semiont/http/content.py +234 -0
  19. semiont-0.6.12.dev2/src/semiont/http/exchange.py +200 -0
  20. semiont-0.6.12.dev2/src/semiont/http/oauth.py +346 -0
  21. semiont-0.6.12.dev2/src/semiont/http/session.py +191 -0
  22. semiont-0.6.12.dev2/src/semiont/http/sse.py +71 -0
  23. semiont-0.6.12.dev2/src/semiont/http/stream.py +683 -0
  24. semiont-0.6.12.dev2/src/semiont/http/transport.py +326 -0
  25. semiont-0.6.12.dev2/src/semiont/identifiers.py +114 -0
  26. semiont-0.6.12.dev2/src/semiont/identity.py +59 -0
  27. semiont-0.6.12.dev2/src/semiont/media_types.py +69 -0
  28. semiont-0.6.12.dev2/src/semiont/media_types_table.py +89 -0
  29. semiont-0.6.12.dev2/src/semiont/model.py +33 -0
  30. semiont-0.6.12.dev2/src/semiont/namespaces/__init__.py +19 -0
  31. semiont-0.6.12.dev2/src/semiont/namespaces/auth.py +32 -0
  32. semiont-0.6.12.dev2/src/semiont/namespaces/beckon.py +47 -0
  33. semiont-0.6.12.dev2/src/semiont/namespaces/bind.py +38 -0
  34. semiont-0.6.12.dev2/src/semiont/namespaces/browse.py +338 -0
  35. semiont-0.6.12.dev2/src/semiont/namespaces/follow.py +238 -0
  36. semiont-0.6.12.dev2/src/semiont/namespaces/frame.py +35 -0
  37. semiont-0.6.12.dev2/src/semiont/namespaces/gather.py +82 -0
  38. semiont-0.6.12.dev2/src/semiont/namespaces/job.py +94 -0
  39. semiont-0.6.12.dev2/src/semiont/namespaces/links.py +66 -0
  40. semiont-0.6.12.dev2/src/semiont/namespaces/live.py +301 -0
  41. semiont-0.6.12.dev2/src/semiont/namespaces/mark.py +174 -0
  42. semiont-0.6.12.dev2/src/semiont/namespaces/match.py +67 -0
  43. semiont-0.6.12.dev2/src/semiont/namespaces/refresher.py +283 -0
  44. semiont-0.6.12.dev2/src/semiont/namespaces/system.py +24 -0
  45. semiont-0.6.12.dev2/src/semiont/namespaces/yield_.py +101 -0
  46. semiont-0.6.12.dev2/src/semiont/oauth_clients.py +27 -0
  47. semiont-0.6.12.dev2/src/semiont/operations.py +337 -0
  48. semiont-0.6.12.dev2/src/semiont/py.typed +0 -0
  49. semiont-0.6.12.dev2/src/semiont/refresh.py +188 -0
  50. semiont-0.6.12.dev2/src/semiont/resume.py +95 -0
  51. semiont-0.6.12.dev2/src/semiont/retry.py +164 -0
  52. semiont-0.6.12.dev2/src/semiont/running.py +110 -0
  53. semiont-0.6.12.dev2/src/semiont/session.py +493 -0
  54. semiont-0.6.12.dev2/src/semiont/sign_in.py +54 -0
  55. semiont-0.6.12.dev2/src/semiont/sign_in_store.py +283 -0
  56. semiont-0.6.12.dev2/src/semiont/storage.py +139 -0
  57. semiont-0.6.12.dev2/src/semiont/telemetry.py +116 -0
  58. semiont-0.6.12.dev2/src/semiont/telemetry_table.py +121 -0
  59. semiont-0.6.12.dev2/src/semiont/testing/__init__.py +62 -0
  60. semiont-0.6.12.dev2/src/semiont/testing/client.py +53 -0
  61. semiont-0.6.12.dev2/src/semiont/testing/content.py +124 -0
  62. semiont-0.6.12.dev2/src/semiont/testing/gateway.py +85 -0
  63. semiont-0.6.12.dev2/src/semiont/testing/transport.py +341 -0
  64. semiont-0.6.12.dev2/src/semiont/timing.py +187 -0
  65. semiont-0.6.12.dev2/src/semiont/transport.py +531 -0
  66. semiont-0.6.12.dev2/src/semiont/types.py +5902 -0
  67. semiont-0.6.12.dev2/src/semiont/watched.py +111 -0
@@ -0,0 +1,126 @@
1
+ # Dependencies
2
+ node_modules
3
+ .npm-stage
4
+
5
+ # Local Verdaccio registry (build_and_install_locally.sh)
6
+ .verdaccio/
7
+ npm-debug.log*
8
+ yarn-debug.log*
9
+ yarn-error.log*
10
+ pnpm-debug.log*
11
+
12
+ # Environment files
13
+ .env
14
+ .env.local
15
+ .env.development.local
16
+ .env.test.local
17
+ .env.production.local
18
+
19
+ # Build outputs
20
+ packages/*/dist/
21
+ dist/
22
+ # transient tsc output: packages/core's build emits it, then rm -rf's it at the end
23
+ dist-types/
24
+ build/
25
+ apps/browser/public
26
+
27
+ # Testing
28
+ coverage/
29
+ *.lcov
30
+ .nyc_output
31
+ test-results.json
32
+ playwright-report/
33
+ test-results/
34
+
35
+ # Logs
36
+ logs
37
+ *.log
38
+
39
+ # Runtime data
40
+ pids
41
+ *.pid
42
+ *.seed
43
+ *.pid.lock
44
+
45
+ # OS generated files
46
+ .DS_Store
47
+ .DS_Store?
48
+ ._*
49
+ .Spotlight-V100
50
+ .Trashes
51
+ ehthumbs.db
52
+ Thumbs.db
53
+
54
+ # IDE and editor files
55
+ .vscode/
56
+ .idea/
57
+ *.swp
58
+ *.swo
59
+ *~
60
+ .project
61
+ .settings/
62
+ *.sublime-project
63
+ *.sublime-workspace
64
+
65
+ # Temporary files
66
+ tmp/
67
+ temp/
68
+ *.tmp
69
+ *.temp
70
+
71
+ # Cache directories
72
+ .cache/
73
+ .parcel-cache/
74
+ .eslintcache
75
+
76
+ # TypeScript
77
+ *.tsbuildinfo
78
+
79
+ # Generated types and copied specs (NOT the source OpenAPI spec!)
80
+ packages/core/openapi.json
81
+ packages/core/src/types.ts
82
+ packages/core/src/generated/
83
+ packages/core/src/config/config.types.ts
84
+
85
+ # Python: an environment, and what the interpreter and its tools cache
86
+ .venv/
87
+ __pycache__/
88
+ .mypy_cache/
89
+ .pytest_cache/
90
+ .ruff_cache/
91
+
92
+ # Generated OpenAPI bundle (source is specs/src/)
93
+ specs/openapi.json
94
+ specs/archivist.openapi.json
95
+
96
+ # Compiled scripts (should use tsx, not tsc)
97
+ packages/*/scripts/*.js
98
+ packages/*/scripts/*.js.map
99
+ packages/*/scripts/*.d.ts
100
+ packages/*/scripts/*.d.ts.map
101
+ # Note: apps/*/scripts/*.js removed - these are actual source JS files, not compiled output
102
+ apps/*/scripts/*.js.map
103
+ apps/*/scripts/*.d.ts
104
+ apps/*/scripts/*.d.ts.map
105
+
106
+ # Ephemeral Planning Documents
107
+ .plans
108
+
109
+ # Assembled by scripts/release/notes-context.sh; quotes .plans content verbatim
110
+ .release-notes-context.md
111
+
112
+ # Compliance audit generated files
113
+ **/scripts/symbols.json
114
+ # Generated compliance reports (all of them — never enumerate outputs by hand)
115
+ .compliance/
116
+
117
+ # tesseract.js language data — fetched at runtime into the working directory.
118
+ # NOT a source artifact: the images vendor it at build time (OCR is core and
119
+ # must never CDN-fetch in production; SMELTER-MEDIA-TYPES decision 8).
120
+ *.traineddata
121
+
122
+ # Backups written beside .env.local by tooling
123
+ .env*.backup*
124
+
125
+ # Cargo's build output, for the whole Rust workspace (Cargo.toml).
126
+ /target
@@ -0,0 +1,533 @@
1
+ Metadata-Version: 2.5
2
+ Name: semiont
3
+ Version: 0.6.12.dev2
4
+ Summary: The Python SDK for Semiont, an open platform for building trusted AI knowledge bases
5
+ Project-URL: Homepage, https://the-ai-alliance.github.io/semiont/
6
+ Project-URL: Documentation, https://github.com/The-AI-Alliance/semiont/blob/main/docs/builder/README.md
7
+ Project-URL: Repository, https://github.com/The-AI-Alliance/semiont
8
+ Project-URL: Issues, https://github.com/The-AI-Alliance/semiont/issues
9
+ Project-URL: Changelog, https://github.com/The-AI-Alliance/semiont/releases
10
+ License-Expression: Apache-2.0
11
+ Keywords: agents,annotation,knowledge-base,sdk,semiont
12
+ Classifier: Framework :: AsyncIO
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.12
20
+ Requires-Dist: httpx<1,>=0.28
21
+ Requires-Dist: opentelemetry-api<2,>=1.30
22
+ Requires-Dist: pydantic<3,>=2.12
23
+ Description-Content-Type: text/markdown
24
+
25
+ # semiont
26
+
27
+ The Python SDK for [Semiont][semiont], an open platform for building trusted
28
+ AI knowledge bases: a shared workspace where humans and AI agents annotate,
29
+ connect and govern a corpus of documents.
30
+
31
+ This package is the client. With it a program reads and writes one knowledge
32
+ base: it adds resources, annotates and links them, searches them, gathers
33
+ context for a model, and hears what the other participants do as they do it.
34
+ A person's application and an AI agent use the same client. The SDK does not
35
+ tell them apart.
36
+
37
+ It has the same namespaces, methods and live queries as the
38
+ [TypeScript SDK][sdk-typescript] and the [Rust SDK][sdk-rust], and is held to
39
+ the same [conformance suite][conformance]. New to Semiont? The
40
+ [Introduction][introduction] explains the domain and the ideas the API falls
41
+ out of. Its code is TypeScript, and the ideas are this package's too.
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ pip install semiont
47
+ ```
48
+
49
+ It requires Python 3.12 or later, and runs on asyncio. It is checked by
50
+ `mypy --strict` and by `pyright` in strict mode, and ships its types
51
+ (`py.typed`): a wrong id, a wrong payload for a channel and an unhandled code
52
+ or state are errors a type checker reports.
53
+
54
+ One package holds all of it: the client, the transport that carries it over
55
+ HTTP to a knowledge base's gateway, signing in, and the doubles a test is
56
+ built on. Its dependencies are `httpx`, `pydantic` and `opentelemetry-api`.
57
+
58
+ A client speaks to a running knowledge base. The [Quick Start][quick-start]
59
+ sets one up on your own machine.
60
+
61
+ ## The client
62
+
63
+ A client is the eleven namespaces over one transport: `frame`, `browse`,
64
+ `mark`, `bind`, `gather`, `match`, `yield_` and `beckon`, one per
65
+ [flow of the protocol][protocol], and `job`, `auth` and `system` beside them.
66
+ `yield` is Python's own word, so its namespace is `yield_`. Each method is a
67
+ row of [`specs/src/client/surface.json`][surface], the table every Semiont SDK
68
+ is held to. `HttpTransport` is the transport this package ships: it reaches a
69
+ knowledge base's gateway, and sends the token it is given.
70
+
71
+ ```python
72
+ from semiont.client import SemiontClient
73
+ from semiont.http import HttpTransport
74
+ from semiont.identifiers import AnnotationId, ResourceId
75
+ from semiont.namespaces.follow import JobAttemptFailed, JobCompleted, JobProgressed
76
+ from semiont.namespaces.mark import MarkAssistOptions
77
+ from semiont.transport import Transport
78
+ from semiont.watched import Variable
79
+
80
+
81
+ async def annotate(client: SemiontClient[Transport], resource: ResourceId, annotation: AnnotationId) -> None:
82
+ described = await client.browse.resource(resource).fresh() # a query, read once
83
+ text = await client.browse.resource_content(resource) # asked once, answered once
84
+ print(described.name, len(text))
85
+
86
+ async for event in client.mark.assist(resource, "highlighting", MarkAssistOptions()): # a job, followed
87
+ match event:
88
+ case JobProgressed(data=progress):
89
+ print(progress.percentage)
90
+ case JobAttemptFailed(data=setback):
91
+ print("trying again after:", setback.error)
92
+ case JobCompleted(data=done):
93
+ print(done.result)
94
+
95
+ reached = await client.beckon.attention(resource, annotation) # a drive: how many the gateway reached
96
+ client.browse.click(annotation) # a signal: this viewer's own, never sent
97
+ print(reached)
98
+
99
+
100
+ async def over_http(origin: str, token: str, resource: ResourceId, annotation: AnnotationId) -> None:
101
+ async with (
102
+ HttpTransport(origin, token=Variable[str | None](token)) as transport,
103
+ SemiontClient(transport, transport.content, transport) as client,
104
+ ):
105
+ await annotate(client, resource, annotation)
106
+ ```
107
+
108
+ Every method returns one of seven shapes, and the table says which:
109
+
110
+ | Shape | In Python | |
111
+ |---|---|---|
112
+ | asked once, answered once | `async def` | a failure is raised |
113
+ | a long-running operation | `Running[T]` | awaited for its final value, or read with `async for` for every report and then the final value; one or the other, once |
114
+ | an upload | `Upload` | awaited for the resource it created, read for its progress |
115
+ | a query | `Cached[T]` | building it sends nothing; `await query.fresh()` reads it once, and held with `async with` it is watched |
116
+ | a signal | `def`, returning nothing | published on the client's own bus, or sent and not awaited |
117
+ | a drive | `async def` giving `int \| None` | how many participants the gateway reached, or nothing when it kept no count |
118
+ | a channel's events | `Typed[P]` | `async for`, each event's payload decoded |
119
+
120
+ - **A followed job** (`mark.assist`, `yield_.from_context`) reports its
121
+ progress and ends with its completion. A job that says nothing is asked for
122
+ its status, so a completion the stream did not carry is still heard. It ends
123
+ as a `JobError` when the job failed for good, was cancelled, or (a
124
+ generation) said nothing for longer than its length allows.
125
+ - **`client.bus`** is the client's own bus: every frame its transport
126
+ delivered, and every signal its own parts gave each other.
127
+ **`client.wire`** is the bus over the transport, typed by channel.
128
+ - **A client is held with `async with`**: inside, it listens for what keeps
129
+ its queries true, and it ends what it started on the way out. It does not
130
+ close its transport: whoever opened that closes it, after the client. A
131
+ session (`session_from_kept`) holds one as `session.client`.
132
+ - **A client is generic in its transport.** Code that takes a
133
+ `SemiontClient[Transport]` runs over HTTP and over a test's doubles alike.
134
+ - **What it sends unasked is the table's**: a list's first hundred, a
135
+ search's ten candidates, a context's two thousand characters. An option
136
+ given as `None` is an option not given, and is not sent.
137
+
138
+ Why the shapes are these seven, in every SDK, is
139
+ [the reactive model][reactive-model].
140
+
141
+ ## Signing in
142
+
143
+ A transport sends the token it is given. Where the token comes from is one of
144
+ three things, and each keeps it renewed: half its lifetime before it expires,
145
+ at most five minutes before, and at once when the gateway refuses it.
146
+
147
+ **An agent** is a service account's token exchanged at the gateway for the
148
+ token of the agent `(provider, model)`. It is what a service holds: a
149
+ transport, its agent's token, and the channels it awaits replies on.
150
+
151
+ ```python
152
+ from semiont.bus import reply_channels_for
153
+ from semiont.http import AgentToken, Credential, HttpTransport, ServiceToken
154
+ from semiont.operations import JOB_CLAIM
155
+ from semiont.watched import reached
156
+
157
+
158
+ async def work(gateway: str, issuer: str, secret: str) -> None:
159
+ service = ServiceToken(Credential(issuer=issuer, client_id="semiont-smelter", client_secret=secret))
160
+ async with (
161
+ AgentToken(gateway, provider="ollama", model="gemma2:27b", service=service) as agent,
162
+ HttpTransport(gateway, token=agent.token, refresher=agent.refresh, channels=reply_channels_for(JOB_CLAIM)) as transport,
163
+ ):
164
+ print(await reached(transport.state, lambda state: state == "open")) # its stream is open, as the agent
165
+ ```
166
+
167
+ **A person** is signed in at the issuer the knowledge base trusts, and the
168
+ sign-in is kept. [`semiont login`][launcher] keeps one for each stack, and a
169
+ session here is built over it; a script on a machine with no launcher makes
170
+ its own by the device grant, which shows a code to approve in any browser. No
171
+ password passes through this process.
172
+
173
+ ```python
174
+ from pathlib import Path
175
+
176
+ from semiont.http import DeviceCode, session_from_kept, sign_in_device
177
+ from semiont.sign_in_store import FILE_NAME, SignInStore, state_dir, this_system
178
+
179
+
180
+ def show(code: DeviceCode) -> None:
181
+ print(f"Open {code.verification_uri} and enter {code.user_code}")
182
+
183
+
184
+ async def as_me(gateway: str, home: str) -> None:
185
+ directory = state_dir(this_system(), home=home, xdg_state_home=None, local_app_data=None)
186
+ if directory is None:
187
+ return
188
+ kept = SignInStore(Path(directory) / FILE_NAME).entry("local")
189
+ if await kept.held() is None:
190
+ await sign_in_device(gateway, kept, show)
191
+ async with session_from_kept(gateway, kb_id="local", kept=kept) as session:
192
+ print(session.user.value, (await session.client.system.status()).version)
193
+ ```
194
+
195
+ - **An agent whose renewal fails keeps the token it has** and tries again: a
196
+ process holding a token that still works does not stop over one bad round
197
+ trip. Signing in is the one step that raises.
198
+ - **A person's session ends once, and says why.** A token the issuer will not
199
+ renew ends it as `expired`, and a token the issuer has just issued and the
200
+ gateway refuses ends it as `refused` (`on_auth_failed`). The sign-in is
201
+ then forgotten, so a dead credential is never sent again. What a session
202
+ does at its start and when it is refused is
203
+ [`specs/src/session/cases.json`][session-cases], row for row.
204
+ - **A renewal spends its refresh token once.** Renewals asked for together are
205
+ one request of the issuer, a renewal the issuer did not refuse is tried
206
+ again inside a bounded budget, and one under way runs to its end whoever
207
+ stops waiting for it.
208
+ - **The store is the launcher's** ([`specs/src/sign-in-store`][sign-in-store]):
209
+ the same file and the same lock, on macOS, Linux and Windows. A sign-in made
210
+ here serves the launcher's verbs, and one it renewed serves this session.
211
+ `MemorySignIn` keeps one for the life of the process instead, and
212
+ `sign_out` revokes one at its issuer and forgets it.
213
+ - **Nothing here reads the environment.** An application reads its own
214
+ (`HOME`, a secret, an address) and says what it found.
215
+
216
+ ## Live queries
217
+
218
+ A query is one of ten reads
219
+ ([`specs/src/client/refresh.json`][refresh] names them) that answer from the
220
+ client's cache. Read once, it asks the knowledge base now. Held with
221
+ `async with`, it is watched: its state now, and each state after it, until
222
+ the client closes.
223
+
224
+ ```python
225
+ from typing import assert_never
226
+
227
+ from semiont.cache import Failed, Pending, Ready
228
+ from semiont.client import SemiontClient
229
+ from semiont.identifiers import ResourceId
230
+ from semiont.transport import Transport
231
+
232
+
233
+ async def watch(client: SemiontClient[Transport], resource: ResourceId) -> None:
234
+ async with client.browse.annotations(resource) as live: # holds the resource's scope
235
+ async for state in live:
236
+ match state:
237
+ case Pending():
238
+ print("asking")
239
+ case Ready(value=annotations):
240
+ print(len(annotations), "annotations")
241
+ case Failed(error=error): # a state, not a raise: the query lives on
242
+ print("failed:", error.code)
243
+ case _:
244
+ assert_never(state)
245
+ ```
246
+
247
+ - **A state is one of three**, and a `match` that leaves one out does not
248
+ type-check. `Failed` is the state of a query with no value whose request
249
+ failed and failed again. It is not the end: the next watcher, an event or
250
+ `query.invalidate()` asks again, and every watcher is shown what comes.
251
+ - **Watching is what asks.** The first watcher of a query starts its request,
252
+ and watchers that arrive together share it. A later one is given what is
253
+ held. A request that fails is made once more; a query that has a value
254
+ keeps showing it whatever its refetches do.
255
+ - **A watched query stays true.** While a query of one resource is held, the
256
+ client holds that resource's scope, so its events reach the client; and each
257
+ event asks again for exactly the queries the refresh table gives it, writes
258
+ the value an event carries, or ends an annotation that is gone. What events
259
+ ask of one query inside a second is one request. A stream that dropped and
260
+ returned asks again for what its events could not replay.
261
+ - **A read** (`await query.fresh()`) asks now, raises the failure it meets,
262
+ and gives every watcher of the query its answer.
263
+ - **A watcher that falls behind** is given the latest state, not each one it
264
+ missed: a state is what is true now.
265
+ - **A query is watched inside its client's `async with`**, which is what
266
+ listens for the events. After the client closes, a watcher is given
267
+ nothing and a read is refused as `bus.closed`.
268
+ - **A client can keep what its small queries hold**
269
+ (`SemiontClient(..., persistence=CachePersistence(storage=..., key_prefix=...))`),
270
+ in a `semiont.storage.SessionStorage` the application supplies: a
271
+ resource's description, its annotations, one annotation, the entity types,
272
+ the tag schemas. The next client over the same storage shows them at once
273
+ and asks for each anew the first time it is watched.
274
+ `session_from_kept(..., storage=...)` does this for a session, and keeps the
275
+ stream's place in each resource's scope with it (`semiont.resume`), so the
276
+ next session is sent what was recorded since.
277
+
278
+ The behaviours every SDK's live queries are held to are numbered in
279
+ [the cache's semantics][cache-semantics].
280
+
281
+ ## Testing what is built on it
282
+
283
+ `semiont.testing` gives a real client over doubles. Its namespaces, its cache
284
+ and its deadlines are the client's own; only what it speaks through is
285
+ scripted. A test says what the knowledge base answers, runs the code under
286
+ test, and reads what that code asked.
287
+
288
+ ```python
289
+ from semiont.client import SemiontClient
290
+ from semiont.identifiers import ResourceId
291
+ from semiont.testing import create_test_client
292
+ from semiont.transport import Transport
293
+
294
+
295
+ async def title_of(client: SemiontClient[Transport], resource: ResourceId) -> str:
296
+ return (await client.browse.resource(resource).fresh()).name.title()
297
+
298
+
299
+ async def a_title_is_its_resources_name_in_title_case() -> None:
300
+ made = create_test_client()
301
+ made.transport.queue_reply(
302
+ "browse:resource-requested",
303
+ [
304
+ {
305
+ "resource": {"@context": "https://schema.org", "@id": "res-1", "name": "a page of notes", "representations": []},
306
+ "annotations": [],
307
+ "entityReferences": [],
308
+ }
309
+ ],
310
+ )
311
+
312
+ async with made.client as client:
313
+ assert await title_of(client, ResourceId("res-1")) == "A Page Of Notes"
314
+
315
+ # What the code under test asked of the knowledge base, as it was sent.
316
+ assert [(asked.channel, asked.payload) for asked in made.transport.request_log] == [
317
+ ("browse:resource-requested", {"resourceId": "res-1"})
318
+ ]
319
+ await made.transport.close()
320
+ ```
321
+
322
+ - **`FaultyTransport`** is a transport with no wire. `queue_reply` says what
323
+ the gateway answers the next requests of an operation with, and
324
+ `refuse_when` has it answer with a failure. A schedule of `Deliver`,
325
+ `DropReply`, `Delay`, `DuplicateReply` and `RejectEmit` says what the wire
326
+ does to each request in turn, so a lost reply and the retry after it can be
327
+ scripted. `request_log` and `emitted` are what was sent; `deliver` carries a
328
+ frame in as the bus would; `set_state` drops the stream and brings it back.
329
+ - **`InMemoryContent`** keeps what is uploaded and what a test seeds, and
330
+ **`StubGateway`** answers the gateway's own operations as a test scripted
331
+ them. Each records the calls made of it.
332
+ - **A double refuses what nobody scripted, by name.** A request with no
333
+ answer scripted fails naming its operation, and a read of content nobody
334
+ stored fails naming the resource. None answers with a value of its own
335
+ making, so a test that forgot to say something fails where it forgot.
336
+ - **`create_test_client`** takes the doubles a test built, or makes ones
337
+ nothing is scripted on, and the client's own timing and persistence.
338
+
339
+ ## The bus
340
+
341
+ Under the namespaces is the bus, and a program can speak on it directly. A
342
+ transport is held with `async with`. Its stream opens inside, once there is
343
+ a token, and everything it started has ended on the way out.
344
+
345
+ ```python
346
+ from semiont.bus import Bus
347
+ from semiont.channels import MARK_ADDED
348
+ from semiont.errors import BusRequestError
349
+ from semiont.http import HttpTransport
350
+ from semiont.identifiers import ResourceId
351
+ from semiont.operations import BROWSE_RESOURCE_REQUESTED
352
+ from semiont.types import BrowseResourceRequest
353
+ from semiont.watched import Variable
354
+
355
+
356
+ async def read(origin: str, token: str, resource: ResourceId) -> None:
357
+ async with HttpTransport(origin, token=Variable[str | None](token)) as transport:
358
+ bus = Bus(transport)
359
+ try:
360
+ reply = await bus.request(BROWSE_RESOURCE_REQUESTED, BrowseResourceRequest(resource_id=resource))
361
+ except BusRequestError as error:
362
+ print(error.code) # "bus.not-found", "bus.timeout", …: a closed vocabulary
363
+ return
364
+ print(reply.response.resource.name)
365
+
366
+ # Frames arrive on a resource's channels while its scope is held.
367
+ with transport.subscribe_to_resource(resource):
368
+ async with bus.frames(MARK_ADDED) as added:
369
+ async for frame in added:
370
+ print(frame.scope, frame.payload.type)
371
+ ```
372
+
373
+ - **The bus is typed by channel.** A channel is a constant that carries its
374
+ payload's type: a request is refused another operation's payload, its reply
375
+ is that operation's result, and a frame's payload is its channel's. By
376
+ name, with JSON objects, it is `semiont.bus.request` and the transport's
377
+ own `emit` and `frames`.
378
+ - **A request** waits for the stream to be open before it is sent, is answered
379
+ once, and is abandoned by cancelling the task that awaits it.
380
+ - **Frames** are a sequence: each reader is given every one, in order, however
381
+ far behind it falls.
382
+ - **The connection's state** (`transport.state`) is a value: what it is now,
383
+ and each thing it becomes. A dropped stream is a state, never an error, and
384
+ opens again by itself.
385
+ - **Failures** are `SemiontError`s under the codes of
386
+ [`specs/src/errors/codes.json`][codes]. What a server refused is also on
387
+ `transport.failures()`.
388
+ - **The token** is a value the transport watches: when it changes, the next
389
+ request carries the new one. A stream the gateway refused as unauthorized
390
+ asks its `refresher` for another token, once per outage, and with none
391
+ waits, with no request, for a different one.
392
+
393
+ The protocol it keeps to is [the transport contract][transport-contract] and
394
+ [the HTTP transport][transport-http], and the [conformance suite][conformance]
395
+ holds it there, case by case, beside the TypeScript and Rust SDKs.
396
+
397
+ ## Content, and the gateway's own operations
398
+
399
+ Bytes never ride the bus. A transport's `content` uploads them and reads them
400
+ back, and the transport itself asks the gateway what a gateway answers for
401
+ itself.
402
+
403
+ ```python
404
+ from semiont.http import HttpTransport
405
+ from semiont.identifiers import ResourceId
406
+ from semiont.transport import PutBinaryRequest
407
+
408
+
409
+ async def store(transport: HttpTransport, page: bytes) -> ResourceId:
410
+ upload = transport.content.put_binary(
411
+ PutBinaryRequest(name="A page", file=page, format="text/markdown", storage_uri="file://pages/a.md", entity_types=["Note"])
412
+ )
413
+ async for progress in upload:
414
+ print(progress.bytes_uploaded, "of", progress.total_bytes)
415
+ created = await upload
416
+
417
+ stored = await transport.content.get_binary(created.resource_id)
418
+ assert (stored.data, stored.content_type) == (page, "text/markdown")
419
+ async with await transport.content.get_binary_stream(created.resource_id) as arriving:
420
+ async for piece in arriving:
421
+ print(len(piece))
422
+
423
+ me = await transport.get_current_user()
424
+ print(me.did, (await transport.health_check()).status)
425
+ return created.resource_id
426
+ ```
427
+
428
+ - **An upload** is awaited for the resource it created and iterated for its
429
+ progress, each once. Its caller abandons it by cancelling the task that does
430
+ either: its connection closes, and nothing is reported.
431
+ - **What is read is what was stored**, byte for byte, with its media type. A
432
+ time or a URI in an answer is the text the gateway sent, and a field it sent
433
+ as null is `None`.
434
+ - **A read is asked a second time** when the gateway says it will recover, or
435
+ says nothing. A request that may already have had its effect is not.
436
+
437
+ ## Ids, and the protocol's shapes
438
+
439
+ The package holds what the protocol states, as Python types: the kinds of id,
440
+ the shapes the protocol sends and answers with, the channels of its bus and
441
+ the requests made over them. Each kind of id is a type of its own, made only
442
+ from text that is one, and a type checker refuses one kind where a shape
443
+ names another.
444
+
445
+ ```python
446
+ from semiont.identifiers import AnnotationId, InvalidIdentifier, ResourceId
447
+ from semiont.types import MarkDeleteCommand
448
+
449
+ resource = ResourceId("5bcd259ab1464cf68a556bbad21f513f")
450
+ assert ResourceId.parse("not an id") is None # where text that is not an id is an ordinary answer
451
+ try:
452
+ ResourceId("https://kb.example/resources/x")
453
+ except InvalidIdentifier as refused:
454
+ print(refused) # 'https://kb.example/resources/x' is not a ResourceId: it does not match ^[A-Za-z0-9_-]{1,128}$
455
+
456
+ command = MarkDeleteCommand(annotation_id=AnnotationId("a-1"), resource_id=resource)
457
+ print(command.model_dump(mode="json", exclude_unset=True))
458
+ # {'annotationId': 'a-1', 'resourceId': '5bcd259ab1464cf68a556bbad21f513f'}
459
+
460
+ # MarkDeleteCommand(annotation_id=resource) is refused by a type checker: a ResourceId is not an AnnotationId.
461
+ ```
462
+
463
+ ## Telemetry
464
+
465
+ The SDK takes OpenTelemetry's API and installs nothing: it does nothing until
466
+ the application it runs in installs a provider. With one, it exports what
467
+ [`specs/src/sdk-telemetry/telemetry.json`][telemetry] lists, as every Semiont
468
+ SDK does: a span for each frame sent and each received, a span for each upload
469
+ and each read, and a count of the emits sent. A request carries the trace it
470
+ is made in, and a frame continues the trace it was sent under (`frame.trace`).
471
+
472
+ ## What is in the package
473
+
474
+ | Module | What it holds |
475
+ |---|---|
476
+ | `semiont.client` | `SemiontClient`, and the timing it keeps to |
477
+ | `semiont.namespaces` | The methods of each namespace, and the options they take |
478
+ | `semiont.running`, `semiont.cached` | `Running[T]` and `Cached[T]`: what long-running operations and queries return |
479
+ | `semiont.cache`, `semiont.refresh`, `semiont.resume` | The cache queries answer from and its three states, which queries each event asks again, and where a stream resumes after a restart |
480
+ | `semiont.storage` | Where a client keeps what must outlive it: `SessionStorage`, and `MemoryStorage` |
481
+ | `semiont.session` | `SemiontSession`, and `MemorySignIn` |
482
+ | `semiont.http` | `HttpTransport`, and signing in: `ServiceToken`, `AgentToken`, `sign_in_device`, `session_from_kept`, `sign_out` |
483
+ | `semiont.sign_in_store` | The sign-ins `semiont login` keeps, and where each system keeps them |
484
+ | `semiont.identifiers`, `semiont.types` | The kinds of id, and every request, response and event |
485
+ | `semiont.channels`, `semiont.operations` | The bus's channels and its operations, each a constant that carries its payload's type |
486
+ | `semiont.bus`, `semiont.event_bus` | The typed client of the bus over a transport, and a client's own in-process bus |
487
+ | `semiont.transport` | The contract a transport implements: `Transport`, `ContentTransport`, `GatewayOperations` |
488
+ | `semiont.errors`, `semiont.timing`, `semiont.retry` | The failure codes, the deadlines and the retry rules every Semiont SDK shares |
489
+ | `semiont.watched`, `semiont.events` | A value that changes and a sequence of events: what a state and a channel's frames are read as |
490
+ | `semiont.media_types` | The media types a knowledge base admits, and the rules read from them |
491
+ | `semiont.identity` | How a knowledge base is named, and the principals who act in it |
492
+ | `semiont.telemetry` | What the transports tell OpenTelemetry |
493
+ | `semiont.testing` | The doubles: `FaultyTransport`, `InMemoryContent`, `StubGateway`, `create_test_client` |
494
+
495
+ Nothing here restates the protocol by hand. `identifiers`, `types`,
496
+ `channels`, `operations`, `error_codes`, `timing`, `refresh`,
497
+ `telemetry_table`, `oauth_clients`, `sign_in` and `media_types_table` are
498
+ generated from the [specification][specs], and a gate in CI fails when one of
499
+ them differs from what the specification generates.
500
+
501
+ ## Working on it
502
+
503
+ The package is [`packages/sdk-python`][package] of the
504
+ [Semiont repository][semiont], and [Testing][testing] says how its checks and
505
+ its tests are run. The programs this page shows are files of its tests, word
506
+ for word: both type checkers check them, and each one is run.
507
+
508
+ ## License
509
+
510
+ Apache-2.0. See [LICENSE][license].
511
+
512
+ [semiont]: https://github.com/The-AI-Alliance/semiont
513
+ [introduction]: https://github.com/The-AI-Alliance/semiont/blob/main/docs/builder/INTRODUCTION.md
514
+ [quick-start]: https://github.com/The-AI-Alliance/semiont/blob/main/docs/builder/QUICK-START.md
515
+ [reactive-model]: https://github.com/The-AI-Alliance/semiont/blob/main/docs/builder/REACTIVE-MODEL.md
516
+ [protocol]: https://github.com/The-AI-Alliance/semiont/blob/main/docs/protocol/README.md
517
+ [transport-contract]: https://github.com/The-AI-Alliance/semiont/blob/main/docs/protocol/TRANSPORT-CONTRACT.md
518
+ [transport-http]: https://github.com/The-AI-Alliance/semiont/blob/main/docs/protocol/TRANSPORT-HTTP.md
519
+ [cache-semantics]: https://github.com/The-AI-Alliance/semiont/blob/main/docs/protocol/CACHE-SEMANTICS.md
520
+ [conformance]: https://github.com/The-AI-Alliance/semiont/blob/main/tests/conformance/sdk/README.md
521
+ [sdk-typescript]: https://github.com/The-AI-Alliance/semiont/blob/main/packages/sdk/README.md
522
+ [sdk-rust]: https://github.com/The-AI-Alliance/semiont/blob/main/packages/sdk-rust/README.md
523
+ [launcher]: https://github.com/The-AI-Alliance/semiont/blob/main/apps/launcher/README.md
524
+ [specs]: https://github.com/The-AI-Alliance/semiont/blob/main/specs/README.md
525
+ [surface]: https://github.com/The-AI-Alliance/semiont/blob/main/specs/src/client/surface.json
526
+ [refresh]: https://github.com/The-AI-Alliance/semiont/blob/main/specs/src/client/refresh.json
527
+ [codes]: https://github.com/The-AI-Alliance/semiont/blob/main/specs/src/errors/codes.json
528
+ [session-cases]: https://github.com/The-AI-Alliance/semiont/blob/main/specs/src/session/cases.json
529
+ [sign-in-store]: https://github.com/The-AI-Alliance/semiont/blob/main/specs/src/sign-in-store/README.md
530
+ [telemetry]: https://github.com/The-AI-Alliance/semiont/blob/main/specs/src/sdk-telemetry/telemetry.json
531
+ [package]: https://github.com/The-AI-Alliance/semiont/tree/main/packages/sdk-python
532
+ [testing]: https://github.com/The-AI-Alliance/semiont/blob/main/docs/contributor/TESTING.md
533
+ [license]: https://github.com/The-AI-Alliance/semiont/blob/main/LICENSE