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.
Files changed (67) hide show
  1. deepcell_cli/__init__.py +12 -0
  2. deepcell_cli/__main__.py +5 -0
  3. deepcell_cli/_findings.py +84 -0
  4. deepcell_cli/capabilities.py +560 -0
  5. deepcell_cli/capability-contract.json +15622 -0
  6. deepcell_cli/client.py +503 -0
  7. deepcell_cli/commands/__init__.py +1 -0
  8. deepcell_cli/commands/_batch_input.py +29 -0
  9. deepcell_cli/commands/_datatypes.py +56 -0
  10. deepcell_cli/commands/_negative_args.py +133 -0
  11. deepcell_cli/commands/_swapped_args.py +153 -0
  12. deepcell_cli/commands/_version_display.py +40 -0
  13. deepcell_cli/commands/_write_opts.py +139 -0
  14. deepcell_cli/commands/account.py +123 -0
  15. deepcell_cli/commands/auth.py +610 -0
  16. deepcell_cli/commands/changes.py +307 -0
  17. deepcell_cli/commands/deck.py +594 -0
  18. deepcell_cli/commands/defs.py +3890 -0
  19. deepcell_cli/commands/describe.py +902 -0
  20. deepcell_cli/commands/doc.py +529 -0
  21. deepcell_cli/commands/doctor.py +257 -0
  22. deepcell_cli/commands/download.py +36 -0
  23. deepcell_cli/commands/edit.py +384 -0
  24. deepcell_cli/commands/example.py +161 -0
  25. deepcell_cli/commands/export.py +81 -0
  26. deepcell_cli/commands/export_docx.py +57 -0
  27. deepcell_cli/commands/export_pdf.py +66 -0
  28. deepcell_cli/commands/export_pptx.py +45 -0
  29. deepcell_cli/commands/files.py +386 -0
  30. deepcell_cli/commands/grep.py +90 -0
  31. deepcell_cli/commands/guide.py +431 -0
  32. deepcell_cli/commands/help_cmd.py +348 -0
  33. deepcell_cli/commands/impact.py +382 -0
  34. deepcell_cli/commands/import_cmd.py +208 -0
  35. deepcell_cli/commands/ingest.py +110 -0
  36. deepcell_cli/commands/merge.py +399 -0
  37. deepcell_cli/commands/query.py +718 -0
  38. deepcell_cli/commands/reasoning.py +2981 -0
  39. deepcell_cli/commands/ref.py +279 -0
  40. deepcell_cli/commands/replace.py +326 -0
  41. deepcell_cli/commands/rules.py +206 -0
  42. deepcell_cli/commands/share.py +186 -0
  43. deepcell_cli/commands/sync.py +804 -0
  44. deepcell_cli/commands/upgrade.py +185 -0
  45. deepcell_cli/commands/variant.py +353 -0
  46. deepcell_cli/commands/version.py +445 -0
  47. deepcell_cli/commands/viewer.py +54 -0
  48. deepcell_cli/commands/workspace.py +101 -0
  49. deepcell_cli/config.py +352 -0
  50. deepcell_cli/context.py +187 -0
  51. deepcell_cli/errors.py +141 -0
  52. deepcell_cli/logging_setup.py +161 -0
  53. deepcell_cli/main.py +518 -0
  54. deepcell_cli/mcp_server.py +906 -0
  55. deepcell_cli/oauth_provider.py +580 -0
  56. deepcell_cli/output.py +503 -0
  57. deepcell_cli/revision.py +164 -0
  58. deepcell_cli/stages.py +223 -0
  59. deepcell_cli/surface.py +628 -0
  60. deepcell_cli/sync_state.py +120 -0
  61. deepcell_cli/upgrade_check.py +399 -0
  62. deepcell_cli/xml_replace.py +89 -0
  63. deepcell_cli-0.6.1.dist-info/METADATA +264 -0
  64. deepcell_cli-0.6.1.dist-info/RECORD +67 -0
  65. deepcell_cli-0.6.1.dist-info/WHEEL +5 -0
  66. deepcell_cli-0.6.1.dist-info/entry_points.txt +3 -0
  67. 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
+ )