dexverse-cli 0.1.4__tar.gz → 0.1.4.post1__tar.gz

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 (42) hide show
  1. {dexverse_cli-0.1.4 → dexverse_cli-0.1.4.post1}/PKG-INFO +10 -3
  2. {dexverse_cli-0.1.4 → dexverse_cli-0.1.4.post1}/src/dexverse_cli/README.md +9 -2
  3. dexverse_cli-0.1.4.post1/src/dexverse_cli/__init__.py +1 -0
  4. {dexverse_cli-0.1.4 → dexverse_cli-0.1.4.post1}/src/dexverse_cli/update/gate.py +34 -3
  5. dexverse_cli-0.1.4.post1/src/dexverse_cli/update/helper.py +56 -0
  6. {dexverse_cli-0.1.4 → dexverse_cli-0.1.4.post1}/src/dexverse_cli/update/upgrade.py +151 -4
  7. dexverse_cli-0.1.4/src/dexverse_cli/__init__.py +0 -1
  8. {dexverse_cli-0.1.4 → dexverse_cli-0.1.4.post1}/.gitignore +0 -0
  9. {dexverse_cli-0.1.4 → dexverse_cli-0.1.4.post1}/.python-version +0 -0
  10. {dexverse_cli-0.1.4 → dexverse_cli-0.1.4.post1}/pyproject.toml +0 -0
  11. {dexverse_cli-0.1.4 → dexverse_cli-0.1.4.post1}/src/dexverse_cli/cli.py +0 -0
  12. {dexverse_cli-0.1.4 → dexverse_cli-0.1.4.post1}/src/dexverse_cli/common/__init__.py +0 -0
  13. {dexverse_cli-0.1.4 → dexverse_cli-0.1.4.post1}/src/dexverse_cli/common/hashes.py +0 -0
  14. {dexverse_cli-0.1.4 → dexverse_cli-0.1.4.post1}/src/dexverse_cli/common/state.py +0 -0
  15. {dexverse_cli-0.1.4 → dexverse_cli-0.1.4.post1}/src/dexverse_cli/download/__init__.py +0 -0
  16. {dexverse_cli-0.1.4 → dexverse_cli-0.1.4.post1}/src/dexverse_cli/download/batch.py +0 -0
  17. {dexverse_cli-0.1.4 → dexverse_cli-0.1.4.post1}/src/dexverse_cli/download/filesystem.py +0 -0
  18. {dexverse_cli-0.1.4 → dexverse_cli-0.1.4.post1}/src/dexverse_cli/download/http.py +0 -0
  19. {dexverse_cli-0.1.4 → dexverse_cli-0.1.4.post1}/src/dexverse_cli/download/manifest.py +0 -0
  20. {dexverse_cli-0.1.4 → dexverse_cli-0.1.4.post1}/src/dexverse_cli/download/runner.py +0 -0
  21. {dexverse_cli-0.1.4 → dexverse_cli-0.1.4.post1}/src/dexverse_cli/download/transfer.py +0 -0
  22. {dexverse_cli-0.1.4 → dexverse_cli-0.1.4.post1}/src/dexverse_cli/progress/__init__.py +0 -0
  23. {dexverse_cli-0.1.4 → dexverse_cli-0.1.4.post1}/src/dexverse_cli/progress/bar.py +0 -0
  24. {dexverse_cli-0.1.4 → dexverse_cli-0.1.4.post1}/src/dexverse_cli/progress/formatting.py +0 -0
  25. {dexverse_cli-0.1.4 → dexverse_cli-0.1.4.post1}/src/dexverse_cli/progress/reporter.py +0 -0
  26. {dexverse_cli-0.1.4 → dexverse_cli-0.1.4.post1}/src/dexverse_cli/progress/view.py +0 -0
  27. {dexverse_cli-0.1.4 → dexverse_cli-0.1.4.post1}/src/dexverse_cli/restore/__init__.py +0 -0
  28. {dexverse_cli-0.1.4 → dexverse_cli-0.1.4.post1}/src/dexverse_cli/restore/alignment.py +0 -0
  29. {dexverse_cli-0.1.4 → dexverse_cli-0.1.4.post1}/src/dexverse_cli/restore/batch.py +0 -0
  30. {dexverse_cli-0.1.4 → dexverse_cli-0.1.4.post1}/src/dexverse_cli/restore/instruction.py +0 -0
  31. {dexverse_cli-0.1.4 → dexverse_cli-0.1.4.post1}/src/dexverse_cli/restore/layout.py +0 -0
  32. {dexverse_cli-0.1.4 → dexverse_cli-0.1.4.post1}/src/dexverse_cli/restore/legacy.py +0 -0
  33. {dexverse_cli-0.1.4 → dexverse_cli-0.1.4.post1}/src/dexverse_cli/restore/media.py +0 -0
  34. {dexverse_cli-0.1.4 → dexverse_cli-0.1.4.post1}/src/dexverse_cli/restore/publish.py +0 -0
  35. {dexverse_cli-0.1.4 → dexverse_cli-0.1.4.post1}/src/dexverse_cli/restore/runner.py +0 -0
  36. {dexverse_cli-0.1.4 → dexverse_cli-0.1.4.post1}/src/dexverse_cli/restore/schema.py +0 -0
  37. {dexverse_cli-0.1.4 → dexverse_cli-0.1.4.post1}/src/dexverse_cli/restore/session.py +0 -0
  38. {dexverse_cli-0.1.4 → dexverse_cli-0.1.4.post1}/src/dexverse_cli/restore/versioned.py +0 -0
  39. {dexverse_cli-0.1.4 → dexverse_cli-0.1.4.post1}/src/dexverse_cli/update/__init__.py +0 -0
  40. {dexverse_cli-0.1.4 → dexverse_cli-0.1.4.post1}/src/dexverse_cli/update/check.py +0 -0
  41. {dexverse_cli-0.1.4 → dexverse_cli-0.1.4.post1}/src/dexverse_cli/update/observation.py +0 -0
  42. {dexverse_cli-0.1.4 → dexverse_cli-0.1.4.post1}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: dexverse-cli
