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
|
@@ -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)
|