engram-dynamics 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- engram_cli/__init__.py +8 -0
- engram_cli/api.py +525 -0
- engram_cli/common.py +63 -0
- engram_cli/config.py +168 -0
- engram_cli/filesource.py +267 -0
- engram_cli/main.py +655 -0
- engram_cli/mcp_cmd.py +204 -0
- engram_cli/output.py +178 -0
- engram_cli/push.py +651 -0
- engram_cli/push_cmd.py +209 -0
- engram_cli/sources_cmd.py +401 -0
- engram_dynamics-0.1.0.dist-info/METADATA +21 -0
- engram_dynamics-0.1.0.dist-info/RECORD +21 -0
- engram_dynamics-0.1.0.dist-info/WHEEL +4 -0
- engram_dynamics-0.1.0.dist-info/entry_points.txt +2 -0
- mcp_server/Dockerfile +8 -0
- mcp_server/__init__.py +0 -0
- mcp_server/requirements.txt +2 -0
- mcp_server/server.py +765 -0
- mcp_server/test_api_version_offline.py +213 -0
- mcp_server/test_server_offline.py +478 -0
engram_cli/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""engram-dynamics — the `engram` command-line client for the Engram platform.
|
|
2
|
+
|
|
3
|
+
Entry point is `engram_cli.main:main` (the `engram` console script). The package also
|
|
4
|
+
ships the repo's `mcp_server` stdio server, so one install gives a customer both the
|
|
5
|
+
CLI and their MCP server (CAAS-904).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
engram_cli/api.py
ADDED
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
"""HTTP client for the Engram control plane.
|
|
2
|
+
|
|
3
|
+
Every call goes through `Client.request`, so the error mapping lives in exactly one
|
|
4
|
+
place: the server's own message is what the user sees, and the CLI never invents a
|
|
5
|
+
reason of its own. Auth is `Authorization: Bearer <token>`; an `ek_`-prefixed API key
|
|
6
|
+
(CAAS-801) and a session JWT are both accepted there, so the CLI does not care which
|
|
7
|
+
one it is holding.
|
|
8
|
+
|
|
9
|
+
API VERSION (CAAS-802). The same client speaks both contracts, chosen per profile:
|
|
10
|
+
|
|
11
|
+
legacy (the default) the unversioned paths this CLI has always called
|
|
12
|
+
1 the published /v1 contract — one error envelope, list
|
|
13
|
+
responses wrapped in {"items", "next_cursor"}, and
|
|
14
|
+
Idempotency-Key honoured on writes
|
|
15
|
+
auto ask the server: GET / lists `api_versions`, so a deployment
|
|
16
|
+
that advertises "1" gets it and an older one falls back
|
|
17
|
+
|
|
18
|
+
THE DEFAULT STAYS LEGACY on purpose. An installed CLI must not change behaviour
|
|
19
|
+
because a server rolled forward, and a customer on an older self-hosted control plane
|
|
20
|
+
has no /v1 to talk to. `engram login --api-version 1` (or `auto`) opts in, per profile.
|
|
21
|
+
|
|
22
|
+
Everything below this line is version-agnostic: the commands call the same methods and
|
|
23
|
+
see the same lists and the same ApiError messages either way. The two places the
|
|
24
|
+
contracts differ — the error body and the list envelope — are normalised in
|
|
25
|
+
`_detail_message` / `_detail_code` / `_retry_after` and in `Client._list`.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
import httpx
|
|
32
|
+
|
|
33
|
+
from .config import Profile
|
|
34
|
+
|
|
35
|
+
# Generous enough for a control-plane call over a cold ECS task, short enough that a
|
|
36
|
+
# wedged network fails the command instead of hanging a script.
|
|
37
|
+
DEFAULT_TIMEOUT = 30.0
|
|
38
|
+
|
|
39
|
+
# Profile values for `api_version`. "legacy" is the sentinel for the unversioned paths; anything
|
|
40
|
+
# else is a version number and becomes the path prefix ("1" -> "/v1").
|
|
41
|
+
LEGACY_VERSION = "legacy"
|
|
42
|
+
AUTO_VERSION = "auto"
|
|
43
|
+
# The contract's maximum page size (backend/app/pagination.py). Asking for it is how a client that
|
|
44
|
+
# wants everything makes the fewest round trips.
|
|
45
|
+
MAX_PAGE = 500
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _items(body: Any) -> list[dict]:
|
|
49
|
+
"""The rows out of a list response, whichever contract produced it."""
|
|
50
|
+
if isinstance(body, dict):
|
|
51
|
+
return body.get("items") or []
|
|
52
|
+
return body or []
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ApiError(Exception):
|
|
56
|
+
"""A request failed. `message` is already user-facing — print it and exit 1.
|
|
57
|
+
|
|
58
|
+
`code` and `retry_after` carry the structured half of the error (CAAS-111: 429
|
|
59
|
+
`ingest_quota` / `beta_limit` / `plan_limit`, 402 `trial_expired`, 503
|
|
60
|
+
`serving_unavailable`) for the few callers that need to BRANCH on a failure rather
|
|
61
|
+
than just report it — `engram push` backs off and retries a 503 upload, and stops
|
|
62
|
+
dead on a quota 429 instead of hammering the rest of a 20,000-file manifest.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
message: str,
|
|
68
|
+
*,
|
|
69
|
+
status: int | None = None,
|
|
70
|
+
body: Any = None,
|
|
71
|
+
code: str | None = None,
|
|
72
|
+
retry_after: float | None = None,
|
|
73
|
+
):
|
|
74
|
+
super().__init__(message)
|
|
75
|
+
self.message = message
|
|
76
|
+
self.status = status
|
|
77
|
+
self.body = body
|
|
78
|
+
self.code = code
|
|
79
|
+
self.retry_after = retry_after
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class NotLoggedIn(ApiError):
|
|
83
|
+
def __init__(self, profile_name: str):
|
|
84
|
+
super().__init__(
|
|
85
|
+
f"No API key for profile '{profile_name}'. Run: engram login --profile "
|
|
86
|
+
f"{profile_name} --api-key ek_..."
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _envelope(body: Any) -> dict | None:
|
|
91
|
+
"""The /v1 error envelope, `{"error": {"code", "message", ...}}`, or None.
|
|
92
|
+
|
|
93
|
+
Checked on `code` rather than on the key alone: a legacy structured detail also uses
|
|
94
|
+
the word "error", and mistaking one for the other would print a dict at the user."""
|
|
95
|
+
if not isinstance(body, dict):
|
|
96
|
+
return None
|
|
97
|
+
error = body.get("error")
|
|
98
|
+
return error if isinstance(error, dict) and "code" in error else None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _validation_line(errors: Any) -> str | None:
|
|
102
|
+
"""FastAPI's per-field validation list, flattened to one line. Same rendering for both
|
|
103
|
+
contracts — under /v1 the list moved into `details.errors`, its items did not change."""
|
|
104
|
+
if not isinstance(errors, list):
|
|
105
|
+
return None
|
|
106
|
+
parts = []
|
|
107
|
+
for item in errors:
|
|
108
|
+
if isinstance(item, dict) and item.get("msg"):
|
|
109
|
+
loc = ".".join(str(p) for p in (item.get("loc") or []) if p != "body")
|
|
110
|
+
parts.append(f"{loc}: {item['msg']}" if loc else str(item["msg"]))
|
|
111
|
+
else:
|
|
112
|
+
parts.append(str(item))
|
|
113
|
+
return "; ".join(parts) or None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _detail_message(body: Any) -> str | None:
|
|
117
|
+
"""Turn any error body — either contract — into one line.
|
|
118
|
+
|
|
119
|
+
Four shapes in the wild:
|
|
120
|
+
* {"detail": "plain string"} — the common legacy case
|
|
121
|
+
* {"detail": {"error": "beta_limit", "message": ...}} — beta caps (429); the
|
|
122
|
+
`message` is written for the end user, so it wins over the error code.
|
|
123
|
+
* {"detail": [{"loc": [...], "msg": ...}, ...]} — 422 validation errors.
|
|
124
|
+
* {"error": {"code": ..., "message": ..., "details": {...}}} — the /v1 envelope.
|
|
125
|
+
|
|
126
|
+
The /v1 branch is first and deliberately produces the SAME sentence the legacy branch
|
|
127
|
+
would: the envelope is a re-packaging of the same message and the same extras, so a
|
|
128
|
+
customer who switches contracts must not see the CLI start saying different things.
|
|
129
|
+
"""
|
|
130
|
+
error = _envelope(body)
|
|
131
|
+
if error is not None:
|
|
132
|
+
details = error.get("details") if isinstance(error.get("details"), dict) else {}
|
|
133
|
+
message = str(error.get("message") or error.get("code") or "").strip()
|
|
134
|
+
fields = _validation_line(details.get("errors"))
|
|
135
|
+
if fields:
|
|
136
|
+
message = f"{message} {fields}".strip() if message else fields
|
|
137
|
+
upgrade = details.get("upgrade_url")
|
|
138
|
+
if upgrade and str(upgrade) not in message:
|
|
139
|
+
message = f"{message} Upgrade: {upgrade}"
|
|
140
|
+
return message or None
|
|
141
|
+
|
|
142
|
+
if not isinstance(body, dict):
|
|
143
|
+
return None
|
|
144
|
+
detail = body.get("detail")
|
|
145
|
+
if detail is None:
|
|
146
|
+
return None
|
|
147
|
+
if isinstance(detail, str):
|
|
148
|
+
return detail
|
|
149
|
+
if isinstance(detail, dict):
|
|
150
|
+
message = str(detail.get("message") or detail.get("error") or detail)
|
|
151
|
+
# A quota or plan wall is only actionable with the link out of it, and the server
|
|
152
|
+
# is the one that knows which plan page applies. Appended rather than reworded.
|
|
153
|
+
upgrade = detail.get("upgrade_url")
|
|
154
|
+
if upgrade and str(upgrade) not in message:
|
|
155
|
+
message = f"{message} Upgrade: {upgrade}"
|
|
156
|
+
return message
|
|
157
|
+
if isinstance(detail, list):
|
|
158
|
+
return _validation_line(detail)
|
|
159
|
+
return str(detail)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _detail_code(body: Any) -> str | None:
|
|
163
|
+
"""The machine-readable error code, from either contract.
|
|
164
|
+
|
|
165
|
+
This is what `engram push` branches on — it backs off on `serving_unavailable` and stops
|
|
166
|
+
dead on `ingest_quota` — so the codes have to come out identical on both. They do: /v1
|
|
167
|
+
carries the SAME code, just at `error.code` instead of `detail.error`."""
|
|
168
|
+
error = _envelope(body)
|
|
169
|
+
if error is not None:
|
|
170
|
+
code = error.get("code")
|
|
171
|
+
return str(code) if code else None
|
|
172
|
+
if not isinstance(body, dict):
|
|
173
|
+
return None
|
|
174
|
+
detail = body.get("detail")
|
|
175
|
+
if isinstance(detail, dict):
|
|
176
|
+
code = detail.get("error") or detail.get("code")
|
|
177
|
+
return str(code) if code else None
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _retry_after(body: Any, resp: httpx.Response) -> float | None:
|
|
182
|
+
"""Seconds to wait, from the structured detail or the standard header.
|
|
183
|
+
|
|
184
|
+
Read defensively from both places because the two sources disagree in the wild: a
|
|
185
|
+
proxy or the rate limiter may set the header while the application sets the field.
|
|
186
|
+
Either one is better than the CLI inventing a backoff of its own.
|
|
187
|
+
"""
|
|
188
|
+
candidates: list[Any] = []
|
|
189
|
+
error = _envelope(body)
|
|
190
|
+
if error is not None and isinstance(error.get("details"), dict):
|
|
191
|
+
candidates.append(error["details"].get("retry_after"))
|
|
192
|
+
if isinstance(body, dict) and isinstance(body.get("detail"), dict):
|
|
193
|
+
candidates.append(body["detail"].get("retry_after"))
|
|
194
|
+
candidates.append(resp.headers.get("retry-after"))
|
|
195
|
+
for value in candidates:
|
|
196
|
+
if value is None:
|
|
197
|
+
continue
|
|
198
|
+
try:
|
|
199
|
+
return max(0.0, float(value))
|
|
200
|
+
except (TypeError, ValueError):
|
|
201
|
+
continue # an HTTP-date Retry-After; not worth parsing for a backoff hint
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class Client:
|
|
206
|
+
"""Thin wrapper over httpx. One instance per command invocation."""
|
|
207
|
+
|
|
208
|
+
def __init__(self, profile: Profile, *, timeout: float = DEFAULT_TIMEOUT):
|
|
209
|
+
self.profile = profile
|
|
210
|
+
self.base_url = profile.api_url
|
|
211
|
+
self._timeout = timeout
|
|
212
|
+
# Resolved lazily, and once per client, because `auto` costs an extra round trip and a
|
|
213
|
+
# command that makes ten calls should pay for it no more than a command that makes one.
|
|
214
|
+
self._prefix: str | None = None
|
|
215
|
+
|
|
216
|
+
# --- plumbing ---------------------------------------------------------------
|
|
217
|
+
def _headers(self, *, auth: bool) -> dict[str, str]:
|
|
218
|
+
headers = {"Accept": "application/json"}
|
|
219
|
+
if auth:
|
|
220
|
+
if not self.profile.api_key:
|
|
221
|
+
raise NotLoggedIn(self.profile.name)
|
|
222
|
+
headers["Authorization"] = f"Bearer {self.profile.api_key}"
|
|
223
|
+
return headers
|
|
224
|
+
|
|
225
|
+
@property
|
|
226
|
+
def api_prefix(self) -> str:
|
|
227
|
+
""""" for the legacy paths, "/v1" for the published contract.
|
|
228
|
+
|
|
229
|
+
`auto` asks the server: GET / is unauthenticated and lists `api_versions`, so this works
|
|
230
|
+
before login and on a deployment that has never heard of versions (it 404s, and we fall
|
|
231
|
+
back to legacy, which is the right answer for exactly that deployment)."""
|
|
232
|
+
if self._prefix is not None:
|
|
233
|
+
return self._prefix
|
|
234
|
+
version = (self.profile.api_version or LEGACY_VERSION).strip().lower()
|
|
235
|
+
if version == AUTO_VERSION:
|
|
236
|
+
version = self._advertised_version()
|
|
237
|
+
self._prefix = "" if version == LEGACY_VERSION else f"/v{version.lstrip('v')}"
|
|
238
|
+
return self._prefix
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def resolved_version(self) -> str:
|
|
242
|
+
"""The version this client actually settled on — "legacy" or a number, never "auto"."""
|
|
243
|
+
prefix = self.api_prefix
|
|
244
|
+
return prefix[2:] if prefix else LEGACY_VERSION
|
|
245
|
+
|
|
246
|
+
def _advertised_version(self) -> str:
|
|
247
|
+
"""The newest version this deployment says it serves, or legacy if it says nothing.
|
|
248
|
+
|
|
249
|
+
Never raises: version discovery failing is not a reason for the user's actual command to
|
|
250
|
+
fail, and the legacy paths are the safe answer in every case where it does."""
|
|
251
|
+
try:
|
|
252
|
+
body = self.request("GET", "/", auth=False, versioned=False)
|
|
253
|
+
except ApiError:
|
|
254
|
+
return LEGACY_VERSION
|
|
255
|
+
versions = body.get("api_versions") if isinstance(body, dict) else None
|
|
256
|
+
if not isinstance(versions, list) or not versions:
|
|
257
|
+
return LEGACY_VERSION
|
|
258
|
+
return str(max(versions, key=lambda v: (len(str(v)), str(v))))
|
|
259
|
+
|
|
260
|
+
def request(
|
|
261
|
+
self,
|
|
262
|
+
method: str,
|
|
263
|
+
path: str,
|
|
264
|
+
*,
|
|
265
|
+
auth: bool = True,
|
|
266
|
+
json_body: Any = None,
|
|
267
|
+
params: dict[str, Any] | None = None,
|
|
268
|
+
headers: dict[str, str] | None = None,
|
|
269
|
+
versioned: bool = True,
|
|
270
|
+
) -> Any:
|
|
271
|
+
"""Call the API and return parsed JSON (None for a 204), or raise ApiError.
|
|
272
|
+
|
|
273
|
+
`versioned=False` is for the handful of paths that have no versioned copy and never will:
|
|
274
|
+
`/health` (the load balancer's target check) and `/` (version discovery itself)."""
|
|
275
|
+
url = f"{self.base_url}{self.api_prefix if versioned else ''}{path}"
|
|
276
|
+
try:
|
|
277
|
+
resp = httpx.request(
|
|
278
|
+
method,
|
|
279
|
+
url,
|
|
280
|
+
headers={**self._headers(auth=auth), **(headers or {})},
|
|
281
|
+
json=json_body,
|
|
282
|
+
params=params,
|
|
283
|
+
timeout=self._timeout,
|
|
284
|
+
follow_redirects=True,
|
|
285
|
+
)
|
|
286
|
+
except httpx.TimeoutException as exc:
|
|
287
|
+
raise ApiError(f"Timed out talking to {self.base_url} ({exc.__class__.__name__}).") from exc
|
|
288
|
+
except httpx.HTTPError as exc:
|
|
289
|
+
raise ApiError(f"Could not reach {self.base_url}: {exc}") from exc
|
|
290
|
+
|
|
291
|
+
if resp.status_code == 204 or not resp.content:
|
|
292
|
+
if resp.is_success:
|
|
293
|
+
return None
|
|
294
|
+
|
|
295
|
+
body: Any = None
|
|
296
|
+
try:
|
|
297
|
+
body = resp.json()
|
|
298
|
+
except ValueError:
|
|
299
|
+
body = None
|
|
300
|
+
|
|
301
|
+
if resp.is_success:
|
|
302
|
+
return body
|
|
303
|
+
|
|
304
|
+
message = _detail_message(body)
|
|
305
|
+
if message is None:
|
|
306
|
+
message = resp.text.strip() or f"HTTP {resp.status_code}"
|
|
307
|
+
if resp.status_code == 401:
|
|
308
|
+
message = f"{message} (the API key was rejected — check `engram login`)"
|
|
309
|
+
raise ApiError(
|
|
310
|
+
message,
|
|
311
|
+
status=resp.status_code,
|
|
312
|
+
body=body,
|
|
313
|
+
code=_detail_code(body),
|
|
314
|
+
retry_after=_retry_after(body, resp),
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
def _list(self, path: str, *, auth: bool = True, params: dict[str, Any] | None = None) -> list[dict]:
|
|
318
|
+
"""A list endpoint, as a plain list, on either contract.
|
|
319
|
+
|
|
320
|
+
Legacy answers a bare array. /v1 answers `{"items": [...], "next_cursor": ...}` and pages,
|
|
321
|
+
so we FOLLOW the cursor to the end: every caller here wants the whole set (resolve_corpus
|
|
322
|
+
matches a name across it, `engram docs` slices it locally), and a CLI that silently showed
|
|
323
|
+
the first 50 of 400 documents would be worse than one that never paged at all. The page
|
|
324
|
+
size is the contract maximum, so the round trips are as few as the server allows."""
|
|
325
|
+
if not self.api_prefix:
|
|
326
|
+
return self.request("GET", path, auth=auth, params=params) or []
|
|
327
|
+
|
|
328
|
+
out: list[dict] = []
|
|
329
|
+
query = dict(params or {})
|
|
330
|
+
query.setdefault("limit", MAX_PAGE)
|
|
331
|
+
while True:
|
|
332
|
+
body = self.request("GET", path, auth=auth, params=query)
|
|
333
|
+
out.extend(_items(body))
|
|
334
|
+
cursor = body.get("next_cursor") if isinstance(body, dict) else None
|
|
335
|
+
if not cursor:
|
|
336
|
+
return out
|
|
337
|
+
query["cursor"] = cursor
|
|
338
|
+
|
|
339
|
+
# --- endpoints --------------------------------------------------------------
|
|
340
|
+
def health(self) -> Any:
|
|
341
|
+
"""Liveness. Deliberately unauthenticated so `engram status` works logged out, and
|
|
342
|
+
unversioned because the load balancer's target check has no business moving."""
|
|
343
|
+
return self.request("GET", "/health", auth=False, versioned=False)
|
|
344
|
+
|
|
345
|
+
def platform_status(self) -> Any:
|
|
346
|
+
"""Live serving state (CAAS-1302's GET /status, which CAAS-1303 is the query-path half of).
|
|
347
|
+
|
|
348
|
+
Needs a session since 2026-09-11 (the founder made the status page sign-in only), so the
|
|
349
|
+
profile's key rides along; `engram status` only asks when a key is configured. Cached
|
|
350
|
+
server-side, so polling this is cheap."""
|
|
351
|
+
return self.request("GET", "/status", auth=True)
|
|
352
|
+
|
|
353
|
+
def list_corpora(self) -> list[dict]:
|
|
354
|
+
return self._list("/corpora")
|
|
355
|
+
|
|
356
|
+
def create_corpus(self, name: str) -> dict:
|
|
357
|
+
return self.request("POST", "/corpora", json_body={"name": name})
|
|
358
|
+
|
|
359
|
+
def get_corpus(self, corpus_id: str) -> dict:
|
|
360
|
+
return self.request("GET", f"/corpora/{corpus_id}")
|
|
361
|
+
|
|
362
|
+
def list_documents(self, corpus_id: str, *, limit: int | None = None, offset: int = 0) -> list[dict]:
|
|
363
|
+
"""Documents for a corpus.
|
|
364
|
+
|
|
365
|
+
GET /corpora/{id}/documents returns the whole list today — there is no
|
|
366
|
+
server-side paging yet (it arrives with CAAS-903), and guessing whether a
|
|
367
|
+
response was paged is not something a client can do reliably. So `--limit` and
|
|
368
|
+
`--offset` are applied here, deterministically. When the endpoint starts paging,
|
|
369
|
+
move the slice into query params; the command surface does not change.
|
|
370
|
+
"""
|
|
371
|
+
docs = self._list(f"/corpora/{corpus_id}/documents")
|
|
372
|
+
if offset:
|
|
373
|
+
docs = docs[offset:]
|
|
374
|
+
if limit is not None:
|
|
375
|
+
docs = docs[:limit]
|
|
376
|
+
return docs
|
|
377
|
+
|
|
378
|
+
def list_jobs(self, corpus_id: str) -> list[dict]:
|
|
379
|
+
return self._list(f"/corpora/{corpus_id}/jobs")
|
|
380
|
+
|
|
381
|
+
# API keys (CAAS-801, built in parallel — paths are the agreed contract).
|
|
382
|
+
def list_api_keys(self) -> list[dict]:
|
|
383
|
+
return self._list("/api-keys")
|
|
384
|
+
|
|
385
|
+
def create_api_key(self, name: str, scopes: list[str], expires_at: str | None = None) -> dict:
|
|
386
|
+
body: dict[str, Any] = {"name": name, "scopes": scopes}
|
|
387
|
+
if expires_at:
|
|
388
|
+
body["expires_at"] = expires_at
|
|
389
|
+
return self.request("POST", "/api-keys", json_body=body)
|
|
390
|
+
|
|
391
|
+
def revoke_api_key(self, key_id: str) -> Any:
|
|
392
|
+
return self.request("DELETE", f"/api-keys/{key_id}")
|
|
393
|
+
|
|
394
|
+
# --- ingest contract (CAAS-106/107/109, consumed by `engram push`) ------------
|
|
395
|
+
#
|
|
396
|
+
# The three calls are one transaction in the user's head and three on the wire, on
|
|
397
|
+
# purpose: diff is cheap and answers "what actually changed", upload-urls is issued
|
|
398
|
+
# only for that delta, and commit is what makes any of it real. A run interrupted
|
|
399
|
+
# between any two of them is resumable precisely because nothing is registered until
|
|
400
|
+
# commit — the next diff sees the same work still outstanding.
|
|
401
|
+
def platform_info(self) -> dict:
|
|
402
|
+
"""Region, egress guidance, and the extensions this deployment can ingest.
|
|
403
|
+
|
|
404
|
+
Unauthenticated (CAAS-608) so it also answers the pre-signup question, which is
|
|
405
|
+
why `engram push` can use it to learn the supported extensions before doing
|
|
406
|
+
anything that needs a key.
|
|
407
|
+
"""
|
|
408
|
+
return self.request("GET", "/platform/info", auth=False) or {}
|
|
409
|
+
|
|
410
|
+
def diff_documents(self, corpus_id: str, files: list[dict]) -> dict:
|
|
411
|
+
"""The rsync step: send the manifest, get back what is new/changed/unchanged/missing."""
|
|
412
|
+
return self.request(
|
|
413
|
+
"POST", f"/corpora/{corpus_id}/documents/diff", json_body={"files": files}
|
|
414
|
+
) or {}
|
|
415
|
+
|
|
416
|
+
def upload_urls(self, corpus_id: str, files: list[dict]) -> list[dict]:
|
|
417
|
+
"""Ask for a write target per file. Each may be a presigned bucket URL or a URL
|
|
418
|
+
on the API itself; `push.py` decides which credential to send from the origin."""
|
|
419
|
+
body = self.request(
|
|
420
|
+
"POST", f"/corpora/{corpus_id}/documents/upload-urls", json_body={"files": files}
|
|
421
|
+
) or {}
|
|
422
|
+
return body.get("uploads") or []
|
|
423
|
+
|
|
424
|
+
def commit_documents(
|
|
425
|
+
self, corpus_id: str, files: list[dict], *, onboard: bool, delete_missing: bool
|
|
426
|
+
) -> dict:
|
|
427
|
+
"""Register the uploaded bytes and open a sync run.
|
|
428
|
+
|
|
429
|
+
`files` is the WHOLE manifest, not just the delta: `delete_missing` can only mean
|
|
430
|
+
"remove what this manifest does not mention", so the server has to see the full
|
|
431
|
+
desired state to compute a removal safely.
|
|
432
|
+
"""
|
|
433
|
+
return self.request(
|
|
434
|
+
"POST",
|
|
435
|
+
f"/corpora/{corpus_id}/documents/commit",
|
|
436
|
+
json_body={"files": files, "onboard": onboard, "delete_missing": delete_missing},
|
|
437
|
+
) or {}
|
|
438
|
+
|
|
439
|
+
def upsert_document(
|
|
440
|
+
self,
|
|
441
|
+
corpus_id: str,
|
|
442
|
+
path: str,
|
|
443
|
+
*,
|
|
444
|
+
text: str | None = None,
|
|
445
|
+
content_base64: str | None = None,
|
|
446
|
+
) -> dict:
|
|
447
|
+
body: dict[str, Any] = {"path": path}
|
|
448
|
+
if text is not None:
|
|
449
|
+
body["text"] = text
|
|
450
|
+
if content_base64 is not None:
|
|
451
|
+
body["content_base64"] = content_base64
|
|
452
|
+
return self.request("POST", f"/corpora/{corpus_id}/documents/upsert", json_body=body) or {}
|
|
453
|
+
|
|
454
|
+
# --- sync runs (CAAS-109) -----------------------------------------------------
|
|
455
|
+
def get_sync_run(self, corpus_id: str, run_id: str) -> dict:
|
|
456
|
+
return self.request("GET", f"/corpora/{corpus_id}/sync-runs/{run_id}") or {}
|
|
457
|
+
|
|
458
|
+
def list_sync_runs(self, corpus_id: str) -> list[dict]:
|
|
459
|
+
"""Run history, newest first if the server orders it; a bare list or a
|
|
460
|
+
{"runs": [...]} envelope are both accepted because either is a reasonable shape
|
|
461
|
+
and guessing wrong would break `engram status` for a cosmetic reason."""
|
|
462
|
+
if self.api_prefix:
|
|
463
|
+
return self._list(f"/corpora/{corpus_id}/sync-runs")
|
|
464
|
+
body = self.request("GET", f"/corpora/{corpus_id}/sync-runs")
|
|
465
|
+
if isinstance(body, dict):
|
|
466
|
+
return body.get("runs") or body.get("items") or []
|
|
467
|
+
return body or []
|
|
468
|
+
|
|
469
|
+
# --- sources (CAAS-601/903) ---------------------------------------------------
|
|
470
|
+
def create_source(self, corpus_id: str, body: dict) -> dict:
|
|
471
|
+
return self.request("POST", f"/corpora/{corpus_id}/sources", json_body=body) or {}
|
|
472
|
+
|
|
473
|
+
def list_sources(self, corpus_id: str) -> list[dict]:
|
|
474
|
+
return self._list(f"/corpora/{corpus_id}/sources")
|
|
475
|
+
|
|
476
|
+
def validate_source(self, corpus_id: str, source_id: str) -> dict:
|
|
477
|
+
return self.request(
|
|
478
|
+
"POST", f"/corpora/{corpus_id}/sources/{source_id}/validate"
|
|
479
|
+
) or {}
|
|
480
|
+
|
|
481
|
+
def sync_source(self, corpus_id: str, source_id: str) -> dict:
|
|
482
|
+
"""Kick a pull now instead of waiting for the schedule. Returns a sync run."""
|
|
483
|
+
return self.request("POST", f"/corpora/{corpus_id}/sources/{source_id}/sync") or {}
|
|
484
|
+
|
|
485
|
+
def delete_source(self, corpus_id: str, source_id: str) -> Any:
|
|
486
|
+
return self.request("DELETE", f"/corpora/{corpus_id}/sources/{source_id}")
|
|
487
|
+
|
|
488
|
+
def s3_setup(
|
|
489
|
+
self, *, external_id: str, bucket: str, prefix: str = "", kms_key_arn: str | None = None
|
|
490
|
+
) -> dict:
|
|
491
|
+
"""The customer-side IAM templates for one source. A pure renderer server-side,
|
|
492
|
+
so it holds no secret and needs nothing to exist first."""
|
|
493
|
+
params: dict[str, Any] = {"external_id": external_id, "bucket": bucket, "prefix": prefix}
|
|
494
|
+
if kms_key_arn:
|
|
495
|
+
params["kms_key_arn"] = kms_key_arn
|
|
496
|
+
return self.request("GET", "/sources/s3/setup", params=params) or {}
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def resolve_corpus(client: Client, ref: str) -> dict:
|
|
500
|
+
"""Accept a corpus by exact id OR exact name, anywhere a corpus is named.
|
|
501
|
+
|
|
502
|
+
One list call answers both, and gives good errors: an unknown ref names the closest
|
|
503
|
+
thing the tenant actually has, and a duplicated name lists the candidate ids rather
|
|
504
|
+
than silently picking one.
|
|
505
|
+
"""
|
|
506
|
+
corpora = client.list_corpora()
|
|
507
|
+
for corpus in corpora:
|
|
508
|
+
if corpus.get("id") == ref:
|
|
509
|
+
return corpus
|
|
510
|
+
|
|
511
|
+
by_name = [c for c in corpora if c.get("name") == ref]
|
|
512
|
+
if not by_name:
|
|
513
|
+
# Case-insensitive second pass — people type `engram status sales` for "Sales".
|
|
514
|
+
by_name = [c for c in corpora if (c.get("name") or "").lower() == ref.lower()]
|
|
515
|
+
|
|
516
|
+
if len(by_name) == 1:
|
|
517
|
+
return by_name[0]
|
|
518
|
+
if len(by_name) > 1:
|
|
519
|
+
candidates = "\n".join(f" {c.get('id')} {c.get('name')}" for c in by_name)
|
|
520
|
+
raise ApiError(
|
|
521
|
+
f"'{ref}' matches {len(by_name)} document bases. Use the id:\n{candidates}"
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
known = ", ".join(sorted(c.get("name", "") for c in corpora)) or "(none)"
|
|
525
|
+
raise ApiError(f"No document base named or with id '{ref}'. You have: {known}")
|
engram_cli/common.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""The conventions every `engram` command shares, in one importable place.
|
|
2
|
+
|
|
3
|
+
`main.py` owns the command surface, but `sources_cmd.py` needs the same `--profile` /
|
|
4
|
+
`--json` handling and the same error-to-exit-code mapping, and importing `main` from a
|
|
5
|
+
command module it registers would be circular. So the four pieces both need live here:
|
|
6
|
+
the merged option object, the two shared `typer.Option` declarations, the error decorator,
|
|
7
|
+
and the client factory.
|
|
8
|
+
|
|
9
|
+
Behaviour is unchanged from where it started in `main.py` — every command still accepts
|
|
10
|
+
its flags before OR after the subcommand, and still exits 1 with the SERVER's message on
|
|
11
|
+
stderr rather than a reason the CLI invented.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import functools
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import typer
|
|
21
|
+
|
|
22
|
+
from . import output
|
|
23
|
+
from .api import ApiError, Client
|
|
24
|
+
from .config import ConfigError, load_profile
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class CommonOpts:
|
|
29
|
+
profile: str | None = None
|
|
30
|
+
json_out: bool = False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
PROFILE_OPT = typer.Option(None, "--profile", "-p", help="Config profile to use (default: 'default').")
|
|
34
|
+
JSON_OPT = typer.Option(False, "--json", help="Emit JSON on stdout for scripting.")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def opts(ctx: typer.Context, profile: str | None, json_out: bool) -> CommonOpts:
|
|
38
|
+
"""Merge the group-level flags (`engram --json status`) with the command-level ones
|
|
39
|
+
(`engram status --json`). Either position works; the command-level one wins."""
|
|
40
|
+
base: CommonOpts = ctx.obj if isinstance(ctx.obj, CommonOpts) else CommonOpts()
|
|
41
|
+
return CommonOpts(profile=profile or base.profile, json_out=bool(json_out or base.json_out))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def handle_errors(func: Callable) -> Callable:
|
|
45
|
+
"""Turn an ApiError/ConfigError into `error: <server message>` on stderr and exit 1.
|
|
46
|
+
|
|
47
|
+
Applied per command rather than around the whole app so the behaviour is identical
|
|
48
|
+
when a test drives a command through CliRunner.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
@functools.wraps(func)
|
|
52
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
53
|
+
try:
|
|
54
|
+
return func(*args, **kwargs)
|
|
55
|
+
except (ApiError, ConfigError) as exc:
|
|
56
|
+
output.error(str(exc))
|
|
57
|
+
raise typer.Exit(code=1) from exc
|
|
58
|
+
|
|
59
|
+
return wrapper
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def client(options: CommonOpts) -> Client:
|
|
63
|
+
return Client(load_profile(options.profile))
|