3
- Version: 0.1.4
3
+ Version: 0.1.4.post1
4
4
  Summary: Download DexVerse dataset manifests and unpack HDF5 sessions
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: av<17,>=14
@@ -39,8 +39,15 @@ falls back to the source this machine's pip is configured with (`pip.conf`,
39
39
  `python -m pip install --upgrade dexverse-cli` from the source that answered,
40
40
  and then exits with status **20**. Run the same command again; the rerun uses
41
41
  the new version.
42
- - The check times out, pip is missing, no source answers, or the upgrade fails:
43
- one line is printed and the requested command still runs.
42
+ - The check times out, pip is missing, no source answers, the environment is not
43
+ writable (a system Python), or the upgrade fails: one line is printed and the
44
+ requested command still runs. A directory pip cannot write is refused before
45
+ pip starts, so a failed upgrade never leaves a half-installed package behind.
46
+ - On Windows, when the command was started from the installed `dexverse-dl.exe`,
47
+ the upgrade runs in the background: pip cannot replace that file while it is
48
+ the running program, so the CLI starts a helper that waits for this process to
49
+ exit and then upgrades. Run the command again shortly. Linux and macOS upgrade
50
+ in place, before the command continues.
44
51
 
45
52
  The switch is on by default. Turn the check off for one command, or permanently:
46
53
 
@@ -24,8 +24,15 @@ falls back to the source this machine's pip is configured with (`pip.conf`,
24
24
  `python -m pip install --upgrade dexverse-cli` from the source that answered,
25
25
  and then exits with status **20**. Run the same command again; the rerun uses
26
26
  the new version.
27
- - The check times out, pip is missing, no source answers, or the upgrade fails:
28
- one line is printed and the requested command still runs.
27
+ - The check times out, pip is missing, no source answers, the environment is not
28
+ writable (a system Python), or the upgrade fails: one line is printed and the
29
+ requested command still runs. A directory pip cannot write is refused before
30
+ pip starts, so a failed upgrade never leaves a half-installed package behind.
31
+ - On Windows, when the command was started from the installed `dexverse-dl.exe`,
32
+ the upgrade runs in the background: pip cannot replace that file while it is
33
+ the running program, so the CLI starts a helper that waits for this process to
34
+ exit and then upgrades. Run the command again shortly. Linux and macOS upgrade
35
+ in place, before the command continues.
29
36
 
30
37
  The switch is on by default. Turn the check off for one command, or permanently:
31
38
 
@@ -0,0 +1 @@
1
+ __version__ = "0.1.4.post1"
@@ -55,18 +55,46 @@ def upgrade_failure_message(result: upgrade.UpgradeOutcome) -> str:
55
55
  "`python -m pip install --force-reinstall dexverse-cli`. Continuing with the requested command."
56
56
  )
57
57
  return (
58
- f"Automatic upgrade to {result.version} failed ({result.status}: {result.detail}); "
59
- "run `python -m pip install --upgrade dexverse-cli` yourself if needed. "
58
+ f"Automatic upgrade to {result.version} failed ({result.detail}); "
59
+ "if the next command reports a missing dexverse_cli module, repair it with "
60
+ "`python -m pip install --force-reinstall --upgrade dexverse-cli`. "
60
61
  "Continuing with the requested command."
61
62
  )
