dna-client 0.21.1__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.
@@ -0,0 +1,23 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ node_modules/
7
+ *.tsbuildinfo
8
+ dist/
9
+ coverage/
10
+ .DS_Store
11
+ .env
12
+ .env.*
13
+ !.env.example
14
+ # resolver cache — created when a demo/example resolves github: deps
15
+ .dna-cache/
16
+ # MkDocs build output — the docs site is built in CI / on Pages, never committed
17
+ site/
18
+ # per-workstation pointer written by `dna sdlc story start` (read by the
19
+ # prepare-commit-msg hook) — never source-of-truth, never committed
20
+ .dna/active-story.txt
21
+
22
+ # Local hybrid-search index store (dna recall/search) — s-record-search-sqlite
23
+ .dna-search/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DNA contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: dna-client
3
+ Version: 0.21.1
4
+ Summary: DNA — Official Python client for the DNA REST read-API (spec-parity twin of the TypeScript dna-client)
5
+ Project-URL: Homepage, https://ruinosus.github.io/dna/
6
+ Project-URL: Repository, https://github.com/ruinosus/dna
7
+ Project-URL: Documentation, https://ruinosus.github.io/dna/guides/using-the-client/
8
+ Project-URL: Changelog, https://github.com/ruinosus/dna/blob/main/CHANGELOG.md
9
+ Project-URL: Issues, https://github.com/ruinosus/dna/issues
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: agents,client,dna,openapi,rest,sdlc,typed
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: <3.14,>=3.12
19
+ Requires-Dist: httpx>=0.27
20
+ Provides-Extra: dev
21
+ Requires-Dist: dna-cli[api]; extra == 'dev'
22
+ Requires-Dist: pytest>=8.0; extra == 'dev'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # dna-client (Python)
26
+
27
+ The **official Python client for the DNA REST API** (`dna api serve`).
28
+ Spec-parity twin of the TypeScript [`dna-client`](https://www.npmjs.com/package/dna-client):
29
+ both cover the same surface, derived from the same OpenAPI document
30
+ (`docs/openapi.json`, dumped from the FastAPI app) — so consumers stop
31
+ hand-rolling HTTP.
32
+
33
+ - **Typed inputs**, `httpx`-based, sync, context-manageable.
34
+ - **Complete** — a named method for EVERY operation in the spec, reads and
35
+ writes alike. `client.request(...)` remains as a low-level escape hatch, but
36
+ no route depends on it.
37
+ - **Guarded** — a drift test re-dumps the live schema and fails CI if the
38
+ committed spec is stale, *and* fails if any operation (of any HTTP method)
39
+ has no named method. Adding a write route cannot pass silently.
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ pip install dna-client # or: uv add dna-client
45
+ ```
46
+
47
+ Only runtime dep is `httpx`. Python >= 3.12.
48
+
49
+ ## Usage
50
+
51
+ ```python
52
+ from dna_client import DnaClient
53
+
54
+ with DnaClient(
55
+ "http://127.0.0.1:8080",
56
+ token="…", # optional — for --auth token/config
57
+ scope="dna-development", # optional default applied to every call
58
+ ) as dna:
59
+ agents = dna.list_agents()
60
+ prompt = dna.agent_prompt("jarvis")
61
+ hits = dna.search_memories("tenancy invariant", k=3)
62
+ board = dna.get_board("dna-development", recent=6)
63
+
64
+ # Writes are named methods too — no escape hatch needed.
65
+ dna.remember_memory("a lesson worth keeping", area="ops")
66
+ dna.delete_memory("s-foo")
67
+ dna.set_insight_state("i-42", "actioned")
68
+
69
+ # Workspace routes are identity-scoped: the boundary comes from the caller's
70
+ # verified claims, so they never take the client's default scope/tenant.
71
+ ws = dna.create_workspace("Acme") # id is minted server-side
72
+ dna.create_invite(ws["workspace_id"], "teammate@acme.com", role="member")
73
+ ```
74
+
75
+ Routes with security semantics say so in their docstring — e.g.
76
+ `create_project()` is **403** without an active workspace membership, and
77
+ `revoke_workspace_member()` is **409** on the last remaining owner.
78
+
79
+ A non-2xx response raises `DnaApiError` (carrying `.status` and the API's
80
+ `{"detail": ...}` payload).
81
+
82
+ ## Note on return types
83
+
84
+ Every DNA REST handler returns an untyped JSON object (`dict[str, Any]`), so the
85
+ OpenAPI **response** schemas are opaque. Request inputs (query/path params) are
86
+ typed; response bodies are `dict[str, Any]`. Tighten the API's response models to
87
+ tighten these.
88
+
89
+ ## Parity
90
+
91
+ This client and the TypeScript twin are generated from the SAME
92
+ `docs/openapi.json` — spec-parity, not byte-parity. See
93
+ [Using the DNA client](https://ruinosus.github.io/dna/guides/using-the-client/).
@@ -0,0 +1,69 @@
1
+ # dna-client (Python)
2
+
3
+ The **official Python client for the DNA REST API** (`dna api serve`).
4
+ Spec-parity twin of the TypeScript [`dna-client`](https://www.npmjs.com/package/dna-client):
5
+ both cover the same surface, derived from the same OpenAPI document
6
+ (`docs/openapi.json`, dumped from the FastAPI app) — so consumers stop
7
+ hand-rolling HTTP.
8
+
9
+ - **Typed inputs**, `httpx`-based, sync, context-manageable.
10
+ - **Complete** — a named method for EVERY operation in the spec, reads and
11
+ writes alike. `client.request(...)` remains as a low-level escape hatch, but
12
+ no route depends on it.
13
+ - **Guarded** — a drift test re-dumps the live schema and fails CI if the
14
+ committed spec is stale, *and* fails if any operation (of any HTTP method)
15
+ has no named method. Adding a write route cannot pass silently.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ pip install dna-client # or: uv add dna-client
21
+ ```
22
+
23
+ Only runtime dep is `httpx`. Python >= 3.12.
24
+
25
+ ## Usage
26
+
27
+ ```python
28
+ from dna_client import DnaClient
29
+
30
+ with DnaClient(
31
+ "http://127.0.0.1:8080",
32
+ token="…", # optional — for --auth token/config
33
+ scope="dna-development", # optional default applied to every call
34
+ ) as dna:
35
+ agents = dna.list_agents()
36
+ prompt = dna.agent_prompt("jarvis")
37
+ hits = dna.search_memories("tenancy invariant", k=3)
38
+ board = dna.get_board("dna-development", recent=6)
39
+
40
+ # Writes are named methods too — no escape hatch needed.
41
+ dna.remember_memory("a lesson worth keeping", area="ops")
42
+ dna.delete_memory("s-foo")
43
+ dna.set_insight_state("i-42", "actioned")
44
+
45
+ # Workspace routes are identity-scoped: the boundary comes from the caller's
46
+ # verified claims, so they never take the client's default scope/tenant.
47
+ ws = dna.create_workspace("Acme") # id is minted server-side
48
+ dna.create_invite(ws["workspace_id"], "teammate@acme.com", role="member")
49
+ ```
50
+
51
+ Routes with security semantics say so in their docstring — e.g.
52
+ `create_project()` is **403** without an active workspace membership, and
53
+ `revoke_workspace_member()` is **409** on the last remaining owner.
54
+
55
+ A non-2xx response raises `DnaApiError` (carrying `.status` and the API's
56
+ `{"detail": ...}` payload).
57
+
58
+ ## Note on return types
59
+
60
+ Every DNA REST handler returns an untyped JSON object (`dict[str, Any]`), so the
61
+ OpenAPI **response** schemas are opaque. Request inputs (query/path params) are
62
+ typed; response bodies are `dict[str, Any]`. Tighten the API's response models to
63
+ tighten these.
64
+
65
+ ## Parity
66
+
67
+ This client and the TypeScript twin are generated from the SAME
68
+ `docs/openapi.json` — spec-parity, not byte-parity. See
69
+ [Using the DNA client](https://ruinosus.github.io/dna/guides/using-the-client/).
@@ -0,0 +1,26 @@
1
+ """``dna_client`` — the official Python client for the **DNA REST read-API**
2
+ (``dna api serve``).
3
+
4
+ The spec-parity twin of the TypeScript ``dna-client``: both cover the SAME read
5
+ surface, derived from the SAME OpenAPI document (``docs/openapi.json``, dumped
6
+ from the FastAPI app by ``scripts/dump_openapi.py``). The TS client generates its
7
+ types from that spec with ``openapi-typescript``; this Python client is a
8
+ hand-thin ``httpx`` wrapper whose method surface + query params are derived from
9
+ the same spec (a drift test keeps the spec honest against the live routes). The
10
+ two clients stay semantically in sync — spec-parity, not byte-parity.
11
+
12
+ Read-first: the named methods cover the ``/v1/*`` GET read surface (the shape
13
+ dna-cloud's hand-rolled ``lib/rest-client.ts`` consumes today). The full surface
14
+ — including the few writes — is reachable via :meth:`DnaClient.request`.
15
+
16
+ NOTE ON RETURN TYPES: every DNA REST handler returns an untyped JSON object
17
+ (``dict[str, Any]``), so the OpenAPI *response* schemas are opaque. Request
18
+ inputs (query/path params) ARE typed here; response bodies are ``dict[str, Any]``
19
+ (documented per method). Tighten the API's response models to tighten these.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ from .client import DnaApiError, DnaClient
24
+
25
+ __all__ = ["DnaClient", "DnaApiError"]
26
+ __version__ = "0.17.0"
@@ -0,0 +1,509 @@
1
+ """The DNA REST API client (sync, ``httpx``-based).
2
+
3
+ Named methods for the FULL ``/v1/*`` surface — every operation in
4
+ ``docs/openapi.json``, reads AND writes. :meth:`DnaClient.request` remains the
5
+ low-level escape hatch, but it is no longer the only way to reach a write.
6
+ Every method returns the API's JSON object (``dict[str, Any]``) and raises
7
+ :class:`DnaApiError` on a non-2xx status.
8
+
9
+ Coverage is enforced: ``tests/test_openapi_drift.py`` fails if the spec grows an
10
+ operation — of ANY HTTP method — with no named method here.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+
16
+ import httpx
17
+
18
+ __all__ = ["DnaClient", "DnaApiError"]
19
+
20
+ JsonObject = dict[str, Any]
21
+
22
+
23
+ class DnaApiError(Exception):
24
+ """Raised when the DNA REST API responds with a non-2xx status. Carries the
25
+ HTTP ``status`` and the API's ``{"detail": ...}`` payload (or the raw body)."""
26
+
27
+ def __init__(self, status: int, detail: Any) -> None:
28
+ self.status = status
29
+ self.detail = detail
30
+ message = (
31
+ detail["detail"]
32
+ if isinstance(detail, dict) and "detail" in detail
33
+ else f"DNA REST API error (HTTP {status})"
34
+ )
35
+ super().__init__(str(message))
36
+
37
+
38
+ #: Routes that accept NO ``scope``/``tenant`` query param, so the client-level
39
+ #: defaults must NOT be merged into them, keyed by ``(METHOD, path)``. FastAPI
40
+ #: would silently ignore the stray params, but sending them misrepresents the
41
+ #: route: the workspace boundary is resolved from the caller's VERIFIED identity
42
+ #: (or the body), never from a tenant hint on the query string.
43
+ _NO_SCOPE_TENANT: frozenset[tuple[str, str]] = frozenset(
44
+ {
45
+ ("GET", "/health"),
46
+ # The workspace boundary routes — identity-scoped, not tenant-scoped.
47
+ ("GET", "/v1/workspaces"),
48
+ ("POST", "/v1/workspaces"),
49
+ ("POST", "/v1/workspaces/accept"),
50
+ ("PUT", "/v1/workspace-plan"),
51
+ # POST /v1/projects names its workspace in the BODY (decision A1); the
52
+ # GET on the same path IS scope/tenant-aware, hence the method-keyed set.
53
+ ("POST", "/v1/projects"),
54
+ }
55
+ )
56
+
57
+
58
+ class DnaClient:
59
+ """A typed client for the DNA REST API — the full read AND write surface.
60
+
61
+ ``base_url`` is a running API, e.g. ``http://127.0.0.1:8080``. ``token`` (for
62
+ ``--auth token``/``--auth config``) is sent as ``Authorization: Bearer``.
63
+ ``tenant``/``scope`` are optional defaults merged into every call's query
64
+ (a per-call value wins). Usable as a context manager (closes the transport).
65
+
66
+ >>> with DnaClient("http://127.0.0.1:8080", scope="dna-development") as dna:
67
+ ... agents = dna.list_agents()
68
+ ... hits = dna.search_memories("tenancy invariant", k=3)
69
+ """
70
+
71
+ def __init__(
72
+ self,
73
+ base_url: str,
74
+ *,
75
+ token: str | None = None,
76
+ tenant: str | None = None,
77
+ scope: str | None = None,
78
+ timeout: float = 30.0,
79
+ transport: httpx.BaseTransport | None = None,
80
+ ) -> None:
81
+ headers = {"Authorization": f"Bearer {token}"} if token else {}
82
+ self._default_tenant = tenant
83
+ self._default_scope = scope
84
+ self._http = httpx.Client(
85
+ base_url=base_url.rstrip("/"),
86
+ headers=headers,
87
+ timeout=timeout,
88
+ transport=transport,
89
+ )
90
+
91
+ # -- lifecycle -----------------------------------------------------------
92
+
93
+ def close(self) -> None:
94
+ self._http.close()
95
+
96
+ def __enter__(self) -> DnaClient:
97
+ return self
98
+
99
+ def __exit__(self, *exc: object) -> None:
100
+ self.close()
101
+
102
+ # -- low-level (the full surface, incl. writes) --------------------------
103
+
104
+ def request(
105
+ self, method: str, path: str, *, params: dict[str, Any] | None = None,
106
+ json: Any = None,
107
+ ) -> JsonObject:
108
+ """Issue a raw request (the full API surface — reads AND writes). Drops
109
+ ``None`` query params, merges the client-level default ``scope``/``tenant``
110
+ when the path accepts them, and raises :class:`DnaApiError` on non-2xx."""
111
+ query = self._merge_query(method, path, params)
112
+ resp = self._http.request(method, path, params=query, json=json)
113
+ if resp.is_success:
114
+ return resp.json()
115
+ try:
116
+ detail: Any = resp.json()
117
+ except Exception: # noqa: BLE001 — a non-JSON error body is still an error.
118
+ detail = resp.text
119
+ raise DnaApiError(resp.status_code, detail)
120
+
121
+ def _get(self, path: str, **params: Any) -> JsonObject:
122
+ return self.request("GET", path, params=params)
123
+
124
+ def _write(
125
+ self, method: str, path: str, body: JsonObject, **params: Any
126
+ ) -> JsonObject:
127
+ """Issue a write, dropping ``None`` body keys so the server's own default
128
+ (not the client's idea of one) applies to an omitted optional field."""
129
+ payload = {k: v for k, v in body.items() if v is not None}
130
+ return self.request(method, path, params=params, json=payload)
131
+
132
+ def _merge_query(
133
+ self, method: str, path: str, params: dict[str, Any] | None
134
+ ) -> dict[str, Any]:
135
+ params = dict(params or {})
136
+ # A `/v1/workspaces/{id}/...` sub-route is identity-scoped like its parent.
137
+ takes_scope_tenant = (
138
+ (method.upper(), path) not in _NO_SCOPE_TENANT
139
+ and not path.startswith("/v1/workspaces/")
140
+ )
141
+ if takes_scope_tenant:
142
+ if self._default_scope is not None and params.get("scope") is None:
143
+ params["scope"] = self._default_scope
144
+ # `/v1/tenants/{tid}/...` takes `scope` but NOT `tenant` — the tenant
145
+ # IS the `tid` path segment, so a default tenant must not shadow it.
146
+ if (
147
+ self._default_tenant is not None
148
+ and params.get("tenant") is None
149
+ and not path.startswith("/v1/tenants/")
150
+ ):
151
+ params["tenant"] = self._default_tenant
152
+ return {k: v for k, v in params.items() if v is not None}
153
+
154
+ # -- health --------------------------------------------------------------
155
+
156
+ def health(self) -> JsonObject:
157
+ """Liveness probe (unauthenticated). Returns ``{"ok": True}``."""
158
+ return self._get("/health")
159
+
160
+ # -- definitions ---------------------------------------------------------
161
+
162
+ def list_agents(
163
+ self, *, scope: str | None = None, tenant: str | None = None
164
+ ) -> JsonObject:
165
+ """List a scope's prompt-target agents, tenant-aware."""
166
+ return self._get("/v1/agents", scope=scope, tenant=tenant)
167
+
168
+ def agent_prompt(
169
+ self, name: str, *, scope: str | None = None, tenant: str | None = None
170
+ ) -> JsonObject:
171
+ """Compose one agent's system prompt LIVE (Soul + Guardrails + instruction)."""
172
+ return self._get(f"/v1/agents/{name}/prompt", scope=scope, tenant=tenant)
173
+
174
+ def list_tools(
175
+ self, *, scope: str | None = None, tenant: str | None = None
176
+ ) -> JsonObject:
177
+ """List a scope's Tool Kind surfaces (name + description), tenant-aware."""
178
+ return self._get("/v1/tools", scope=scope, tenant=tenant)
179
+
180
+ # -- memory (reads) ------------------------------------------------------
181
+
182
+ def list_memories(
183
+ self, *, scope: str | None = None, tenant: str | None = None
184
+ ) -> JsonObject:
185
+ """List the tenant's memory — base + the tenant's OWN overlay."""
186
+ return self._get("/v1/memories", scope=scope, tenant=tenant)
187
+
188
+ def search_memories(
189
+ self, q: str, *, k: int = 5, scope: str | None = None,
190
+ tenant: str | None = None,
191
+ ) -> JsonObject:
192
+ """Recall the tenant's memory for ``q`` (hybrid/bi-temporal or lexical)."""
193
+ return self._get("/v1/memories/search", q=q, k=k, scope=scope, tenant=tenant)
194
+
195
+ # -- memory (writes) -----------------------------------------------------
196
+
197
+ def remember_memory(
198
+ self, summary: str, *, area: str = "general", tags: list[str] | None = None,
199
+ affect: str = "triumph", owner: str = "portal", scope: str | None = None,
200
+ tenant: str | None = None,
201
+ ) -> JsonObject:
202
+ """Persist ONE memory (an ``Engram``) into the tenant's OWN overlay.
203
+
204
+ Writes only to the caller's overlay — never the base scope, never another
205
+ tenant. 400 on a blank ``summary``. The deterministic name it returns is
206
+ the id :meth:`delete_memory` targets to undo the write."""
207
+ return self._write(
208
+ "POST", "/v1/memories",
209
+ {"summary": summary, "area": area, "tags": tags,
210
+ "affect": affect, "owner": owner},
211
+ scope=scope, tenant=tenant,
212
+ )
213
+
214
+ def delete_memory(
215
+ self, name: str, *, scope: str | None = None, tenant: str | None = None
216
+ ) -> JsonObject:
217
+ """Delete ONE memory from the tenant's OWN overlay.
218
+
219
+ Refuses anything outside that overlay: a base-scope memory, or another
220
+ tenant's, is a 404 — the delete cannot reach across the isolation."""
221
+ return self.request(
222
+ "DELETE", f"/v1/memories/{name}", params={"scope": scope, "tenant": tenant},
223
+ )
224
+
225
+ # -- intel (reads) -------------------------------------------------------
226
+
227
+ def list_sources(
228
+ self, *, scope: str | None = None, tenant: str | None = None
229
+ ) -> JsonObject:
230
+ """List the tenant's watched IntelSource docs (the Direction stage)."""
231
+ return self._get("/v1/sources", scope=scope, tenant=tenant)
232
+
233
+ def list_insights(
234
+ self, *, scope: str | None = None, tenant: str | None = None,
235
+ state: str | None = None, source: str | None = None,
236
+ source_ref: str | None = None,
237
+ ) -> JsonObject:
238
+ """List the tenant's IntelInsight docs (ranked), filterable by state/source."""
239
+ return self._get(
240
+ "/v1/insights", scope=scope, tenant=tenant, state=state,
241
+ source=source, source_ref=source_ref,
242
+ )
243
+
244
+ def insight_metrics(
245
+ self, *, scope: str | None = None, tenant: str | None = None,
246
+ source_ref: str | None = None,
247
+ ) -> JsonObject:
248
+ """The feedback KPIs (precision + noise rate) over the insight stream."""
249
+ return self._get(
250
+ "/v1/insights/metrics", scope=scope, tenant=tenant, source_ref=source_ref
251
+ )
252
+
253
+ # -- intel (write) -------------------------------------------------------
254
+
255
+ def set_insight_state(
256
+ self, name: str, state: str, *, scope: str | None = None,
257
+ tenant: str | None = None,
258
+ ) -> JsonObject:
259
+ """Set an insight's feedback state — the reader's disposition.
260
+
261
+ ``state`` is one of ``new|actioned|dismissed|snoozed``; anything else is a
262
+ 400. An insight unknown to this (scope, tenant) is a 404."""
263
+ return self._write(
264
+ "PATCH", f"/v1/insights/{name}/state", {"state": state},
265
+ scope=scope, tenant=tenant,
266
+ )
267
+
268
+ # -- portfolio (reads) ---------------------------------------------------
269
+
270
+ def list_orgs(
271
+ self, *, scope: str | None = None, tenant: str | None = None
272
+ ) -> JsonObject:
273
+ """List the tenant's Organization docs."""
274
+ return self._get("/v1/orgs", scope=scope, tenant=tenant)
275
+
276
+ def list_projects(
277
+ self, *, scope: str | None = None, tenant: str | None = None
278
+ ) -> JsonObject:
279
+ """List the tenant's Project docs."""
280
+ return self._get("/v1/projects", scope=scope, tenant=tenant)
281
+
282
+ def get_project(
283
+ self, slug: str, *, scope: str | None = None, tenant: str | None = None
284
+ ) -> JsonObject:
285
+ """One project's detail + its RESOLVED repos. 404 → :class:`DnaApiError`."""
286
+ return self._get(f"/v1/projects/{slug}", scope=scope, tenant=tenant)
287
+
288
+ def list_project_members(
289
+ self, slug: str, *, scope: str | None = None, tenant: str | None = None,
290
+ viewer: str | None = None,
291
+ ) -> JsonObject:
292
+ """List a project's members with their RESOLVED role, tenant-scoped."""
293
+ return self._get(
294
+ f"/v1/projects/{slug}/members", scope=scope, tenant=tenant, viewer=viewer
295
+ )
296
+
297
+ def list_repos(
298
+ self, *, scope: str | None = None, tenant: str | None = None
299
+ ) -> JsonObject:
300
+ """List the tenant's Repo docs (code repositories the portfolio references)."""
301
+ return self._get("/v1/repos", scope=scope, tenant=tenant)
302
+
303
+ # -- portfolio (writes) --------------------------------------------------
304
+
305
+ def create_project(
306
+ self, workspace_id: str, name: str, *, slug: str | None = None,
307
+ claims: dict[str, Any] | None = None,
308
+ ) -> JsonObject:
309
+ """Create a Project inside ``workspace_id``.
310
+
311
+ SECURITY: the caller must hold an ACTIVE ``WorkspaceMembership`` in that
312
+ workspace — a caller without one is **403**, and a pending invite does not
313
+ count. The write scope and the project's ``board_scope`` are DERIVED from
314
+ the workspace + slug; the route refuses to accept either from the caller.
315
+ 400 on a blank ``workspace_id``/``name``.
316
+
317
+ ``claims`` is the caller's identity for a trusted server-side call under
318
+ ``--auth none``/``--auth token``. Under ``--auth config`` the VERIFIED
319
+ token claims always win and this argument is ignored."""
320
+ return self._write(
321
+ "POST", "/v1/projects",
322
+ {"workspace_id": workspace_id, "name": name, "slug": slug,
323
+ "claims": claims},
324
+ )
325
+
326
+ def set_project_member(
327
+ self, slug: str, user: str, role: str, *, actor: str | None = None,
328
+ scope: str | None = None, tenant: str | None = None,
329
+ ) -> JsonObject:
330
+ """Invite / set a user's PROJECT-scope role (upserts one Membership doc).
331
+
332
+ SECURITY: ``actor`` must be Owner/Admin of the project or its org, and only
333
+ an Owner may grant ``owner`` — **403** otherwise. 404 for an unknown
334
+ project; 422 for an unknown role."""
335
+ return self._write(
336
+ "POST", f"/v1/projects/{slug}/members",
337
+ {"user": user, "role": role, "actor": actor},
338
+ scope=scope, tenant=tenant,
339
+ )
340
+
341
+ def remove_project_member(
342
+ self, slug: str, user: str, *, actor: str | None = None,
343
+ scope: str | None = None, tenant: str | None = None,
344
+ ) -> JsonObject:
345
+ """Remove a user's PROJECT-scope grant.
346
+
347
+ SECURITY: ``actor`` must be Owner/Admin, and removing an Owner requires
348
+ Owner — **403** otherwise. Deletes ONLY the project-scope grant; an
349
+ inherited org-scope grant is untouched (the user may still resolve to a
350
+ role afterwards). 404 when the user holds no project grant here."""
351
+ return self.request(
352
+ "DELETE", f"/v1/projects/{slug}/members/{user}",
353
+ params={"actor": actor, "scope": scope, "tenant": tenant},
354
+ )
355
+
356
+ def provision_tenant_owner(
357
+ self, tid: str, user: str, *, scope: str | None = None
358
+ ) -> JsonObject:
359
+ """First-owner bootstrap: make ``user`` Owner of tenant ``tid`` when it has
360
+ no Owner yet (org- + project-scope grants).
361
+
362
+ SECURITY: FIRST-owner only and idempotent — once ANY Owner exists this is a
363
+ no-op, so a later user cannot auto-escalate into an established tenant.
364
+ This is a trusted server-side call (the portal's shared bearer), not a
365
+ user-facing one. 400 on a missing tenant/user."""
366
+ return self._write(
367
+ "POST", f"/v1/tenants/{tid}/provision-owner", {"user": user}, scope=scope,
368
+ )
369
+
370
+ # -- board (reads) -------------------------------------------------------
371
+
372
+ def get_board(
373
+ self, scope: str, *, tenant: str | None = None, recent: int = 6
374
+ ) -> JsonObject:
375
+ """A compact SDLC summary for a project's ``board_scope``."""
376
+ return self._get("/v1/board", scope=scope, tenant=tenant, recent=recent)
377
+
378
+ def get_board_item(
379
+ self, scope: str, name: str, *, tenant: str | None = None,
380
+ kind: str | None = None,
381
+ ) -> JsonObject:
382
+ """One board work-item's FULL doc (the console's item-detail drawer)."""
383
+ return self._get(
384
+ "/v1/board/item", scope=scope, name=name, tenant=tenant, kind=kind
385
+ )
386
+
387
+ # -- workspaces (read) ---------------------------------------------------
388
+
389
+ def list_workspaces(
390
+ self, *, actor_oid: str | None = None, actor_email: str | None = None,
391
+ ) -> JsonObject:
392
+ """The workspaces the caller holds an ACTIVE membership in.
393
+
394
+ Enumerates by membership, never by tenant provenance: a pending invite
395
+ does not appear, and an unknown identity gets an empty list rather than
396
+ somebody else's. This is the data source a workspace switcher builds on."""
397
+ return self._get(
398
+ "/v1/workspaces", actor_oid=actor_oid, actor_email=actor_email,
399
+ )
400
+
401
+ def list_workspace_members(
402
+ self, workspace_id: str, *, actor_oid: str | None = None,
403
+ actor_email: str | None = None,
404
+ ) -> JsonObject:
405
+ """List a workspace's members (grants). RBAC: the actor must be Owner/Admin."""
406
+ return self._get(
407
+ f"/v1/workspaces/{workspace_id}/members",
408
+ actor_oid=actor_oid, actor_email=actor_email,
409
+ )
410
+
411
+ # -- workspaces (writes) -------------------------------------------------
412
+ # Every route below is identity-scoped: the boundary is resolved from the
413
+ # caller's VERIFIED claims, never from a `tenant` query hint. Under
414
+ # `--auth config` the token's claims WIN over any `claims`/`actor` argument
415
+ # here; those arguments exist for a TRUSTED server-side caller running the API
416
+ # under `--auth none`/`--auth token` (the portal, holding the shared bearer).
417
+
418
+ def create_workspace(
419
+ self, name: str, *, slug: str | None = None,
420
+ claims: dict[str, Any] | None = None,
421
+ ) -> JsonObject:
422
+ """Create a workspace and its first OWNER, in one call.
423
+
424
+ SECURITY: the ``workspace_id`` is MINTED SERVER-SIDE and cannot be supplied
425
+ — there is deliberately no field for it, so a caller cannot name a
426
+ workspace into existence and race its real owner for it. The caller's
427
+ verified identity becomes the active owner. ``slug`` defaults to a
428
+ slugified ``name`` and is made unique. 400 on a blank name or a missing
429
+ oid/email claim."""
430
+ return self._write(
431
+ "POST", "/v1/workspaces",
432
+ {"name": name, "slug": slug, "claims": claims},
433
+ )
434
+
435
+ def create_invite(
436
+ self, workspace_id: str, email: str, *, role: str = "member",
437
+ actor: dict[str, Any] | None = None,
438
+ ) -> JsonObject:
439
+ """Invite an identity (by email) into a workspace — a ``pending``
440
+ ``WorkspaceMembership`` that only :meth:`accept_invites` can activate.
441
+
442
+ SECURITY: the actor must be Owner/Admin of the workspace, and only an
443
+ Owner may invite an Owner — **403** otherwise. 422 on an unknown role."""
444
+ return self._write(
445
+ "POST", f"/v1/workspaces/{workspace_id}/invites",
446
+ {"email": email, "role": role, "actor": actor},
447
+ )
448
+
449
+ def accept_invites(self, *, claims: dict[str, Any] | None = None) -> JsonObject:
450
+ """Accept EVERY pending invite matching the caller's verified sign-in
451
+ claims — binds the durable ``oid`` and flips ``pending`` → ``active``.
452
+
453
+ SECURITY: matches on a VERIFIED email claim only, and refuses to hijack a
454
+ grant already bound to a different ``oid``. Takes no workspace argument by
455
+ design: a caller cannot accept an invite that was not addressed to them."""
456
+ return self._write("POST", "/v1/workspaces/accept", {"claims": claims})
457
+
458
+ def provision_workspace_owner(
459
+ self, workspace_id: str, *, claims: dict[str, Any] | None = None
460
+ ) -> JsonObject:
461
+ """Reconcile the verified identity's membership in ``workspace_id`` — the
462
+ portal's every-sign-in idempotent no-op.
463
+
464
+ SECURITY: since decision **D5** this CREATES NOTHING. It REQUIRES an
465
+ existing ACTIVE ``WorkspaceMembership`` and merely returns it (back-filling
466
+ a missing Workspace identity doc for an owner). A caller holding no active
467
+ membership here — a stranger included — is **403**. To create a workspace
468
+ use :meth:`create_workspace`, which mints its own id. 400 on a missing
469
+ oid/email claim."""
470
+ return self._write(
471
+ "POST", f"/v1/workspaces/{workspace_id}/provision-owner",
472
+ {"claims": claims},
473
+ )
474
+
475
+ def revoke_workspace_member(
476
+ self, workspace_id: str, *, target_email: str | None = None,
477
+ target_oid: str | None = None, actor: dict[str, Any] | None = None,
478
+ ) -> JsonObject:
479
+ """Revoke (remove) a member's ``WorkspaceMembership``.
480
+
481
+ SECURITY: the actor must be Owner/Admin — **403** otherwise. The LAST
482
+ remaining owner can NEVER be revoked (**409**, fail-closed), so a workspace
483
+ cannot be orphaned. A target holding no grant here is 404. Name the target
484
+ by ``target_email`` or ``target_oid`` (oid wins when both are given)."""
485
+ return self._write(
486
+ "POST", f"/v1/workspaces/{workspace_id}/members/revoke",
487
+ {"target_email": target_email, "target_oid": target_oid, "actor": actor},
488
+ )
489
+
490
+ # -- billing (write) -----------------------------------------------------
491
+
492
+ def set_workspace_plan(
493
+ self, workspace_id: str, tier_id: str, *, source: str = "stripe",
494
+ stripe_customer_id: str | None = None,
495
+ stripe_subscription_id: str | None = None, status: str | None = None,
496
+ ) -> JsonObject:
497
+ """Upsert the ``WorkspacePlan`` assigning ``workspace_id`` → ``tier_id`` —
498
+ the billing→enforcement bridge.
499
+
500
+ SECURITY: this route ASSIGNS a plan and performs no membership check of its
501
+ own; it is a trusted server-side call (the portal's Stripe webhook handler,
502
+ holding the shared bearer) and must never be exposed to an end user.
503
+ Idempotent under Stripe retries. 400 on a missing workspace_id/tier_id."""
504
+ return self._write(
505
+ "PUT", "/v1/workspace-plan",
506
+ {"workspace_id": workspace_id, "tier_id": tier_id, "source": source,
507
+ "stripe_customer_id": stripe_customer_id,
508
+ "stripe_subscription_id": stripe_subscription_id, "status": status},
509
+ )
File without changes
@@ -0,0 +1,54 @@
1
+ [project]
2
+ name = "dna-client"
3
+ version = "0.21.1"
4
+ description = "DNA — Official Python client for the DNA REST read-API (spec-parity twin of the TypeScript dna-client)"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ keywords = ["dna", "client", "rest", "openapi", "typed", "agents", "sdlc"]
8
+ classifiers = [
9
+ "Development Status :: 4 - Beta",
10
+ "Intended Audience :: Developers",
11
+ "Programming Language :: Python :: 3.12",
12
+ "Programming Language :: Python :: 3.13",
13
+ "Typing :: Typed",
14
+ ]
15
+ # 3.12 floor — tracks dna-sdk / dna-cli (see packages/sdk-py/pyproject.toml).
16
+ requires-python = ">=3.12,<3.14"
17
+ dependencies = [
18
+ "httpx>=0.27",
19
+ ]
20
+
21
+ [project.optional-dependencies]
22
+ dev = [
23
+ "pytest>=8.0",
24
+ # The OpenAPI drift test (tests/test_openapi_drift.py) re-dumps the live
25
+ # FastAPI schema via scripts/dump_openapi.py and diffs it against the
26
+ # committed docs/openapi.json — so it needs the REST API face importable.
27
+ # Resolved from the sibling packages via [tool.uv.sources] below (editable).
28
+ "dna-cli[api]",
29
+ ]
30
+
31
+ [build-system]
32
+ requires = ["hatchling"]
33
+ build-backend = "hatchling.build"
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["dna_client"]
37
+
38
+ # Dev-workspace override: inside this repo, uv resolves dna-cli + dna-sdk from
39
+ # the sibling packages (editable) for the drift test — NOT from PyPI. The
40
+ # published wheel carries ONLY the httpx runtime dep (the drift test is a repo
41
+ # dev tool, never shipped).
42
+ [tool.uv.sources]
43
+ dna-cli = { path = "../cli", editable = true }
44
+ dna-sdk = { path = "../sdk-py", editable = true }
45
+
46
+ [tool.pytest.ini_options]
47
+ testpaths = ["tests"]
48
+
49
+ [project.urls]
50
+ Homepage = "https://ruinosus.github.io/dna/"
51
+ Repository = "https://github.com/ruinosus/dna"
52
+ Documentation = "https://ruinosus.github.io/dna/guides/using-the-client/"
53
+ Changelog = "https://github.com/ruinosus/dna/blob/main/CHANGELOG.md"
54
+ Issues = "https://github.com/ruinosus/dna/issues"
@@ -0,0 +1,198 @@
1
+ """Usage test for the DNA REST Python client — drives the named read methods
2
+ against an ``httpx.MockTransport`` (no live server), asserting method, URL, path
3
+ substitution, query params (incl. client-level defaults), the bearer header, and
4
+ that it unwraps success + raises :class:`DnaApiError` on a non-2xx.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+
10
+ import httpx
11
+ import pytest
12
+
13
+ from dna_client import DnaApiError, DnaClient
14
+
15
+
16
+ def _recorder(body: object, status: int = 200):
17
+ """A MockTransport that records each request and returns a canned JSON body."""
18
+ calls: list[httpx.Request] = []
19
+
20
+ def handler(request: httpx.Request) -> httpx.Response:
21
+ calls.append(request)
22
+ return httpx.Response(status, json=body)
23
+
24
+ return httpx.MockTransport(handler), calls
25
+
26
+
27
+ BASE = "http://dna.test"
28
+
29
+
30
+ def test_health_unwraps_body():
31
+ transport, calls = _recorder({"ok": True})
32
+ with DnaClient(BASE, transport=transport) as dna:
33
+ assert dna.health() == {"ok": True}
34
+ assert calls[0].url.path == "/health"
35
+
36
+
37
+ def test_bearer_token_sent():
38
+ transport, calls = _recorder({"agents": []})
39
+ with DnaClient(BASE, token="sekret", transport=transport) as dna:
40
+ dna.list_agents(scope="s")
41
+ assert calls[0].headers["authorization"] == "Bearer sekret"
42
+
43
+
44
+ def test_scope_tenant_query_params():
45
+ transport, calls = _recorder({"agents": []})
46
+ with DnaClient(BASE, transport=transport) as dna:
47
+ dna.list_agents(scope="dna-development", tenant="acme")
48
+ url = calls[0].url
49
+ assert url.path == "/v1/agents"
50
+ assert url.params["scope"] == "dna-development"
51
+ assert url.params["tenant"] == "acme"
52
+
53
+
54
+ def test_client_defaults_apply_and_per_call_overrides_win():
55
+ transport, calls = _recorder({"memories": []})
56
+ with DnaClient(BASE, tenant="acme", scope="base", transport=transport) as dna:
57
+ dna.list_memories() # uses defaults
58
+ dna.list_memories(tenant="other") # overrides tenant
59
+ assert calls[0].url.params["tenant"] == "acme"
60
+ assert calls[0].url.params["scope"] == "base"
61
+ assert calls[1].url.params["tenant"] == "other"
62
+ assert calls[1].url.params["scope"] == "base"
63
+
64
+
65
+ def test_none_params_are_dropped():
66
+ transport, calls = _recorder({"agents": []})
67
+ with DnaClient(BASE, transport=transport) as dna:
68
+ dna.list_agents() # no scope/tenant/defaults → empty query
69
+ assert str(calls[0].url) == f"{BASE}/v1/agents"
70
+
71
+
72
+ def test_path_substitution_and_typed_query():
73
+ transport, calls = _recorder({"ok": 1})
74
+ with DnaClient(BASE, transport=transport) as dna:
75
+ dna.agent_prompt("jarvis", scope="s")
76
+ dna.get_project("my-proj")
77
+ dna.search_memories("hello", k=3)
78
+ dna.get_board("dna-development", recent=5)
79
+ dna.get_board_item("dna-development", "s-foo")
80
+ assert calls[0].url.path == "/v1/agents/jarvis/prompt"
81
+ assert calls[1].url.path == "/v1/projects/my-proj"
82
+ assert calls[2].url.path == "/v1/memories/search"
83
+ assert calls[2].url.params["q"] == "hello"
84
+ assert calls[2].url.params["k"] == "3"
85
+ assert calls[3].url.params["recent"] == "5"
86
+ assert calls[4].url.params["name"] == "s-foo"
87
+
88
+
89
+ def test_workspace_members_no_scope_tenant_default():
90
+ # /v1/workspaces/* boundary routes do NOT take scope/tenant — the client
91
+ # must not inject its defaults there.
92
+ transport, calls = _recorder({"members": []})
93
+ with DnaClient(BASE, tenant="acme", scope="base", transport=transport) as dna:
94
+ dna.list_workspace_members("ws-1", actor_email="a@b.com")
95
+ url = calls[0].url
96
+ assert url.path == "/v1/workspaces/ws-1/members"
97
+ assert "tenant" not in url.params
98
+ assert "scope" not in url.params
99
+ assert url.params["actor_email"] == "a@b.com"
100
+
101
+
102
+ def test_non_2xx_raises_dna_api_error():
103
+ transport, _ = _recorder({"detail": "unknown project 'nope'"}, status=404)
104
+ with DnaClient(BASE, transport=transport) as dna:
105
+ with pytest.raises(DnaApiError) as exc:
106
+ dna.get_project("nope")
107
+ assert exc.value.status == 404
108
+ assert "unknown project" in str(exc.value)
109
+
110
+
111
+ def test_request_reaches_full_surface_incl_writes():
112
+ transport, calls = _recorder({"deleted": "s-foo"})
113
+ with DnaClient(BASE, transport=transport) as dna:
114
+ out = dna.request("DELETE", "/v1/memories/s-foo", params={"tenant": "acme"})
115
+ assert out == {"deleted": "s-foo"}
116
+ assert calls[0].method == "DELETE"
117
+ assert calls[0].url.path == "/v1/memories/s-foo"
118
+ assert calls[0].url.params["tenant"] == "acme"
119
+
120
+
121
+ # -- the named write surface -------------------------------------------------
122
+
123
+
124
+ def test_named_writes_use_the_right_verb_and_path():
125
+ transport, calls = _recorder({"ok": 1})
126
+ with DnaClient(BASE, transport=transport) as dna:
127
+ dna.remember_memory("a lesson", scope="s")
128
+ dna.delete_memory("s-foo", scope="s")
129
+ dna.set_insight_state("i-1", "dismissed")
130
+ dna.set_workspace_plan("w1", "pro")
131
+ dna.revoke_workspace_member("w1", target_email="a@b.c")
132
+ dna.remove_project_member("proj", "user@x.y", actor="boss@x.y")
133
+ seen = [(c.method, c.url.path) for c in calls]
134
+ assert seen == [
135
+ ("POST", "/v1/memories"),
136
+ ("DELETE", "/v1/memories/s-foo"),
137
+ ("PATCH", "/v1/insights/i-1/state"),
138
+ ("PUT", "/v1/workspace-plan"),
139
+ ("POST", "/v1/workspaces/w1/members/revoke"),
140
+ ("DELETE", "/v1/projects/proj/members/user@x.y"),
141
+ ]
142
+
143
+
144
+ def test_write_body_drops_none_so_server_defaults_apply():
145
+ transport, calls = _recorder({"ok": 1})
146
+ with DnaClient(BASE, transport=transport) as dna:
147
+ dna.create_workspace("Acme") # slug + claims omitted
148
+ body = json.loads(calls[0].content)
149
+ assert body == {"name": "Acme"}, "None-valued optional keys must not be sent"
150
+
151
+
152
+ def test_write_body_carries_explicit_values():
153
+ transport, calls = _recorder({"ok": 1})
154
+ with DnaClient(BASE, transport=transport) as dna:
155
+ dna.remember_memory("a lesson", area="ops", tags=["x"], affect="scar")
156
+ body = json.loads(calls[0].content)
157
+ assert body == {
158
+ "summary": "a lesson", "area": "ops", "tags": ["x"],
159
+ "affect": "scar", "owner": "portal",
160
+ }
161
+
162
+
163
+ def test_workspace_boundary_writes_get_no_scope_tenant_default():
164
+ # The workspace boundary is resolved from the caller's VERIFIED identity, so a
165
+ # client-level tenant default must never leak onto these routes and imply the
166
+ # caller may choose their own boundary.
167
+ transport, calls = _recorder({"ok": 1})
168
+ with DnaClient(BASE, tenant="acme", scope="base", transport=transport) as dna:
169
+ dna.list_workspaces()
170
+ dna.create_workspace("Acme")
171
+ dna.accept_invites()
172
+ dna.create_project("w1", "P")
173
+ dna.create_invite("w1", "a@b.c")
174
+ dna.provision_workspace_owner("w1")
175
+ dna.set_workspace_plan("w1", "pro")
176
+ for call in calls:
177
+ assert "tenant" not in call.url.params, f"tenant leaked onto {call.url.path}"
178
+ assert "scope" not in call.url.params, f"scope leaked onto {call.url.path}"
179
+
180
+
181
+ def test_scope_tenant_defaults_still_reach_the_scoped_writes():
182
+ transport, calls = _recorder({"ok": 1})
183
+ with DnaClient(BASE, tenant="acme", scope="base", transport=transport) as dna:
184
+ dna.remember_memory("x")
185
+ dna.set_project_member("proj", "u@x.y", "admin")
186
+ for call in calls:
187
+ assert call.url.params["tenant"] == "acme"
188
+ assert call.url.params["scope"] == "base"
189
+
190
+
191
+ def test_provision_tenant_owner_takes_scope_but_never_tenant():
192
+ # The tenant IS the {tid} path segment — a default tenant must not shadow it.
193
+ transport, calls = _recorder({"ok": 1})
194
+ with DnaClient(BASE, tenant="acme", scope="base", transport=transport) as dna:
195
+ dna.provision_tenant_owner("tid-1", "u@x.y")
196
+ assert calls[0].url.path == "/v1/tenants/tid-1/provision-owner"
197
+ assert calls[0].url.params["scope"] == "base"
198
+ assert "tenant" not in calls[0].url.params
@@ -0,0 +1,172 @@
1
+ """OpenAPI drift guard — keeps the committed generation source in sync with the
2
+ live DNA REST API.
3
+
4
+ The clients (``packages/client-ts`` + ``packages/client-py``) are generated from
5
+ ``docs/openapi.json``, which is dumped from the FastAPI app by
6
+ ``scripts/dump_openapi.py``. If a route/param changes and nobody re-dumps the
7
+ spec, the clients silently drift from the API. This test re-dumps the schema from
8
+ the live app and asserts it equals the committed file — so a stale spec fails CI
9
+ with a clear "run `python scripts/dump_openapi.py`" message.
10
+
11
+ The second half of this module is the COVERAGE guard: every operation in the
12
+ spec — of any HTTP method — must have a named method on the client. See the
13
+ ``_COVERED`` comment for why it is keyed by ``(method, path)`` rather than by
14
+ path, and why the read-only version of this guard was a blind spot.
15
+
16
+ It needs the REST API face importable (``dna-cli[api]`` + ``dna-sdk``); when
17
+ those are absent (a minimal ``pip install dna-client`` with no dev extras) it
18
+ SKIPS rather than fails, so the published package's own install never depends on
19
+ the repo. CI installs the dev extra, so the guard runs for real there.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import sys
24
+ from pathlib import Path
25
+
26
+ import pytest
27
+
28
+ # Repo root = packages/client-py/tests/ → up 3. scripts/ + docs/openapi.json live there.
29
+ _REPO_ROOT = Path(__file__).resolve().parents[3]
30
+ _SCRIPTS = _REPO_ROOT / "scripts"
31
+ _SPEC = _REPO_ROOT / "docs" / "openapi.json"
32
+
33
+ if str(_SCRIPTS) not in sys.path:
34
+ sys.path.insert(0, str(_SCRIPTS))
35
+
36
+
37
+ def _load_dumper():
38
+ """Import scripts/dump_openapi.py, skipping if the REST API face is absent."""
39
+ pytest.importorskip(
40
+ "fastapi",
41
+ reason="the drift test needs the REST API face — install dna-client[dev] "
42
+ "(pulls dna-cli[api] + dna-sdk).",
43
+ )
44
+ pytest.importorskip("dna_cli._rest_api", reason="dna-cli not importable")
45
+ import dump_openapi # noqa: WPS433 — repo dev script on sys.path
46
+
47
+ return dump_openapi
48
+
49
+
50
+ def test_committed_openapi_matches_live_api():
51
+ dump_openapi = _load_dumper()
52
+ assert _SPEC.exists(), f"missing committed spec: {_SPEC}"
53
+ committed = _SPEC.read_text(encoding="utf-8")
54
+ live = dump_openapi.dump()
55
+ assert committed == live, (
56
+ "docs/openapi.json is stale — the DNA REST API changed without "
57
+ "regenerating the client spec. Run `python scripts/dump_openapi.py` "
58
+ "(and `cd packages/client-ts && bun run gen`), then commit."
59
+ )
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Operation coverage — EVERY operation, not just the reads.
64
+ #
65
+ # This map is keyed by ``(HTTP METHOD, path)``, not by path, and is checked
66
+ # against every operation in the spec. That is deliberate: the previous version
67
+ # of this guard enumerated GET paths only and declared writes out of scope
68
+ # ("writes stay behind .request"), so a new write route entered the spec, the
69
+ # API and CI in total silence — which is exactly how POST /v1/workspaces and
70
+ # POST /v1/projects shipped with no client coverage at all. A guard that only
71
+ # watches the surface you already covered is not a guard.
72
+ #
73
+ # Adding an operation to the API now REQUIRES either a named client method here
74
+ # or an explicit, justified entry in _UNCOVERED below.
75
+ _COVERED: dict[tuple[str, str], str] = {
76
+ # -- reads ---------------------------------------------------------------
77
+ ("GET", "/health"): "health",
78
+ ("GET", "/v1/agents"): "list_agents",
79
+ ("GET", "/v1/agents/{name}/prompt"): "agent_prompt",
80
+ ("GET", "/v1/tools"): "list_tools",
81
+ ("GET", "/v1/memories"): "list_memories",
82
+ ("GET", "/v1/memories/search"): "search_memories",
83
+ ("GET", "/v1/sources"): "list_sources",
84
+ ("GET", "/v1/insights"): "list_insights",
85
+ ("GET", "/v1/insights/metrics"): "insight_metrics",
86
+ ("GET", "/v1/orgs"): "list_orgs",
87
+ ("GET", "/v1/projects"): "list_projects",
88
+ ("GET", "/v1/projects/{slug}"): "get_project",
89
+ ("GET", "/v1/projects/{slug}/members"): "list_project_members",
90
+ ("GET", "/v1/repos"): "list_repos",
91
+ ("GET", "/v1/board"): "get_board",
92
+ ("GET", "/v1/board/item"): "get_board_item",
93
+ ("GET", "/v1/workspaces"): "list_workspaces",
94
+ ("GET", "/v1/workspaces/{workspace_id}/members"): "list_workspace_members",
95
+ # -- writes --------------------------------------------------------------
96
+ ("POST", "/v1/memories"): "remember_memory",
97
+ ("DELETE", "/v1/memories/{name}"): "delete_memory",
98
+ ("PATCH", "/v1/insights/{name}/state"): "set_insight_state",
99
+ ("POST", "/v1/projects"): "create_project",
100
+ ("POST", "/v1/projects/{slug}/members"): "set_project_member",
101
+ ("DELETE", "/v1/projects/{slug}/members/{user}"): "remove_project_member",
102
+ ("POST", "/v1/tenants/{tid}/provision-owner"): "provision_tenant_owner",
103
+ ("PUT", "/v1/workspace-plan"): "set_workspace_plan",
104
+ ("POST", "/v1/workspaces"): "create_workspace",
105
+ ("POST", "/v1/workspaces/accept"): "accept_invites",
106
+ ("POST", "/v1/workspaces/{workspace_id}/invites"): "create_invite",
107
+ ("POST", "/v1/workspaces/{workspace_id}/members/revoke"): "revoke_workspace_member",
108
+ ("POST", "/v1/workspaces/{workspace_id}/provision-owner"): "provision_workspace_owner",
109
+ }
110
+
111
+ # Operations DELIBERATELY left without a named client method. Each entry needs a
112
+ # comment saying WHY — "it's a write" is not a reason. Empty today: every one of
113
+ # the spec's 31 operations is a single, self-contained call that a named method
114
+ # can honestly express. An operation belongs here only if a named method would
115
+ # LIE about how usable it is (e.g. it needs a multi-step handshake, or a
116
+ # credential the client has no way to hold).
117
+ _UNCOVERED: dict[tuple[str, str], str] = {}
118
+
119
+ # HTTP methods that carry an operation (OpenAPI path items also hold non-operation
120
+ # keys such as "parameters" / "summary", which must not be mistaken for one).
121
+ _HTTP_METHODS = frozenset(
122
+ {"get", "put", "post", "delete", "options", "head", "patch", "trace"}
123
+ )
124
+
125
+
126
+ def _spec_operations(schema: dict) -> set[tuple[str, str]]:
127
+ """Every ``(METHOD, path)`` operation in the OpenAPI document."""
128
+ return {
129
+ (method.upper(), path)
130
+ for path, item in schema["paths"].items()
131
+ for method in item
132
+ if method.lower() in _HTTP_METHODS
133
+ }
134
+
135
+
136
+ def test_client_covers_every_operation():
137
+ """EVERY operation in the spec — read or write — must have a named method on
138
+ the client, or an explicit justified entry in ``_UNCOVERED``."""
139
+ dump_openapi = _load_dumper()
140
+ from dna_client import DnaClient
141
+
142
+ operations = _spec_operations(dump_openapi.build_schema())
143
+
144
+ uncovered = operations - _COVERED.keys() - _UNCOVERED.keys()
145
+ assert not uncovered, (
146
+ "operation(s) in the API with no named client method: "
147
+ f"{sorted(uncovered)} — add a named method to dna_client/client.py and "
148
+ "map it in _COVERED, or, if it genuinely should not have one, add it to "
149
+ "_UNCOVERED with the reason. Do NOT leave it to `.request`."
150
+ )
151
+
152
+ for (verb, path), name in _COVERED.items():
153
+ assert hasattr(DnaClient, name), f"client missing {name}() for {verb} {path}"
154
+
155
+
156
+ def test_coverage_map_has_no_stale_entries():
157
+ """The maps must not outlive the spec — a removed route has to be removed here
158
+ too, else the guard silently protects an operation that no longer exists."""
159
+ dump_openapi = _load_dumper()
160
+ operations = _spec_operations(dump_openapi.build_schema())
161
+
162
+ stale = (_COVERED.keys() | _UNCOVERED.keys()) - operations
163
+ assert not stale, (
164
+ f"coverage map lists operation(s) absent from the spec: {sorted(stale)} "
165
+ "— the route was removed/renamed; drop the entry (and its client method)."
166
+ )
167
+
168
+
169
+ def test_every_uncovered_entry_states_a_reason():
170
+ """An allowlist without reasons decays into a dumping ground."""
171
+ for key, reason in _UNCOVERED.items():
172
+ assert reason and reason.strip(), f"_UNCOVERED[{key}] needs a stated reason"