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
@@ -0,0 +1,120 @@
1
+ """Sync metadata management for local <-> cloud workspace sync.
2
+
3
+ Tracks which workspace a local directory is linked to, the last synced
4
+ commit SHA, and per-file checksums so we can detect local vs remote changes.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ from collections.abc import Iterable
12
+ from dataclasses import asdict, dataclass, field
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ SYNC_DIR = ".deepcell"
17
+ SYNC_FILE = f"{SYNC_DIR}/sync.json"
18
+
19
+
20
+ @dataclass
21
+ class FileChecksum:
22
+ """Per-file sync state."""
23
+
24
+ git_sha: str # server blob SHA at last sync
25
+ local_hash: str # SHA-256 of content at last sync
26
+
27
+
28
+ @dataclass
29
+ class SyncState:
30
+ """Persisted sync metadata for a cloned workspace."""
31
+
32
+ workspace_slug: str
33
+ workspace_id: str
34
+ api_url: str
35
+ last_sync_sha: str = ""
36
+ file_checksums: dict[str, FileChecksum] = field(default_factory=dict)
37
+ active_variant: str = "" # name of the checked-out variant, or "" for main
38
+
39
+
40
+ def find_sync_root(start_path: Path | None = None) -> Path | None:
41
+ """Walk up from *start_path* (default CWD) looking for `.deepcell/sync.json`."""
42
+ p = (start_path or Path.cwd()).resolve()
43
+ while True:
44
+ if (p / SYNC_FILE).is_file():
45
+ return p
46
+ parent = p.parent
47
+ if parent == p:
48
+ return None
49
+ p = parent
50
+
51
+
52
+ def load_sync_state(root: Path) -> SyncState:
53
+ """Read and parse sync.json from *root*."""
54
+ data = json.loads((root / SYNC_FILE).read_text(encoding="utf-8"))
55
+ checksums = {
56
+ name: FileChecksum(**ck)
57
+ for name, ck in data.get("file_checksums", {}).items()
58
+ }
59
+ return SyncState(
60
+ workspace_slug=data["workspace_slug"],
61
+ workspace_id=data["workspace_id"],
62
+ api_url=data["api_url"],
63
+ last_sync_sha=data.get("last_sync_sha", ""),
64
+ file_checksums=checksums,
65
+ active_variant=data.get("active_variant", ""),
66
+ )
67
+
68
+
69
+ def save_sync_state(root: Path, state: SyncState) -> None:
70
+ """Write sync.json to *root*/.deepcell/."""
71
+ sync_dir = root / SYNC_DIR
72
+ sync_dir.mkdir(parents=True, exist_ok=True)
73
+ raw: dict[str, Any] = {
74
+ "workspace_slug": state.workspace_slug,
75
+ "workspace_id": state.workspace_id,
76
+ "api_url": state.api_url,
77
+ "last_sync_sha": state.last_sync_sha,
78
+ "active_variant": state.active_variant,
79
+ "file_checksums": {
80
+ name: asdict(ck) for name, ck in state.file_checksums.items()
81
+ },
82
+ }
83
+ (root / SYNC_FILE).write_text(
84
+ json.dumps(raw, indent=2) + "\n", encoding="utf-8"
85
+ )
86
+
87
+
88
+ def compute_local_hash(content: str) -> str:
89
+ """SHA-256 hex digest of *content*."""
90
+ return hashlib.sha256(content.encode("utf-8")).hexdigest()
91
+
92
+
93
+ def scan_local_files(
94
+ root: Path, tracked: Iterable[str] = ()
95
+ ) -> dict[str, str]:
96
+ """Return {filename: sha256} for all non-hidden files under *root*.
97
+
98
+ Excludes the `.deepcell/` metadata directory, any dotfiles/dotdirs, and
99
+ `<file>.local` conflict backups — those are CLI-managed artifacts written
100
+ by `pull`, and tracking them made `push` upload them as real workspace
101
+ files (visible in `ls`, the web file list, and every future clone).
102
+
103
+ *tracked* is the set of filenames the sync state already knows about
104
+ (i.e. that came from the server). A `.local` name in that set is a real
105
+ workspace file, not a backup, and must be scanned: excluding it made
106
+ `push` classify it as deleted-locally and delete it server-side. Conflict
107
+ backups are written after the scan and never enter the sync state, so
108
+ they are never in *tracked*.
109
+ """
110
+ tracked_names = set(tracked)
111
+ result: dict[str, str] = {}
112
+ for p in root.iterdir():
113
+ if p.name.startswith("."):
114
+ continue
115
+ if p.name.endswith(".local") and p.name not in tracked_names:
116
+ continue
117
+ if p.is_file():
118
+ content = p.read_text(encoding="utf-8")
119
+ result[p.name] = compute_local_hash(content)
120
+ return result
@@ -0,0 +1,399 @@
1
+ """Is a newer ``deepcell-cli`` published? — the automatic check behind the notice.
2
+
3
+ Reading the answer and *fetching* it are deliberately separated:
4
+
5
+ - every invocation reads a **cached** answer from disk (no network, no
6
+ latency) and, when that answer names a newer version, prints one notice to
7
+ **stderr** after the command's own output;
8
+ - when the cached answer is older than :data:`CHECK_INTERVAL_SECONDS`, a
9
+ daemon thread refreshes it for *next* time, and the invocation waits up to
10
+ :data:`REFRESH_JOIN_SECONDS` at close for that thread to land its write.
11
+
12
+ So the first run after a release is silent and the run after it warns. That
13
+ is the trade for never adding latency to a command and never failing one
14
+ because an index was unreachable — every path here swallows its own
15
+ exceptions, because a version notice that can break ``deepcell cat`` is worse
16
+ than no version notice.
17
+
18
+ That bounded wait is not a hedge, it is what makes the refresh real. A daemon
19
+ thread dies with the process, and a short command exits long before an HTTP
20
+ round-trip returns: measured against the live indexes, the fetch takes ~0.15s
21
+ while ``deepcell upgrade status`` and ``deepcell help`` finish in
22
+ milliseconds, so the cache was never written and *no* number of runs ever
23
+ warned. Only commands slow enough to outlive the fetch by luck
24
+ (``rules --all``, ``example list``) primed it. The wait costs nothing on those
25
+ — their thread has already finished, so the join returns at once — and it is
26
+ reached at most once per :data:`CHECK_INTERVAL_SECONDS`, because a fresh cache
27
+ is not stale and starts no thread at all. An index that hangs past the budget
28
+ is abandoned exactly as before: the cache goes unwritten, and the command is
29
+ not held up for it.
30
+
31
+ Nothing is ever installed. The notice prints the exact command to run, since
32
+ knowing a newer version exists is not the same as knowing how to get it — and
33
+ the command belongs to the index that carries the release, not to this module.
34
+ :data:`DEFAULT_INDEXES` names one index today, production PyPI, and the
35
+ highest-version-wins loop in :func:`fetch_latest` is what keeps that a data
36
+ change rather than a code change (``DEEPCELL_UPGRADE_INDEX_URL`` overrides it).
37
+
38
+ The notice goes to stderr, never stdout — ``deepcell cat`` emits raw
39
+ ``.deepcell`` XML, and a footer on stdout makes the captured document invalid
40
+ (issue #570, the same reason ``echo_version_history`` writes to stderr).
41
+
42
+ Off switches, in precedence order:
43
+
44
+ - ``DEEPCELL_NO_UPGRADE_CHECK=1`` — this invocation (and everything a hosted
45
+ process spawns), without writing to a config file it may share;
46
+ - ``deepcell upgrade disable`` — persistently, in ``config.json``.
47
+ """
48
+
49
+ from __future__ import annotations
50
+
51
+ import json
52
+ import os
53
+ import re
54
+ import threading
55
+ import time
56
+ from pathlib import Path
57
+ from typing import Any, NamedTuple
58
+
59
+ import click
60
+
61
+ from deepcell_cli import __version__
62
+ from deepcell_cli.config import load_config, save_config, save_state_file, state_file
63
+
64
+ PACKAGE_NAME = "deepcell-cli"
65
+
66
+ CONFIG_KEY = "auto_upgrade_check"
67
+ ENV_DISABLE = "DEEPCELL_NO_UPGRADE_CHECK"
68
+ ENV_INDEX = "DEEPCELL_UPGRADE_INDEX_URL"
69
+
70
+ STATE_FILENAME = "upgrade-check.json"
71
+ CHECK_INTERVAL_SECONDS = 24 * 60 * 60
72
+ FETCH_TIMEOUT_SECONDS = 3.0
73
+
74
+ #: How long an invocation will wait at close for a refresh it started to write
75
+ #: its answer. Deliberately far below the worst case of ``FETCH_TIMEOUT_SECONDS``
76
+ #: per index: the point is to cover the ordinary fetch (~0.15s against the live
77
+ #: indexes), not to make a command hostage to a stalled one.
78
+ REFRESH_JOIN_SECONDS = 1.0
79
+
80
+
81
+ class Index(NamedTuple):
82
+ """A package index to ask, and the command that installs from it."""
83
+
84
+ json_url: str
85
+ install_command: str
86
+
87
+
88
+ # One index: `deepcell-cli` ships to production PyPI, which carries its
89
+ # dependencies too, so the install line needs no index flags at all.
90
+ #
91
+ # `fetch_latest` still asks *every* index and takes the **highest** version
92
+ # rather than the first that answers, and that is deliberate with a tuple of
93
+ # one. The tuple is the seam a second index is added at — a mirror, a private
94
+ # index via `DEEPCELL_UPGRADE_INDEX_URL`, a pre-release channel — and
95
+ # first-answer-wins would then quietly stop noticing releases whenever the
96
+ # earlier index lagged the later one. Nothing would fail; the check would just
97
+ # say nothing forever, which is the failure mode this whole module is built to
98
+ # avoid. (It is also what the TestPyPI era needed, when production PyPI 404'd.)
99
+ DEFAULT_INDEXES = (
100
+ Index(
101
+ f"https://pypi.org/pypi/{PACKAGE_NAME}/json",
102
+ f"pip install --upgrade {PACKAGE_NAME}",
103
+ ),
104
+ )
105
+
106
+ # Daemon threads started by `start_background_refresh`, and the reason
107
+ # `join_background_refresh` can find them at close. They are still daemons, so
108
+ # an overrunning fetch never holds the interpreter open — the join, not the
109
+ # thread, is what bounds the wait. (`main._TRACKING_THREADS` keeps the same
110
+ # list for the same reason, minus the join: dropping an analytics POST costs
111
+ # nothing, whereas dropping this write costs the whole feature.) The test
112
+ # conftest also drains this list between tests.
113
+ _REFRESH_THREADS: list[threading.Thread] = []
114
+
115
+
116
+ # ── Version comparison (a subset of PEP 440) ────────────────
117
+
118
+ _VERSION_RE = re.compile(
119
+ r"^\s*v?(?P<release>\d+(?:\.\d+)*)"
120
+ r"(?:[-_.]?(?P<label>dev|alpha|beta|preview|pre|post|rev|rc|a|b|c|r)"
121
+ r"[-_.]?(?P<num>\d+)?)?"
122
+ r"(?:\+[0-9a-zA-Z.]+)?\s*$"
123
+ )
124
+
125
+ # dev < alpha < beta < rc < final < post. A bare release sorts at _FINAL_RANK,
126
+ # so 1.2.3 beats 1.2.3rc1 and loses to 1.2.3.post1.
127
+ _LABEL_RANK = {
128
+ "dev": 0,
129
+ "a": 1,
130
+ "alpha": 1,
131
+ "b": 2,
132
+ "beta": 2,
133
+ "c": 3,
134
+ "rc": 3,
135
+ "pre": 3,
136
+ "preview": 3,
137
+ "post": 5,
138
+ "rev": 5,
139
+ "r": 5,
140
+ }
141
+ _FINAL_RANK = 4
142
+
143
+
144
+ def parse_version(text: Any) -> tuple[tuple[int, ...], int, int] | None:
145
+ """Sort key for *text*, or ``None`` when it is not a version we understand.
146
+
147
+ A deliberate subset of PEP 440 — enough for ``1.2.3``, ``1.2.3rc1``,
148
+ ``1.2.3.post1`` and ``1.2.3.dev4``, which covers every shape this package
149
+ has published. Returning ``None`` rather than guessing is the point: an
150
+ unrecognised string must make the check say *nothing*, not invent an
151
+ ordering that tells a current install it is out of date.
152
+
153
+ Trailing zero segments are dropped so ``0.3`` and ``0.3.0`` compare equal.
154
+ """
155
+ if not isinstance(text, str):
156
+ return None
157
+ match = _VERSION_RE.match(text.lower())
158
+ if not match:
159
+ return None
160
+ release = tuple(int(part) for part in match.group("release").split("."))
161
+ while len(release) > 1 and release[-1] == 0:
162
+ release = release[:-1]
163
+ label = match.group("label") or ""
164
+ rank = _LABEL_RANK.get(label, _FINAL_RANK) if label else _FINAL_RANK
165
+ return release, rank, int(match.group("num") or 0)
166
+
167
+
168
+ def is_newer(candidate: Any, current: str = __version__) -> bool:
169
+ """True when *candidate* is a version strictly ahead of *current*."""
170
+ left, right = parse_version(candidate), parse_version(current)
171
+ if left is None or right is None:
172
+ return False
173
+ return left > right
174
+
175
+
176
+ # ── The setting ─────────────────────────────────────────────
177
+
178
+
179
+ def config_enabled() -> bool:
180
+ """The persisted setting. Absent means on — the check is opt-out."""
181
+ return bool(load_config().get(CONFIG_KEY, True))
182
+
183
+
184
+ def env_silenced() -> bool:
185
+ """True when this process was told to stay quiet regardless of config."""
186
+ return bool(os.environ.get(ENV_DISABLE, "").strip())
187
+
188
+
189
+ def is_enabled() -> bool:
190
+ """True when the automatic check may run in this process.
191
+
192
+ The env var wins over the config file so a CI job, an MCP host, or any
193
+ other shared process can silence the notice without writing to a config
194
+ file that belongs to someone else.
195
+ """
196
+ return not env_silenced() and config_enabled()
197
+
198
+
199
+ def set_enabled(enabled: bool) -> None:
200
+ """Persist the setting to ``config.json``."""
201
+ cfg = load_config()
202
+ cfg[CONFIG_KEY] = bool(enabled)
203
+ save_config(cfg)
204
+
205
+
206
+ # ── Cached answer ───────────────────────────────────────────
207
+
208
+
209
+ def state_path() -> Path:
210
+ return state_file(STATE_FILENAME)
211
+
212
+
213
+ def read_state() -> dict[str, Any]:
214
+ """The cached answer, or ``{}`` when there is none / it is unreadable."""
215
+ try:
216
+ data = json.loads(state_path().read_text(encoding="utf-8"))
217
+ except (OSError, ValueError):
218
+ return {}
219
+ return data if isinstance(data, dict) else {}
220
+
221
+
222
+ def is_stale(state: dict[str, Any] | None = None, now: float | None = None) -> bool:
223
+ """True when the cached answer is due for a refresh."""
224
+ state = read_state() if state is None else state
225
+ checked = state.get("checked_at")
226
+ if not isinstance(checked, (int, float)) or isinstance(checked, bool):
227
+ return True
228
+ age = (time.time() if now is None else now) - checked
229
+ # A negative age means the clock moved backwards, or the state file came
230
+ # from another machine. Refresh rather than trust it — the alternative is
231
+ # a cache that is never due again.
232
+ return not (0 <= age < CHECK_INTERVAL_SECONDS)
233
+
234
+
235
+ # ── Fetching ────────────────────────────────────────────────
236
+
237
+
238
+ def indexes() -> tuple[Index, ...]:
239
+ """The indexes to ask — ``DEEPCELL_UPGRADE_INDEX_URL`` overrides, comma-separated."""
240
+ raw = os.environ.get(ENV_INDEX, "").strip()
241
+ if not raw:
242
+ return DEFAULT_INDEXES
243
+ chosen: list[Index] = []
244
+ for url in (part.strip() for part in raw.split(",")):
245
+ if not url:
246
+ continue
247
+ known = next((i for i in DEFAULT_INDEXES if i.json_url == url), None)
248
+ chosen.append(known or Index(url, f"pip install --upgrade {PACKAGE_NAME}"))
249
+ return tuple(chosen) or DEFAULT_INDEXES
250
+
251
+
252
+ def fetch_latest(timeout: float = FETCH_TIMEOUT_SECONDS) -> tuple[str, str] | None:
253
+ """Ask every configured index and return the highest release found.
254
+
255
+ Returns ``(version, install_command)``, or ``None`` when no index answered
256
+ with a version we can parse. An index that errors, times out, or 404s is
257
+ skipped, not fatal — one dead index must not hide a live one.
258
+ """
259
+ import httpx
260
+
261
+ best: tuple[tuple[tuple[int, ...], int, int], str, str] | None = None
262
+ for index in indexes():
263
+ try:
264
+ response = httpx.get(
265
+ index.json_url,
266
+ timeout=timeout,
267
+ follow_redirects=True,
268
+ headers={"Accept": "application/json"},
269
+ )
270
+ if response.status_code != 200:
271
+ continue
272
+ payload = response.json()
273
+ version = payload.get("info", {}).get("version")
274
+ except Exception:
275
+ continue
276
+ key = parse_version(version)
277
+ if key is None:
278
+ continue
279
+ if best is None or key > best[0]:
280
+ best = (key, version, index.install_command)
281
+ return None if best is None else (best[1], best[2])
282
+
283
+
284
+ def refresh(timeout: float = FETCH_TIMEOUT_SECONDS) -> dict[str, Any]:
285
+ """Fetch, cache, and return the new state.
286
+
287
+ ``checked_at`` advances even when the fetch failed, so an offline machine
288
+ retries once a day instead of on every command. A previously-known
289
+ ``latest`` is kept on failure — a dropped network is no reason to forget
290
+ an upgrade that really is available.
291
+ """
292
+ result = fetch_latest(timeout)
293
+ state = read_state()
294
+ state["checked_at"] = time.time()
295
+ state["ok"] = result is not None
296
+ if result is not None:
297
+ state["latest"], state["install_command"] = result
298
+ try:
299
+ save_state_file(STATE_FILENAME, state)
300
+ except OSError:
301
+ pass
302
+ return state
303
+
304
+
305
+ def start_background_refresh() -> None:
306
+ """Refresh the cache in a daemon thread. Never blocks, never raises."""
307
+
308
+ def _run() -> None:
309
+ try:
310
+ refresh()
311
+ except Exception:
312
+ pass
313
+
314
+ thread = threading.Thread(target=_run, daemon=True, name="deepcell-upgrade-check")
315
+ _REFRESH_THREADS.append(thread)
316
+ thread.start()
317
+
318
+
319
+ def join_background_refresh(timeout: float = REFRESH_JOIN_SECONDS) -> None:
320
+ """Wait, briefly and at most once, for started refreshes to finish.
321
+
322
+ Without this the refresh is a write that never happens. ``_run`` is a
323
+ daemon, so the interpreter does not wait for it: a command that finishes
324
+ before the HTTP round-trip returns takes the thread down with it, mid
325
+ request, and ``save_state_file`` is never reached. Nothing fails and
326
+ nothing is logged — the cache simply stays empty, so the next run has no
327
+ answer to warn from either, and the notice never appears no matter how
328
+ many times the CLI is run.
329
+
330
+ *timeout* is a budget for the whole set, not per thread, so the delay a
331
+ command can inherit stays bounded however many refreshes were started.
332
+ Overrunning it is the pre-existing outcome, not a new failure: the write
333
+ is lost and the next invocation tries again.
334
+
335
+ Never raises — this runs from a Click close callback, outside
336
+ :func:`schedule`'s guard, and no upgrade check is worth failing a command.
337
+ """
338
+ try:
339
+ deadline = time.monotonic() + timeout
340
+ for thread in _REFRESH_THREADS:
341
+ remaining = deadline - time.monotonic()
342
+ if remaining <= 0:
343
+ return
344
+ thread.join(remaining)
345
+ except Exception:
346
+ return
347
+
348
+
349
+ # ── The notice ──────────────────────────────────────────────
350
+
351
+
352
+ def pending_notice(current: str = __version__) -> str | None:
353
+ """The stderr notice for the cached answer, or ``None`` when up to date."""
354
+ state = read_state()
355
+ latest = state.get("latest")
356
+ if not is_newer(latest, current):
357
+ return None
358
+ install = state.get("install_command") or f"pip install --upgrade {PACKAGE_NAME}"
359
+ return (
360
+ f"A newer deepcell is available: {current} -> {latest}\n"
361
+ f" {install}\n"
362
+ f" (silence this: deepcell upgrade disable)"
363
+ )
364
+
365
+
366
+ def schedule(ctx: click.Context) -> None:
367
+ """Arrange this invocation's notice and, if due, its background refresh.
368
+
369
+ Called from the root group callback, so it runs once per invocation before
370
+ any subcommand. The notice is deferred to context close so it lands *after*
371
+ the command's own output rather than in front of it.
372
+
373
+ Swallows everything: no upgrade check is worth failing a command over.
374
+ """
375
+ try:
376
+ if not is_enabled():
377
+ return
378
+ notice = pending_notice()
379
+ if notice:
380
+ # Re-read the setting at emit time, not just here: this callback is
381
+ # registered *before* the subcommand runs, so `deepcell upgrade
382
+ # disable` would otherwise print one last notice on its way out and
383
+ # read as "disable didn't work".
384
+ def _emit() -> None:
385
+ if is_enabled():
386
+ click.echo(notice, err=True)
387
+
388
+ ctx.call_on_close(_emit)
389
+ if is_stale():
390
+ start_background_refresh()
391
+ # Registered second, so Click's LIFO close order runs it before the
392
+ # notice above: the refresh gets the length of the command *plus*
393
+ # this budget to land, and the notice still reports the answer that
394
+ # was cached when the command started, which is the documented
395
+ # cadence — silent on the run that discovers a release, warning on
396
+ # the next one.
397
+ ctx.call_on_close(join_background_refresh)
398
+ except Exception:
399
+ return
@@ -0,0 +1,89 @@
1
+ """Pure string helpers for whitespace-tolerant XML replacement."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def find_whitespace_tolerant_match(
7
+ file_content: str,
8
+ old_string: str,
9
+ ) -> str | None:
10
+ """Find old_string in file_content using whitespace-tolerant matching.
11
+
12
+ Strips leading/trailing whitespace from each line and ignores blank lines
13
+ when comparing. Returns the actual text from the file (with original
14
+ indentation) if exactly one contiguous match is found, otherwise None.
15
+ """
16
+ file_content = file_content.replace("\r\n", "\n")
17
+ old_string = old_string.replace("\r\n", "\n")
18
+
19
+ file_lines = file_content.split("\n")
20
+ old_lines = old_string.split("\n")
21
+
22
+ # Build normalized (non-empty, stripped) lines with index mapping
23
+ norm_file: list[tuple[int, str]] = []
24
+ for i, line in enumerate(file_lines):
25
+ stripped = line.strip()
26
+ if stripped:
27
+ norm_file.append((i, stripped))
28
+
29
+ norm_old = [line.strip() for line in old_lines if line.strip()]
30
+
31
+ if not norm_old:
32
+ return None
33
+
34
+ # Slide normalized old_string across normalized file lines
35
+ matches: list[int] = []
36
+ for start in range(len(norm_file) - len(norm_old) + 1):
37
+ if all(
38
+ norm_file[start + j][1] == norm_old[j]
39
+ for j in range(len(norm_old))
40
+ ):
41
+ matches.append(start)
42
+
43
+ if len(matches) != 1:
44
+ return None
45
+
46
+ # Map back to original file line range
47
+ match_start = matches[0]
48
+ first_orig_line = norm_file[match_start][0]
49
+ last_orig_line = norm_file[match_start + len(norm_old) - 1][0]
50
+
51
+ return "\n".join(file_lines[first_orig_line : last_orig_line + 1])
52
+
53
+
54
+ def normalize_new_string_indentation(
55
+ actual_old: str,
56
+ original_old: str,
57
+ new_string: str,
58
+ ) -> str:
59
+ """Adjust new_string indentation to match the file's actual indentation.
60
+
61
+ Computes the indentation delta between actual_old (from file) and
62
+ original_old (from the caller) using the first non-empty line pair,
63
+ then applies that delta to every line of new_string.
64
+ """
65
+ actual_lines = actual_old.split("\n")
66
+ original_lines = original_old.split("\n")
67
+
68
+ # Find indentation delta from first non-empty line pair
69
+ delta = 0
70
+ for a_line, o_line in zip(actual_lines, original_lines):
71
+ if a_line.strip() and o_line.strip():
72
+ a_indent = len(a_line) - len(a_line.lstrip())
73
+ o_indent = len(o_line) - len(o_line.lstrip())
74
+ delta = a_indent - o_indent
75
+ break
76
+
77
+ if delta == 0:
78
+ return new_string
79
+
80
+ result_lines: list[str] = []
81
+ for line in new_string.split("\n"):
82
+ if not line.strip():
83
+ result_lines.append(line)
84
+ continue
85
+ current_indent = len(line) - len(line.lstrip())
86
+ new_indent = max(0, current_indent + delta)
87
+ result_lines.append(" " * new_indent + line.lstrip())
88
+
89
+ return "\n".join(result_lines)