62
63
 
63
64
 
65
+ def background_upgrade(observed: check.CheckOutcome) -> None:
66
+ """@brief Upgrade from a detached helper when this process locks its own launcher.
67
+ @param observed Release observation that selected the target version.
68
+ @throws SystemExit With 20 once the helper owns the upgrade.
69
+ """
70
+ if upgrade.upgrade_pending():
71
+ print("An upgrade is already running in the background; run the command again shortly.", file=sys.stderr)
72
+ raise SystemExit(RERUN_EXIT_CODE)
73
+ target = str(observed.latest)
74
+ with contextlib.suppress(OSError, ValueError):
75
+ upgrade.mark_pending(target)
76
+ if upgrade.start_detached(target, check.prerelease_allowed(), observed.source):
77
+ print(
78
+ f"dexverse-cli {target} is available; upgrading in the background because the running launcher "
79
+ "cannot be replaced while it runs. Run the command again shortly.",
80
+ file=sys.stderr,
81
+ )
82
+ raise SystemExit(RERUN_EXIT_CODE)
83
+ upgrade.clear_pending()
84
+ print(
85
+ "Automatic upgrade needs this launcher to exit first; run "
86
+ "`python -m pip install --upgrade dexverse-cli` yourself. Continuing with the requested command.",
87
+ file=sys.stderr,
88
+ )
89
+
90
+
64
91
  def update_gate(*, check_enabled: bool = True, index_url: str | None = None) -> None:
65
92
  """@brief Run before any business import, stdin read, or file access.
66
93
  @param check_enabled False when the caller passed --no-update-check.
67
94
  @param index_url Optional release index for this command; publishing it as the
68
95
  environment variable keeps the check and the upgrade on one source.
69
- @throws SystemExit With 20 only after a verified in-place upgrade.
96
+ @throws SystemExit With 20 after a verified upgrade, or once a background
97
+ upgrade owns the environment.
70
98
  """
71
99
  if not check_enabled:
72
100
  return
@@ -74,6 +102,9 @@ def update_gate(*, check_enabled: bool = True, index_url: str | None = None) ->
74
102
  os.environ[check.INDEX_ENVIRONMENT] = index_url
75
103
  observed = check.check_for_update()
76
104
  if observed.status == check.STATUS_OUTDATED:
