deepcell-cli 0.6.1__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.
- deepcell_cli/__init__.py +12 -0
- deepcell_cli/__main__.py +5 -0
- deepcell_cli/_findings.py +84 -0
- deepcell_cli/capabilities.py +560 -0
- deepcell_cli/capability-contract.json +15622 -0
- deepcell_cli/client.py +503 -0
- deepcell_cli/commands/__init__.py +1 -0
- deepcell_cli/commands/_batch_input.py +29 -0
- deepcell_cli/commands/_datatypes.py +56 -0
- deepcell_cli/commands/_negative_args.py +133 -0
- deepcell_cli/commands/_swapped_args.py +153 -0
- deepcell_cli/commands/_version_display.py +40 -0
- deepcell_cli/commands/_write_opts.py +139 -0
- deepcell_cli/commands/account.py +123 -0
- deepcell_cli/commands/auth.py +610 -0
- deepcell_cli/commands/changes.py +307 -0
- deepcell_cli/commands/deck.py +594 -0
- deepcell_cli/commands/defs.py +3890 -0
- deepcell_cli/commands/describe.py +902 -0
- deepcell_cli/commands/doc.py +529 -0
- deepcell_cli/commands/doctor.py +257 -0
- deepcell_cli/commands/download.py +36 -0
- deepcell_cli/commands/edit.py +384 -0
- deepcell_cli/commands/example.py +161 -0
- deepcell_cli/commands/export.py +81 -0
- deepcell_cli/commands/export_docx.py +57 -0
- deepcell_cli/commands/export_pdf.py +66 -0
- deepcell_cli/commands/export_pptx.py +45 -0
- deepcell_cli/commands/files.py +386 -0
- deepcell_cli/commands/grep.py +90 -0
- deepcell_cli/commands/guide.py +431 -0
- deepcell_cli/commands/help_cmd.py +348 -0
- deepcell_cli/commands/impact.py +382 -0
- deepcell_cli/commands/import_cmd.py +208 -0
- deepcell_cli/commands/ingest.py +110 -0
- deepcell_cli/commands/merge.py +399 -0
- deepcell_cli/commands/query.py +718 -0
- deepcell_cli/commands/reasoning.py +2981 -0
- deepcell_cli/commands/ref.py +279 -0
- deepcell_cli/commands/replace.py +326 -0
- deepcell_cli/commands/rules.py +206 -0
- deepcell_cli/commands/share.py +186 -0
- deepcell_cli/commands/sync.py +804 -0
- deepcell_cli/commands/upgrade.py +185 -0
- deepcell_cli/commands/variant.py +353 -0
- deepcell_cli/commands/version.py +445 -0
- deepcell_cli/commands/viewer.py +54 -0
- deepcell_cli/commands/workspace.py +101 -0
- deepcell_cli/config.py +352 -0
- deepcell_cli/context.py +187 -0
- deepcell_cli/errors.py +141 -0
- deepcell_cli/logging_setup.py +161 -0
- deepcell_cli/main.py +518 -0
- deepcell_cli/mcp_server.py +906 -0
- deepcell_cli/oauth_provider.py +580 -0
- deepcell_cli/output.py +503 -0
- deepcell_cli/revision.py +164 -0
- deepcell_cli/stages.py +223 -0
- deepcell_cli/surface.py +628 -0
- deepcell_cli/sync_state.py +120 -0
- deepcell_cli/upgrade_check.py +399 -0
- deepcell_cli/xml_replace.py +89 -0
- deepcell_cli-0.6.1.dist-info/METADATA +264 -0
- deepcell_cli-0.6.1.dist-info/RECORD +67 -0
- deepcell_cli-0.6.1.dist-info/WHEEL +5 -0
- deepcell_cli-0.6.1.dist-info/entry_points.txt +3 -0
- deepcell_cli-0.6.1.dist-info/top_level.txt +1 -0
deepcell_cli/client.py
ADDED
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
"""HTTP client with automatic token refresh and error handling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import random
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from typing import Any, Callable
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from deepcell_cli.config import (
|
|
13
|
+
client_headers,
|
|
14
|
+
get_access_token,
|
|
15
|
+
get_api_url,
|
|
16
|
+
get_client_surface,
|
|
17
|
+
get_refresh_token,
|
|
18
|
+
load_credentials,
|
|
19
|
+
save_credentials,
|
|
20
|
+
warn_if_insecure,
|
|
21
|
+
)
|
|
22
|
+
from deepcell_cli.errors import APIError, AuthError, EmailVerificationRequired
|
|
23
|
+
from deepcell_cli.errors import ConnectionError as CLIConnectionError
|
|
24
|
+
|
|
25
|
+
_TIMEOUT = 30.0
|
|
26
|
+
_REFRESH_BUFFER = 60 # refresh token 60s before expiry
|
|
27
|
+
|
|
28
|
+
# ── Load-shed retry ─────────────────────────────────────────
|
|
29
|
+
# One `deepcell` invocation is one process and one blocking request, so N
|
|
30
|
+
# concurrent commands are N independent clients that coordinate through nothing.
|
|
31
|
+
# Whatever sheds them — nginx's per-IP limiters, the API's own rate limiter —
|
|
32
|
+
# rejects the overflow all at once, and without a retry here that arrives as N
|
|
33
|
+
# hard failures rather than as backpressure. The requests were never proxied to
|
|
34
|
+
# the app (limit_req/limit_conn reject before proxy_pass), so replaying them
|
|
35
|
+
# cannot double-apply anything.
|
|
36
|
+
_MAX_SHED_RETRIES = 4
|
|
37
|
+
_SHED_BACKOFF_BASE = 0.25
|
|
38
|
+
# Also the longest server-requested wait we will sit through. Past this a retry
|
|
39
|
+
# is worse than the error: the caller blocks with no output while the message we
|
|
40
|
+
# already have ("Try again in 47s") is the actionable answer.
|
|
41
|
+
_SHED_BACKOFF_CAP = 8.0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _is_shed(resp: httpx.Response) -> bool:
|
|
45
|
+
"""True when ``resp`` is load-shedding rather than an answer.
|
|
46
|
+
|
|
47
|
+
429 is unambiguous — both nginx's limiters (``limit_req_status`` /
|
|
48
|
+
``limit_conn_status`` in docker/nginx.conf) and the API's rate limiter mean
|
|
49
|
+
"come back later" by it.
|
|
50
|
+
|
|
51
|
+
503 is not. nginx sheds with 503 in older configurations and behind other
|
|
52
|
+
proxies, but the application also returns a genuine 503 that must reach the
|
|
53
|
+
caller unretried — ``POST /demo/session`` with anonymous sessions disabled
|
|
54
|
+
is the one the CLI hits routinely. The two are told apart by the body: a
|
|
55
|
+
FastAPI error carries JSON, an nginx shed page carries HTML.
|
|
56
|
+
"""
|
|
57
|
+
if resp.status_code == 429:
|
|
58
|
+
return True
|
|
59
|
+
if resp.status_code != 503:
|
|
60
|
+
return False
|
|
61
|
+
return "json" not in resp.headers.get("content-type", "").lower()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _shed_delay(resp: httpx.Response, attempt: int) -> float | None:
|
|
65
|
+
"""Seconds to wait before replaying a shed request, or None to give up.
|
|
66
|
+
|
|
67
|
+
Jitter is load-bearing, not a refinement. The failure this retry exists to
|
|
68
|
+
fix is N clients shed at the same instant; on a fixed backoff schedule they
|
|
69
|
+
wake at the same instant too and collide again, so a deterministic retry
|
|
70
|
+
mostly moves the pile-up one second later. Full jitter (uniform over the
|
|
71
|
+
whole window, not window ± a bit) is what actually spreads them.
|
|
72
|
+
"""
|
|
73
|
+
retry_after = resp.headers.get("Retry-After")
|
|
74
|
+
if retry_after:
|
|
75
|
+
try:
|
|
76
|
+
requested = float(retry_after)
|
|
77
|
+
except ValueError:
|
|
78
|
+
# The HTTP-date form. Rare from these servers, and parsing it buys
|
|
79
|
+
# nothing over the jittered backoff below.
|
|
80
|
+
requested = None
|
|
81
|
+
if requested is not None:
|
|
82
|
+
if requested > _SHED_BACKOFF_CAP:
|
|
83
|
+
return None
|
|
84
|
+
# Honor the server's floor, then jitter above it — an identical
|
|
85
|
+
# Retry-After handed to every client is itself a synchronizer.
|
|
86
|
+
return requested + random.uniform(0, _SHED_BACKOFF_BASE)
|
|
87
|
+
ceiling = min(_SHED_BACKOFF_CAP, _SHED_BACKOFF_BASE * (2**attempt))
|
|
88
|
+
return random.uniform(0, ceiling)
|
|
89
|
+
|
|
90
|
+
# Opt-out for the zero-login anonymous bootstrap (set to any non-empty value).
|
|
91
|
+
ANON_OPT_OUT_ENV = "DEEPCELL_NO_ANON"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class DeepCellClient:
|
|
95
|
+
"""Thin sync wrapper around httpx with auto-auth.
|
|
96
|
+
|
|
97
|
+
The client resolves the access token from:
|
|
98
|
+
1. ``DEEPCELL_ACCESS_TOKEN`` env var (highest priority)
|
|
99
|
+
2. ``~/.deepcell/credentials.json``
|
|
100
|
+
|
|
101
|
+
Token refresh is transparent — if the stored token is near expiry,
|
|
102
|
+
the client calls ``POST /auth/refresh`` automatically.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(self, *, verbose: bool = False, command: str | None = None) -> None:
|
|
106
|
+
self._verbose = verbose
|
|
107
|
+
self._command = command
|
|
108
|
+
self._operation_id = str(uuid.uuid4())
|
|
109
|
+
# Client-level headers, so the surface rides on EVERY request this
|
|
110
|
+
# object makes — including the anonymous mint and the token refresh,
|
|
111
|
+
# which build their own per-request headers and would otherwise be the
|
|
112
|
+
# two calls a new user makes before anything else. httpx merges these
|
|
113
|
+
# with per-request headers rather than replacing them.
|
|
114
|
+
self._http = httpx.Client(timeout=_TIMEOUT, headers=client_headers())
|
|
115
|
+
|
|
116
|
+
# ── Helpers ─────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
def _log(self, msg: str) -> None:
|
|
119
|
+
if self._verbose:
|
|
120
|
+
import sys
|
|
121
|
+
|
|
122
|
+
print(f"[debug] {msg}", file=sys.stderr)
|
|
123
|
+
|
|
124
|
+
def _mint_anon_session(self, device_id: str | None) -> str | None:
|
|
125
|
+
"""Mint (or re-mint) an anonymous demo session. Returns the token or None.
|
|
126
|
+
|
|
127
|
+
This is the CLI variant of ``POST /demo/session``: the identity travels
|
|
128
|
+
as ``device_id`` in the body (no cookie jar) and the server echoes the
|
|
129
|
+
UUID to persist. Failures (flag off → 503, rate-limited, offline) all
|
|
130
|
+
degrade to ``None`` so the caller falls back to the plain login error.
|
|
131
|
+
"""
|
|
132
|
+
import os
|
|
133
|
+
|
|
134
|
+
if os.environ.get(ANON_OPT_OUT_ENV):
|
|
135
|
+
return None
|
|
136
|
+
self._log("No credentials — requesting anonymous demo session…")
|
|
137
|
+
try:
|
|
138
|
+
resp = self._http.post(
|
|
139
|
+
f"{get_api_url()}/demo/session",
|
|
140
|
+
# The real surface, not a hardcoded "cli": the MCP server runs
|
|
141
|
+
# this code in-process, and the server reads this field both to
|
|
142
|
+
# pick the cookie-free identity flow AND (historically) to tag
|
|
143
|
+
# the session. Sending "cli" from MCP recorded the funnel's
|
|
144
|
+
# stage-1 entry under the wrong client.
|
|
145
|
+
json={"client": get_client_surface(), "device_id": device_id},
|
|
146
|
+
)
|
|
147
|
+
except Exception as e:
|
|
148
|
+
self._log(f"Anonymous session mint failed: {e}")
|
|
149
|
+
return None
|
|
150
|
+
if resp.status_code != 200:
|
|
151
|
+
self._log(f"Anonymous session mint failed ({resp.status_code})")
|
|
152
|
+
return None
|
|
153
|
+
data = resp.json()
|
|
154
|
+
save_credentials(
|
|
155
|
+
{
|
|
156
|
+
"access_token": data["access_token"],
|
|
157
|
+
"expires_at": time.time() + data["expires_in"],
|
|
158
|
+
"anonymous": True,
|
|
159
|
+
"device_id": data.get("device_id") or device_id,
|
|
160
|
+
}
|
|
161
|
+
)
|
|
162
|
+
return data["access_token"]
|
|
163
|
+
|
|
164
|
+
def _maybe_refresh(self) -> None:
|
|
165
|
+
"""Refresh access token if it's about to expire."""
|
|
166
|
+
import os
|
|
167
|
+
|
|
168
|
+
# Skip if token comes from env var (user manages it)
|
|
169
|
+
if os.environ.get("DEEPCELL_ACCESS_TOKEN"):
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
creds = load_credentials()
|
|
173
|
+
expires_at = creds.get("expires_at", 0)
|
|
174
|
+
if time.time() < expires_at - _REFRESH_BUFFER:
|
|
175
|
+
return # still valid
|
|
176
|
+
|
|
177
|
+
if creds.get("anonymous"):
|
|
178
|
+
# Anonymous sessions have no refresh token — re-mint with the
|
|
179
|
+
# stored device identity instead.
|
|
180
|
+
self._mint_anon_session(creds.get("device_id"))
|
|
181
|
+
return
|
|
182
|
+
|
|
183
|
+
refresh_tok = creds.get("refresh_token")
|
|
184
|
+
if not refresh_tok:
|
|
185
|
+
return # nothing to refresh with
|
|
186
|
+
|
|
187
|
+
self._log("Access token near expiry — refreshing…")
|
|
188
|
+
try:
|
|
189
|
+
resp = self._http.post(
|
|
190
|
+
f"{get_api_url()}/auth/refresh",
|
|
191
|
+
json={"refresh_token": refresh_tok},
|
|
192
|
+
)
|
|
193
|
+
if resp.status_code == 200:
|
|
194
|
+
data = resp.json()
|
|
195
|
+
updated = {
|
|
196
|
+
**creds,
|
|
197
|
+
"access_token": data["access_token"],
|
|
198
|
+
"expires_at": time.time() + data["expires_in"],
|
|
199
|
+
}
|
|
200
|
+
# Rotate refresh token when the server provides a new one
|
|
201
|
+
if data.get("refresh_token"):
|
|
202
|
+
updated["refresh_token"] = data["refresh_token"]
|
|
203
|
+
save_credentials(updated)
|
|
204
|
+
self._log("Token refreshed successfully")
|
|
205
|
+
else:
|
|
206
|
+
self._log(f"Token refresh failed ({resp.status_code})")
|
|
207
|
+
except Exception as e:
|
|
208
|
+
self._log(f"Token refresh error: {e}")
|
|
209
|
+
|
|
210
|
+
def _headers(self) -> dict[str, str]:
|
|
211
|
+
self._maybe_refresh()
|
|
212
|
+
token = get_access_token()
|
|
213
|
+
if not token:
|
|
214
|
+
# Zero-login path: bootstrap an anonymous demo session so a new
|
|
215
|
+
# user can try the product before registering. Falls back to the
|
|
216
|
+
# login hint when the server has anonymous sessions disabled, the
|
|
217
|
+
# user opted out (DEEPCELL_NO_ANON), or the mint fails.
|
|
218
|
+
token = self._mint_anon_session(load_credentials().get("device_id"))
|
|
219
|
+
if not token:
|
|
220
|
+
raise AuthError()
|
|
221
|
+
warn_if_insecure(get_api_url())
|
|
222
|
+
from deepcell_cli import __version__
|
|
223
|
+
|
|
224
|
+
headers = {
|
|
225
|
+
"Authorization": f"Bearer {token}",
|
|
226
|
+
# `X-DeepCell-Client` is NOT set here. It is a client-level header
|
|
227
|
+
# (see __init__) carrying `<surface>/<version>`, so it rides on the
|
|
228
|
+
# unauthenticated calls too — and, unlike a literal "cli", it says
|
|
229
|
+
# `mcp` when the MCP server is the caller. Setting it per-request
|
|
230
|
+
# would override that and silently re-tag every MCP write.
|
|
231
|
+
"X-DeepCell-Client-Version": __version__,
|
|
232
|
+
"X-DeepCell-Operation-Id": self._operation_id,
|
|
233
|
+
}
|
|
234
|
+
if self._command:
|
|
235
|
+
headers["X-DeepCell-Command"] = self._command
|
|
236
|
+
return headers
|
|
237
|
+
|
|
238
|
+
@staticmethod
|
|
239
|
+
def _format_detail(detail: Any) -> str:
|
|
240
|
+
"""Render FastAPI's error ``detail`` for a human/agent reader.
|
|
241
|
+
|
|
242
|
+
A 422 ``detail`` is a LIST OF DICTS (pydantic errors). ``str()`` on it
|
|
243
|
+
produces a raw Python repr — the surface an agent sees for every
|
|
244
|
+
constraint the CLI does not mirror (op-count caps, length limits,
|
|
245
|
+
numeric ranges), with no indication of which field or rule failed.
|
|
246
|
+
"""
|
|
247
|
+
if not isinstance(detail, list):
|
|
248
|
+
return str(detail)
|
|
249
|
+
lines: list[str] = []
|
|
250
|
+
for item in detail:
|
|
251
|
+
if not isinstance(item, dict):
|
|
252
|
+
lines.append(f" - {item}")
|
|
253
|
+
continue
|
|
254
|
+
loc = item.get("loc") or []
|
|
255
|
+
# Drop the leading "body"/"query" frame — it is noise to the caller.
|
|
256
|
+
parts = [str(p) for p in loc if str(p) not in ("body", "query", "path")]
|
|
257
|
+
field = ".".join(parts) if parts else "request"
|
|
258
|
+
msg = item.get("msg") or item.get("type") or "invalid"
|
|
259
|
+
lines.append(f" - {field}: {msg}")
|
|
260
|
+
if not lines:
|
|
261
|
+
return str(detail)
|
|
262
|
+
return "request validation failed:\n" + "\n".join(lines)
|
|
263
|
+
|
|
264
|
+
def _handle_response(self, resp: httpx.Response, *, auth: bool = True) -> httpx.Response:
|
|
265
|
+
if resp.status_code >= 400:
|
|
266
|
+
try:
|
|
267
|
+
body = resp.json()
|
|
268
|
+
detail = body.get("detail", resp.text)
|
|
269
|
+
except Exception:
|
|
270
|
+
detail = resp.text
|
|
271
|
+
detail_str = self._format_detail(detail)
|
|
272
|
+
if resp.status_code == 403 and "email verification required" in detail_str.lower():
|
|
273
|
+
raise EmailVerificationRequired(detail_str)
|
|
274
|
+
payload = detail if isinstance(detail, (dict, list)) else None
|
|
275
|
+
headers = dict(resp.headers)
|
|
276
|
+
if resp.status_code in (401, 403):
|
|
277
|
+
from deepcell_cli.config import is_anonymous_session
|
|
278
|
+
from deepcell_cli.errors import AccountRequiredError, SessionExpiredError
|
|
279
|
+
|
|
280
|
+
if is_anonymous_session():
|
|
281
|
+
# Anonymous sessions hitting a full-account surface get
|
|
282
|
+
# the upgrade path, not a dead-end permission error.
|
|
283
|
+
raise AccountRequiredError(resp.status_code, detail_str, payload=payload)
|
|
284
|
+
if resp.status_code == 401 and auth:
|
|
285
|
+
# A 401 only gets here after _force_refresh has already had
|
|
286
|
+
# its turn, so the credentials are genuinely dead. 403 is
|
|
287
|
+
# left alone: that is a real permissions answer from a
|
|
288
|
+
# session that worked.
|
|
289
|
+
import os
|
|
290
|
+
|
|
291
|
+
from deepcell_cli.config import ENV_ACCESS_TOKEN
|
|
292
|
+
|
|
293
|
+
raise SessionExpiredError(
|
|
294
|
+
resp.status_code,
|
|
295
|
+
detail_str,
|
|
296
|
+
payload=payload,
|
|
297
|
+
from_env=bool(os.environ.get(ENV_ACCESS_TOKEN)),
|
|
298
|
+
)
|
|
299
|
+
raise APIError(
|
|
300
|
+
resp.status_code, detail_str, payload=payload, headers=headers
|
|
301
|
+
)
|
|
302
|
+
return resp
|
|
303
|
+
|
|
304
|
+
def _force_refresh(self) -> bool:
|
|
305
|
+
"""Force a token refresh. Returns True if successful."""
|
|
306
|
+
import os
|
|
307
|
+
|
|
308
|
+
if os.environ.get("DEEPCELL_ACCESS_TOKEN"):
|
|
309
|
+
return False
|
|
310
|
+
|
|
311
|
+
creds = load_credentials()
|
|
312
|
+
if creds.get("anonymous"):
|
|
313
|
+
self._log("401 received — re-minting anonymous session…")
|
|
314
|
+
return self._mint_anon_session(creds.get("device_id")) is not None
|
|
315
|
+
|
|
316
|
+
refresh_tok = creds.get("refresh_token")
|
|
317
|
+
if not refresh_tok:
|
|
318
|
+
return False
|
|
319
|
+
|
|
320
|
+
self._log("401 received — forcing token refresh…")
|
|
321
|
+
try:
|
|
322
|
+
resp = self._http.post(
|
|
323
|
+
f"{get_api_url()}/auth/refresh",
|
|
324
|
+
json={"refresh_token": refresh_tok},
|
|
325
|
+
)
|
|
326
|
+
if resp.status_code == 200:
|
|
327
|
+
data = resp.json()
|
|
328
|
+
updated = {
|
|
329
|
+
**creds,
|
|
330
|
+
"access_token": data["access_token"],
|
|
331
|
+
"expires_at": time.time() + data["expires_in"],
|
|
332
|
+
}
|
|
333
|
+
if data.get("refresh_token"):
|
|
334
|
+
updated["refresh_token"] = data["refresh_token"]
|
|
335
|
+
save_credentials(updated)
|
|
336
|
+
self._log("Token refreshed successfully (retry)")
|
|
337
|
+
return True
|
|
338
|
+
self._log(f"Token refresh failed ({resp.status_code})")
|
|
339
|
+
except Exception as e:
|
|
340
|
+
self._log(f"Token refresh error: {e}")
|
|
341
|
+
return False
|
|
342
|
+
|
|
343
|
+
def _retry_on_shed(self, send: Callable[[], httpx.Response]) -> httpx.Response:
|
|
344
|
+
"""Replay ``send`` while the server is shedding load.
|
|
345
|
+
|
|
346
|
+
Returns the last response either way — a shed that outlives the retry
|
|
347
|
+
budget is still handed to :meth:`_handle_response`, so the caller sees
|
|
348
|
+
the server's own message rather than a substitute invented here.
|
|
349
|
+
|
|
350
|
+
Connection errors are deliberately not retried. ``send`` raises
|
|
351
|
+
:class:`CLIConnectionError` for those, and "the API is unreachable" is
|
|
352
|
+
a different problem with a different fix than "the API is busy".
|
|
353
|
+
"""
|
|
354
|
+
for attempt in range(_MAX_SHED_RETRIES + 1):
|
|
355
|
+
resp = send()
|
|
356
|
+
if attempt == _MAX_SHED_RETRIES or not _is_shed(resp):
|
|
357
|
+
return resp
|
|
358
|
+
delay = _shed_delay(resp, attempt)
|
|
359
|
+
if delay is None:
|
|
360
|
+
return resp
|
|
361
|
+
self._log(
|
|
362
|
+
f"Server shed the request ({resp.status_code}) — "
|
|
363
|
+
f"retrying in {delay:.2f}s (attempt {attempt + 1}/{_MAX_SHED_RETRIES})"
|
|
364
|
+
)
|
|
365
|
+
time.sleep(delay)
|
|
366
|
+
return resp # pragma: no cover — the loop always returns
|
|
367
|
+
|
|
368
|
+
def _request(
|
|
369
|
+
self,
|
|
370
|
+
method: str,
|
|
371
|
+
path: str,
|
|
372
|
+
*,
|
|
373
|
+
params: dict | None = None,
|
|
374
|
+
json: Any = None,
|
|
375
|
+
auth: bool = True,
|
|
376
|
+
timeout: float | None = None,
|
|
377
|
+
) -> httpx.Response:
|
|
378
|
+
"""Send a request with automatic retry on 401 after token refresh.
|
|
379
|
+
|
|
380
|
+
``timeout`` overrides the client default for long-running calls
|
|
381
|
+
(e.g. exports that recalculate and render server-side).
|
|
382
|
+
|
|
383
|
+
Load-shed responses are retried with jittered backoff underneath the
|
|
384
|
+
401 handling, so a refreshed token gets the same backpressure treatment
|
|
385
|
+
as the first attempt.
|
|
386
|
+
"""
|
|
387
|
+
headers = self._headers() if auth else {}
|
|
388
|
+
url = get_api_url()
|
|
389
|
+
request_timeout = timeout if timeout is not None else httpx.USE_CLIENT_DEFAULT
|
|
390
|
+
|
|
391
|
+
def send() -> httpx.Response:
|
|
392
|
+
self._log(f"{method} {url}{path}")
|
|
393
|
+
try:
|
|
394
|
+
return self._http.request(
|
|
395
|
+
method, f"{url}{path}", headers=headers, params=params, json=json,
|
|
396
|
+
timeout=request_timeout,
|
|
397
|
+
)
|
|
398
|
+
except (httpx.ConnectError, httpx.TimeoutException, httpx.NetworkError) as exc:
|
|
399
|
+
raise CLIConnectionError(url, exc) from exc
|
|
400
|
+
|
|
401
|
+
resp = self._retry_on_shed(send)
|
|
402
|
+
|
|
403
|
+
# On 401, try refreshing the token and retry once
|
|
404
|
+
if resp.status_code == 401 and auth and self._force_refresh():
|
|
405
|
+
headers = self._headers()
|
|
406
|
+
resp = self._retry_on_shed(send)
|
|
407
|
+
|
|
408
|
+
return self._handle_response(resp, auth=auth)
|
|
409
|
+
|
|
410
|
+
# ── Public API ──────────────────────────────────────────
|
|
411
|
+
|
|
412
|
+
def get(self, path: str, *, params: dict | None = None, auth: bool = True) -> Any:
|
|
413
|
+
"""Send GET request and return parsed JSON."""
|
|
414
|
+
return self._request("GET", path, params=params, auth=auth).json()
|
|
415
|
+
|
|
416
|
+
def get_with_revision(
|
|
417
|
+
self, path: str, *, params: dict | None = None, auth: bool = True
|
|
418
|
+
) -> tuple[Any, str | None]:
|
|
419
|
+
"""GET a workspace file and return ``(json, revision)``.
|
|
420
|
+
|
|
421
|
+
The revision travels as the ``X-DeepCell-Revision`` response header, not
|
|
422
|
+
in the body, so a plain :meth:`get` throws it away. Any command that
|
|
423
|
+
does fetch → transform → write back needs it: without it the write is
|
|
424
|
+
unguarded and silently clobbers whatever landed in between (#1122).
|
|
425
|
+
``None`` when the server did not report one — callers then write
|
|
426
|
+
unguarded, exactly as before.
|
|
427
|
+
"""
|
|
428
|
+
resp = self._request("GET", path, params=params, auth=auth)
|
|
429
|
+
return resp.json(), resp.headers.get("X-DeepCell-Revision")
|
|
430
|
+
|
|
431
|
+
def post(self, path: str, *, json: Any = None, auth: bool = True) -> Any:
|
|
432
|
+
"""Send POST request and return parsed JSON."""
|
|
433
|
+
return self._request("POST", path, json=json, auth=auth).json()
|
|
434
|
+
|
|
435
|
+
def post_raw(
|
|
436
|
+
self, path: str, *, json: Any = None, auth: bool = True,
|
|
437
|
+
timeout: float | None = None,
|
|
438
|
+
) -> httpx.Response:
|
|
439
|
+
"""Send POST and return the raw httpx.Response (for binary)."""
|
|
440
|
+
return self._request("POST", path, json=json, auth=auth, timeout=timeout)
|
|
441
|
+
|
|
442
|
+
def put(self, path: str, *, json: Any = None, auth: bool = True) -> Any:
|
|
443
|
+
return self._request("PUT", path, json=json, auth=auth).json()
|
|
444
|
+
|
|
445
|
+
def delete(
|
|
446
|
+
self, path: str, *, json: Any = None, auth: bool = True
|
|
447
|
+
) -> httpx.Response:
|
|
448
|
+
"""Send DELETE and return the raw response.
|
|
449
|
+
|
|
450
|
+
``json`` carries a body — unusual for DELETE, but ``/auth/me`` requires
|
|
451
|
+
typed confirmation and those fields must not travel in a query string
|
|
452
|
+
(access logs, shell history).
|
|
453
|
+
"""
|
|
454
|
+
return self._request("DELETE", path, json=json, auth=auth)
|
|
455
|
+
|
|
456
|
+
def post_multipart(
|
|
457
|
+
self,
|
|
458
|
+
path: str,
|
|
459
|
+
*,
|
|
460
|
+
file_path: str | None = None,
|
|
461
|
+
file_data: bytes | None = None,
|
|
462
|
+
file_name: str = "upload",
|
|
463
|
+
fields: dict[str, str] | None = None,
|
|
464
|
+
auth: bool = True,
|
|
465
|
+
) -> Any:
|
|
466
|
+
"""Send a multipart/form-data POST with a file and form fields."""
|
|
467
|
+
headers = self._headers() if auth else {}
|
|
468
|
+
url = get_api_url()
|
|
469
|
+
self._log(f"POST (multipart) {url}{path}")
|
|
470
|
+
|
|
471
|
+
if file_data is None and file_path is not None:
|
|
472
|
+
import pathlib
|
|
473
|
+
file_data = pathlib.Path(file_path).read_bytes()
|
|
474
|
+
if not file_name or file_name == "upload":
|
|
475
|
+
file_name = pathlib.Path(file_path).name
|
|
476
|
+
|
|
477
|
+
files = {"file": (file_name, file_data, "application/octet-stream")}
|
|
478
|
+
data = fields or {}
|
|
479
|
+
|
|
480
|
+
# Safe to replay: the payload is already in memory as bytes, so a retry
|
|
481
|
+
# re-reads nothing from disk and cannot send a half-changed file.
|
|
482
|
+
def send() -> httpx.Response:
|
|
483
|
+
try:
|
|
484
|
+
return self._http.post(
|
|
485
|
+
f"{url}{path}", headers=headers, files=files, data=data,
|
|
486
|
+
)
|
|
487
|
+
except (httpx.ConnectError, httpx.TimeoutException, httpx.NetworkError) as exc:
|
|
488
|
+
raise CLIConnectionError(url, exc) from exc
|
|
489
|
+
|
|
490
|
+
resp = self._retry_on_shed(send)
|
|
491
|
+
|
|
492
|
+
if resp.status_code == 401 and auth and self._force_refresh():
|
|
493
|
+
headers = self._headers()
|
|
494
|
+
resp = self._retry_on_shed(send)
|
|
495
|
+
|
|
496
|
+
return self._handle_response(resp, auth=auth).json()
|
|
497
|
+
|
|
498
|
+
def post_no_content(self, path: str, *, json: Any = None, auth: bool = True) -> None:
|
|
499
|
+
"""POST expecting 204 No Content."""
|
|
500
|
+
self._request("POST", path, json=json, auth=auth)
|
|
501
|
+
|
|
502
|
+
def close(self) -> None:
|
|
503
|
+
self._http.close()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI command modules."""
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Shared ``--batch`` payload reader.
|
|
2
|
+
|
|
3
|
+
Lives in its own module because both ``edit`` and ``replace`` take a
|
|
4
|
+
``--batch`` argument in the same three forms, and ``edit`` imports ``replace``
|
|
5
|
+
for the deprecated ``edit --replace`` alias — a helper owned by either one
|
|
6
|
+
would close the import cycle.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def read_batch_payload(batch_arg: str) -> str:
|
|
15
|
+
"""Return the raw JSON text for a ``--batch`` argument.
|
|
16
|
+
|
|
17
|
+
Accepts three forms:
|
|
18
|
+
- inline JSON — the value starts with ``[`` or ``{`` (agents / MCP,
|
|
19
|
+
where a local file path or stdin is unavailable);
|
|
20
|
+
- ``-`` — read from stdin;
|
|
21
|
+
- anything else — a local JSON file path.
|
|
22
|
+
"""
|
|
23
|
+
stripped = batch_arg.strip()
|
|
24
|
+
if stripped.startswith(("[", "{")):
|
|
25
|
+
return stripped
|
|
26
|
+
if batch_arg == "-":
|
|
27
|
+
return sys.stdin.read()
|
|
28
|
+
with open(batch_arg) as fh:
|
|
29
|
+
return fh.read()
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Shared `--data-type` recognition for the CLI.
|
|
2
|
+
|
|
3
|
+
The defs item ops store data types verbatim, so a typo such as
|
|
4
|
+
`--data-type moneytary` would otherwise make downstream formatters fall back
|
|
5
|
+
to their default without a word.
|
|
6
|
+
|
|
7
|
+
The vocabulary is not formally closed (documents legitimately carry `text`,
|
|
8
|
+
`date`, and other author-chosen types), so an unrecognized value is a
|
|
9
|
+
**warning**, not a rejection — the same call the file validator makes for
|
|
10
|
+
undeclared custom dimensions.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
# The types that actually drive rendering. A local copy on purpose: this
|
|
16
|
+
# warning fires before the request goes out, so it cannot ask the server. The
|
|
17
|
+
# authored answer — what each type MEANS and what it drives — lives in
|
|
18
|
+
# `backend/src/core/ref/namespaces/datatypes.py` and is read with
|
|
19
|
+
# `deepcell ref datatype`; keep the two in step, and put any explanation
|
|
20
|
+
# there rather than here.
|
|
21
|
+
RECOGNIZED_DATA_TYPES = frozenset({
|
|
22
|
+
"monetary",
|
|
23
|
+
"percentage",
|
|
24
|
+
"ratio",
|
|
25
|
+
"quantity",
|
|
26
|
+
"count",
|
|
27
|
+
"number",
|
|
28
|
+
"text",
|
|
29
|
+
"date",
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def warn_unrecognized_data_type(data_type: str | None) -> str | None:
|
|
34
|
+
"""Return a warning message for an unrecognized *data_type*, else None."""
|
|
35
|
+
if not data_type:
|
|
36
|
+
return None
|
|
37
|
+
if data_type.lower() in RECOGNIZED_DATA_TYPES:
|
|
38
|
+
return None
|
|
39
|
+
known = ", ".join(sorted(RECOGNIZED_DATA_TYPES))
|
|
40
|
+
suggestion = ""
|
|
41
|
+
try:
|
|
42
|
+
import difflib
|
|
43
|
+
|
|
44
|
+
close = difflib.get_close_matches(
|
|
45
|
+
data_type.lower(), sorted(RECOGNIZED_DATA_TYPES), n=1, cutoff=0.6
|
|
46
|
+
)
|
|
47
|
+
if close:
|
|
48
|
+
suggestion = f" Did you mean '{close[0]}'?"
|
|
49
|
+
except Exception: # pragma: no cover - difflib is stdlib
|
|
50
|
+
pass
|
|
51
|
+
return (
|
|
52
|
+
f"'{data_type}' is not a recognized data type.{suggestion} "
|
|
53
|
+
f"It will be stored verbatim, but number formatting will fall back to "
|
|
54
|
+
f"the default. Recognized: {known}. "
|
|
55
|
+
f"What each one means: deepcell ref datatype"
|
|
56
|
+
)
|