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.
- dna_client-0.21.1/.gitignore +23 -0
- dna_client-0.21.1/LICENSE +21 -0
- dna_client-0.21.1/PKG-INFO +93 -0
- dna_client-0.21.1/README.md +69 -0
- dna_client-0.21.1/dna_client/__init__.py +26 -0
- dna_client-0.21.1/dna_client/client.py +509 -0
- dna_client-0.21.1/dna_client/py.typed +0 -0
- dna_client-0.21.1/pyproject.toml +54 -0
- dna_client-0.21.1/tests/test_client.py +198 -0
- dna_client-0.21.1/tests/test_openapi_drift.py +172 -0
|
@@ -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"
|