105
+ if upgrade.console_script_locked():
106
+ background_upgrade(observed)
107
+ return
77
108
  print(
78
109
  f"dexverse-cli {observed.latest} is available (running {observed.installed}); upgrading...",
79
110
  file=sys.stderr,
@@ -0,0 +1,56 @@
1
+ """Background upgrade helper: replaces the package once the caller has exited."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import sys
7
+ import time
8
+ from datetime import datetime, timezone
9
+ from uuid import uuid4
10
+
11
+ from dexverse_cli import __version__
12
+ from dexverse_cli.update import check, observation, upgrade
13
+
14
+ STARTUP_GRACE_S = 3.0
15
+
16
+
17
+ def record(outcome: upgrade.UpgradeOutcome, target: str) -> None:
18
+ """@brief Persist what the background upgrade actually did.
19
+ @param outcome Result of the retrying upgrade.
20
+ @param target Version the helper was asked to install.
21
+ """
22
+ succeeded = outcome.status == upgrade.STATUS_UPGRADED
23
+ operation = observation.UpdateOperation(
24
+ str(uuid4()),
25
+ "succeeded" if succeeded else "failed",
26
+ "upgraded" if succeeded else "upgrading",
27
+ __version__,
28
+ target,
29
+ datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
30
+ "" if succeeded else outcome.status,
31
+ )
32
+ with contextlib.suppress(OSError, ValueError):
33
+ observation.record_operation(check.install_mode(), __version__, operation)
34
+
35
+
36
+ def main(arguments: list[str] | None = None) -> int:
37
+ """@brief Upgrade this environment after the caller released its launcher.
38
+ @param arguments Optional argument vector for tests; defaults to the command line.
39
+ @return Zero when an upgrade was verified, otherwise one.
40
+ """
41
+ values = sys.argv[1:] if arguments is None else arguments
42
+ if len(values) < 2:
43
+ return 2
44
+ version, flag = values[0], values[1]
45
+ source = tuple(values[2:])
46
+ # The caller must be gone before pip may delete its launcher, and everything
47
+ # this process needs is already imported, so the sleep is safe here.
48
+ time.sleep(STARTUP_GRACE_S)
49
+ outcome = upgrade.retry_upgrade(version, flag == "1", source=source)
50
+ upgrade.clear_pending()
51
+ record(outcome, version)
52
+ return 0 if outcome.status == upgrade.STATUS_UPGRADED else 1
53
+
54
+
55
+ if __name__ == "__main__":
56
+ raise SystemExit(main())
@@ -7,16 +7,28 @@ import importlib.metadata
7
7
  import os
8
8
  import subprocess
9
9
  import sys
10
+ import tempfile
11
+ import time
10
12
  from collections.abc import Callable, Iterator, Mapping, Sequence
11
13
  from dataclasses import dataclass
12
14
  from pathlib import Path
13
15
 
16
+ import dexverse_cli
14
17
  from dexverse_cli.update.check import PACKAGE, source_arguments
15
18
  from dexverse_cli.update.observation import update_state_path
16
19
 
17
20
  UPGRADE_TIMEOUT_S = 300.0
18
21
  LOCK_NAME = "upgrade.lock"
19
- MAX_DETAIL_CHARS = 200
22
+ PENDING_NAME = "upgrade.pending.json"
23
+ PENDING_TTL_S = 300.0
24
+ RETRY_ATTEMPTS = 10
25
+ RETRY_DELAY_S = 2.0
26
+ MAX_DETAIL_CHARS = 320
27
+ DETAIL_TAIL_LINES = 3
28
+
29
+ # Failure reasons that may only mean the launcher was still running, so one more
30
+ # attempt after the caller exits can still succeed.
31
+ LOCK_REASONS = ("denied", "permission", "--user", "did not change")
20
32
 
21
33
  STATUS_UPGRADED = "upgraded"
22
34
  STATUS_BUSY = "busy"
@@ -161,15 +173,42 @@ def installed_version() -> str | None:
161
173
 
162
174
 
163
175
  def failure_detail(completed: subprocess.CompletedProcess[str]) -> str:
164
- """@brief Summarize pip output into one bounded line for the user.
176
+ """@brief Summarize pip output into one bounded line that names the real blocker.
165
177
  @param completed Finished pip process.
166
- @return Last non-empty output line, truncated.
178
+ @return Tail of pip's output, truncated; pip puts its reason before its advice.
167
179
  """
168
180
  text = (completed.stderr or "").strip() or (completed.stdout or "").strip()
169
181
  lines = [line.strip() for line in text.splitlines() if line.strip()]
170
182
  if not lines:
171
183
  return f"pip exited with {completed.returncode}"
172
- return lines[-1][:MAX_DETAIL_CHARS]
184
+ # pip ends a failure with generic advice ("Consider using the --user option"),
185
+ # so keep the lines before it: they name the file or the reason.
186
+ return " | ".join(lines[-DETAIL_TAIL_LINES:])[:MAX_DETAIL_CHARS]
187
+
188
+
189
+ def install_target() -> Path | None:
190
+ """@brief Locate the directory pip has to write to replace this package.
191
+ @return Package parent directory, or None when it cannot be determined.
192
+ """
193
+ module = getattr(dexverse_cli, "__file__", None)
194
+ if not module:
195
+ return None
196
+ return Path(module).resolve().parent.parent
197
+
198
+
199
+ def writable_target() -> bool:
200
+ """@brief Prove pip can write there before it is allowed to touch anything.
201
+ @return True when a probe file could be created and removed.
202
+ """
203
+ target = install_target()
204
+ if target is None or not target.is_dir():
205
+ return False
206
+ try:
207
+ with tempfile.NamedTemporaryFile(dir=target, prefix=".dexverse-write-probe-", delete=True):
208
+ pass
209
+ except OSError:
210
+ return False
211
+ return True
173
212
 
174
213
 
175
214
  def run_upgrade(
@@ -188,6 +227,14 @@ def run_upgrade(
188
227
  with update_lock() as owned:
189
228
  if not owned:
190
229
  return UpgradeOutcome(STATUS_BUSY, version, "another dexverse-cli process is upgrading")
230
+ # A pip run that cannot finish still removes the old files first, which
231
+ # leaves a half-installed package behind. Refuse it up front instead.
232
+ if not writable_target():
233
+ return UpgradeOutcome(
234
+ STATUS_UNAVAILABLE,
235
+ version,
236
+ "installation directory is not writable; run `python -m pip install --user --upgrade dexverse-cli`",
237
+ )
191
238
  try:
192
239
  completed = (runner or run_pip)(upgrade_arguments(prerelease, source), UPGRADE_TIMEOUT_S)
193
240
  except subprocess.TimeoutExpired:
@@ -201,3 +248,103 @@ def run_upgrade(
201
248
  if installed_version() != version:
202
249
  return UpgradeOutcome(STATUS_FAILED, version, "installed version did not change")
203
250
  return UpgradeOutcome(STATUS_UPGRADED, version)
251
+
252
+
253
+ def console_script_locked(platform: str | None = None, launched: str | None = None) -> bool:
254
+ """@brief Detect the Windows case where pip cannot replace the running launcher.
255
+ @param platform Optional platform name for deterministic tests.
256
+ @param launched Optional launched path for deterministic tests.
257
+ @return True when this process is its own environment's console script.
258
+ """
259
+ if (platform or os.name) != "nt":
260
+ return False
261
+ executable = Path(launched if launched is not None else (sys.argv[0] or "")).resolve()
262
+ if executable.suffix.lower() != ".exe":
263
+ return False
264
+ return executable.parent == (Path(sys.prefix) / "Scripts").resolve()
265
+
266
+
267
+ def pending_path() -> Path:
268
+ """@brief Resolve the marker that fences a background upgrade.
269
+ @return Absolute marker path.
270
+ @throws ValueError When the configured state directory is not absolute.
271
+ """
272
+ return update_state_path().with_name(PENDING_NAME)
273
+
274
+
275
+ def upgrade_pending() -> bool:
276
+ """@brief Tell whether a background upgrade is still expected to settle.
277
+ @return True while a fresh marker exists.
278
+ """
279
+ try:
280
+ path = pending_path()
281
+ return time.time() - path.stat().st_mtime < PENDING_TTL_S
282
+ except (OSError, ValueError):
283
+ return False
284
+
285
+
286
+ def mark_pending(target: str) -> None:
287
+ """@brief Publish the marker before a background helper is started.
288
+ @param target Version the helper is expected to install.
289
+ @throws OSError When the marker cannot be written.
290
+ """
291
+ path = pending_path()
292
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
293
+ path.write_text(target, encoding="utf-8")
294
+
295
+
296
+ def clear_pending() -> None:
297
+ """@brief Remove the marker once a background upgrade settled."""
298
+ with contextlib.suppress(OSError, ValueError):
299
+ pending_path().unlink(missing_ok=True)
300
+
301
+
302
+ def start_detached(version: str, prerelease: bool, source: Sequence[str] | None = None) -> bool:
303
+ """@brief Start the helper outside this process so the launcher is released.
304
+ @param version Version the helper must install.
305
+ @param prerelease Whether pre-releases are allowed.
306
+ @param source Pip source arguments answered by the check.
307
+ @return True when the helper process was started.
308
+ """
309
+ command = [sys.executable, "-m", "dexverse_cli.update.helper", version, "1" if prerelease else "0"]
310
+ command += list(source or ())
311
+ flags = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0
312
+ try:
313
+ subprocess.Popen(
314
+ command,
315
+ stdin=subprocess.DEVNULL,
316
+ stdout=subprocess.DEVNULL,
317
+ stderr=subprocess.DEVNULL,
318
+ creationflags=flags,
319
+ )
320
+ except OSError:
321
+ return False
322
+ return True
323
+
324
+
325
+ def retry_upgrade(
326
+ version: str,
327
+ prerelease: bool,
328
+ source: Sequence[str] | None = None,
329
+ runner: Runner | None = None,
330
+ attempts: int = RETRY_ATTEMPTS,
331
+ delay: float = RETRY_DELAY_S,
332
+ ) -> UpgradeOutcome:
333
+ """@brief Retry an upgrade that a still-exiting launcher blocked.
334
+ @param version Version to install.
335
+ @param prerelease Whether pre-releases are allowed.
336
+ @param source Pip source arguments answered by the check.
337
+ @param runner Optional process runner for deterministic tests.
338
+ @param attempts Maximum number of pip runs.
339
+ @param delay Seconds to wait between attempts.
340
+ @return The last outcome.
341
+ """
342
+ outcome = run_upgrade(version, prerelease, runner=runner, source=source)
343
+ for _ in range(max(attempts - 1, 0)):
344
+ if outcome.status == STATUS_UPGRADED:
345
+ return outcome
346
+ if not any(reason in outcome.detail.lower() for reason in LOCK_REASONS):
347
+ return outcome
348
+ time.sleep(delay)
349
+ outcome = run_upgrade(version, prerelease, runner=runner, source=source)
350
+ return outcome
@@ -1 +0,0 @@
1
- __version__ = "0.1.4"
File without changes