scitex-storage 0.2.0__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.
- scitex_storage/__init__.py +123 -0
- scitex_storage/__main__.py +12 -0
- scitex_storage/_archive.py +341 -0
- scitex_storage/_cli/__init__.py +136 -0
- scitex_storage/_cli/_archive_cmd.py +188 -0
- scitex_storage/_cli/_compat.py +121 -0
- scitex_storage/_cli/_duplicates_cmd.py +60 -0
- scitex_storage/_cli/_images_cmd.py +90 -0
- scitex_storage/_cli/_introspect.py +59 -0
- scitex_storage/_cli/_mcp_commands.py +62 -0
- scitex_storage/_cli/_scan_cmd.py +110 -0
- scitex_storage/_cli/_sweep_cmd.py +142 -0
- scitex_storage/_duplicates.py +118 -0
- scitex_storage/_images.py +237 -0
- scitex_storage/_report.py +443 -0
- scitex_storage/_scan.py +324 -0
- scitex_storage/_sphinx_html/_modules/index.html +142 -0
- scitex_storage/_sphinx_html/_modules/scitex_storage/_scan.html +376 -0
- scitex_storage/_sphinx_html/_sources/api/scitex_storage.rst.txt +7 -0
- scitex_storage/_sphinx_html/_sources/index.rst.txt +29 -0
- scitex_storage/_sphinx_html/_sources/installation.rst.txt +23 -0
- scitex_storage/_sphinx_html/_sources/quickstart.rst.txt +7 -0
- scitex_storage/_sphinx_html/_static/_sphinx_javascript_frameworks_compat.js +123 -0
- scitex_storage/_sphinx_html/_static/base-stemmer.js +476 -0
- scitex_storage/_sphinx_html/_static/basic.css +906 -0
- scitex_storage/_sphinx_html/_static/check-solid.svg +4 -0
- scitex_storage/_sphinx_html/_static/clipboard.min.js +7 -0
- scitex_storage/_sphinx_html/_static/copy-button.svg +5 -0
- scitex_storage/_sphinx_html/_static/copybutton.css +94 -0
- scitex_storage/_sphinx_html/_static/copybutton.js +248 -0
- scitex_storage/_sphinx_html/_static/copybutton_funcs.js +73 -0
- scitex_storage/_sphinx_html/_static/css/badge_only.css +1 -0
- scitex_storage/_sphinx_html/_static/css/fonts/Roboto-Slab-Bold.woff +0 -0
- scitex_storage/_sphinx_html/_static/css/fonts/Roboto-Slab-Bold.woff2 +0 -0
- scitex_storage/_sphinx_html/_static/css/fonts/Roboto-Slab-Regular.woff +0 -0
- scitex_storage/_sphinx_html/_static/css/fonts/Roboto-Slab-Regular.woff2 +0 -0
- scitex_storage/_sphinx_html/_static/css/fonts/fontawesome-webfont.eot +0 -0
- scitex_storage/_sphinx_html/_static/css/fonts/fontawesome-webfont.svg +2671 -0
- scitex_storage/_sphinx_html/_static/css/fonts/fontawesome-webfont.ttf +0 -0
- scitex_storage/_sphinx_html/_static/css/fonts/fontawesome-webfont.woff +0 -0
- scitex_storage/_sphinx_html/_static/css/fonts/fontawesome-webfont.woff2 +0 -0
- scitex_storage/_sphinx_html/_static/css/fonts/lato-bold-italic.woff +0 -0
- scitex_storage/_sphinx_html/_static/css/fonts/lato-bold-italic.woff2 +0 -0
- scitex_storage/_sphinx_html/_static/css/fonts/lato-bold.woff +0 -0
- scitex_storage/_sphinx_html/_static/css/fonts/lato-bold.woff2 +0 -0
- scitex_storage/_sphinx_html/_static/css/fonts/lato-normal-italic.woff +0 -0
- scitex_storage/_sphinx_html/_static/css/fonts/lato-normal-italic.woff2 +0 -0
- scitex_storage/_sphinx_html/_static/css/fonts/lato-normal.woff +0 -0
- scitex_storage/_sphinx_html/_static/css/fonts/lato-normal.woff2 +0 -0
- scitex_storage/_sphinx_html/_static/css/theme.css +4 -0
- scitex_storage/_sphinx_html/_static/doctools.js +150 -0
- scitex_storage/_sphinx_html/_static/documentation_options.js +13 -0
- scitex_storage/_sphinx_html/_static/english-stemmer.js +1066 -0
- scitex_storage/_sphinx_html/_static/file.png +0 -0
- scitex_storage/_sphinx_html/_static/fonts/Lato/lato-bold.eot +0 -0
- scitex_storage/_sphinx_html/_static/fonts/Lato/lato-bold.ttf +0 -0
- scitex_storage/_sphinx_html/_static/fonts/Lato/lato-bold.woff +0 -0
- scitex_storage/_sphinx_html/_static/fonts/Lato/lato-bold.woff2 +0 -0
- scitex_storage/_sphinx_html/_static/fonts/Lato/lato-bolditalic.eot +0 -0
- scitex_storage/_sphinx_html/_static/fonts/Lato/lato-bolditalic.ttf +0 -0
- scitex_storage/_sphinx_html/_static/fonts/Lato/lato-bolditalic.woff +0 -0
- scitex_storage/_sphinx_html/_static/fonts/Lato/lato-bolditalic.woff2 +0 -0
- scitex_storage/_sphinx_html/_static/fonts/Lato/lato-italic.eot +0 -0
- scitex_storage/_sphinx_html/_static/fonts/Lato/lato-italic.ttf +0 -0
- scitex_storage/_sphinx_html/_static/fonts/Lato/lato-italic.woff +0 -0
- scitex_storage/_sphinx_html/_static/fonts/Lato/lato-italic.woff2 +0 -0
- scitex_storage/_sphinx_html/_static/fonts/Lato/lato-regular.eot +0 -0
- scitex_storage/_sphinx_html/_static/fonts/Lato/lato-regular.ttf +0 -0
- scitex_storage/_sphinx_html/_static/fonts/Lato/lato-regular.woff +0 -0
- scitex_storage/_sphinx_html/_static/fonts/Lato/lato-regular.woff2 +0 -0
- scitex_storage/_sphinx_html/_static/fonts/RobotoSlab/roboto-slab-v7-bold.eot +0 -0
- scitex_storage/_sphinx_html/_static/fonts/RobotoSlab/roboto-slab-v7-bold.ttf +0 -0
- scitex_storage/_sphinx_html/_static/fonts/RobotoSlab/roboto-slab-v7-bold.woff +0 -0
- scitex_storage/_sphinx_html/_static/fonts/RobotoSlab/roboto-slab-v7-bold.woff2 +0 -0
- scitex_storage/_sphinx_html/_static/fonts/RobotoSlab/roboto-slab-v7-regular.eot +0 -0
- scitex_storage/_sphinx_html/_static/fonts/RobotoSlab/roboto-slab-v7-regular.ttf +0 -0
- scitex_storage/_sphinx_html/_static/fonts/RobotoSlab/roboto-slab-v7-regular.woff +0 -0
- scitex_storage/_sphinx_html/_static/fonts/RobotoSlab/roboto-slab-v7-regular.woff2 +0 -0
- scitex_storage/_sphinx_html/_static/jquery.js +2 -0
- scitex_storage/_sphinx_html/_static/js/badge_only.js +1 -0
- scitex_storage/_sphinx_html/_static/js/theme.js +1 -0
- scitex_storage/_sphinx_html/_static/js/versions.js +228 -0
- scitex_storage/_sphinx_html/_static/language_data.js +13 -0
- scitex_storage/_sphinx_html/_static/minus.png +0 -0
- scitex_storage/_sphinx_html/_static/plus.png +0 -0
- scitex_storage/_sphinx_html/_static/pygments.css +75 -0
- scitex_storage/_sphinx_html/_static/searchtools.js +693 -0
- scitex_storage/_sphinx_html/_static/sphinx_highlight.js +159 -0
- scitex_storage/_sphinx_html/api/scitex_storage.html +314 -0
- scitex_storage/_sphinx_html/genindex.html +292 -0
- scitex_storage/_sphinx_html/index.html +185 -0
- scitex_storage/_sphinx_html/installation.html +170 -0
- scitex_storage/_sphinx_html/objects.inv +0 -0
- scitex_storage/_sphinx_html/py-modindex.html +164 -0
- scitex_storage/_sphinx_html/quickstart.html +203 -0
- scitex_storage/_sphinx_html/search.html +161 -0
- scitex_storage/_sphinx_html/searchindex.js +1 -0
- scitex_storage/_sweep.py +384 -0
- scitex_storage/_system_deps.py +91 -0
- scitex_storage-0.2.0.dist-info/METADATA +444 -0
- scitex_storage-0.2.0.dist-info/RECORD +104 -0
- scitex_storage-0.2.0.dist-info/WHEEL +4 -0
- scitex_storage-0.2.0.dist-info/entry_points.txt +11 -0
- scitex_storage-0.2.0.dist-info/licenses/LICENSE +660 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""scitex-storage: research-data storage triage.
|
|
4
|
+
|
|
5
|
+
Current scope (see README for the full roadmap): a read-only, stat-only
|
|
6
|
+
directory-tree ``scan`` (delegates its walk to ``fd``) that inventories
|
|
7
|
+
the biggest space and inode (file-count) consumers per top-level child of
|
|
8
|
+
a root; ``find_duplicates`` — an explicitly opt-in verb that DOES read
|
|
9
|
+
file contents (delegated to ``fclones``) to report exact-duplicate
|
|
10
|
+
groups; ``images prune`` — rotation for a directory of versioned files
|
|
11
|
+
(e.g. dated SIF builds) that never removes a file any symlink in the
|
|
12
|
+
directory currently references; ``sweep`` — tars an inode-hog directory
|
|
13
|
+
in place (compute-node-only, explicit per-directory confirm required);
|
|
14
|
+
and ``archive``/``restore`` — move-not-delete tiering to nas/nas2 over
|
|
15
|
+
ssh, verified before the local copy is removed, with a manifest
|
|
16
|
+
``restore`` reads back. Every mutating command defaults to a dry-run.
|
|
17
|
+
|
|
18
|
+
Public names — including ``__version__`` — are exposed via PEP 562
|
|
19
|
+
``__getattr__`` (lazy, on first access) so ``import scitex_storage`` itself
|
|
20
|
+
stays cheap — every CLI invocation and every shell tab-completion pays this
|
|
21
|
+
cost (see the python-api skill's "PEP 562 module __getattr__" section).
|
|
22
|
+
``__version__`` in particular defers even the ``import importlib.metadata``
|
|
23
|
+
statement itself, not just the ``version()`` call — that stdlib import
|
|
24
|
+
alone measured ~280ms in this environment (dist-metadata scanning cost,
|
|
25
|
+
not this package's own code), so doing it eagerly at module top-level
|
|
26
|
+
would defeat the whole point of the lazy design.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
# Public-name -> source-submodule map. ONE row per public symbol.
|
|
32
|
+
_LAZY_ATTRS: dict[str, str] = {
|
|
33
|
+
"ChildUsage": "_scan",
|
|
34
|
+
"MissingSystemDependencyError": "_scan",
|
|
35
|
+
"RootScan": "_scan",
|
|
36
|
+
"scan": "_scan",
|
|
37
|
+
"scan_roots": "_scan",
|
|
38
|
+
"find_duplicates": "_duplicates",
|
|
39
|
+
"ApplyResult": "_images",
|
|
40
|
+
"PruneCandidate": "_images",
|
|
41
|
+
"PrunePlan": "_images",
|
|
42
|
+
"SkippedInUse": "_images",
|
|
43
|
+
"apply_prune": "_images",
|
|
44
|
+
"plan_prune": "_images",
|
|
45
|
+
"SweepCandidate": "_sweep",
|
|
46
|
+
"SweepPlan": "_sweep",
|
|
47
|
+
"SweepResult": "_sweep",
|
|
48
|
+
"SweptCandidate": "_sweep",
|
|
49
|
+
"SweptEntry": "_sweep",
|
|
50
|
+
"apply_sweep": "_sweep",
|
|
51
|
+
"plan_sweep": "_sweep",
|
|
52
|
+
"sweep_status": "_sweep",
|
|
53
|
+
"ArchiveManifest": "_archive",
|
|
54
|
+
"ArchivePlan": "_archive",
|
|
55
|
+
"RestorePlan": "_archive",
|
|
56
|
+
"apply_archive": "_archive",
|
|
57
|
+
"apply_restore": "_archive",
|
|
58
|
+
"plan_archive": "_archive",
|
|
59
|
+
"plan_restore": "_archive",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def __getattr__(name: str):
|
|
64
|
+
"""PEP 562 lazy-loader: import on first access, cache, return."""
|
|
65
|
+
if name == "__version__":
|
|
66
|
+
from importlib.metadata import PackageNotFoundError
|
|
67
|
+
from importlib.metadata import version as _get_version
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
value = _get_version("scitex-storage")
|
|
71
|
+
except PackageNotFoundError: # source checkout without an installed dist
|
|
72
|
+
value = "0.0.0+local" # PEP 440 local version segment
|
|
73
|
+
globals()["__version__"] = value
|
|
74
|
+
return value
|
|
75
|
+
|
|
76
|
+
mod_name = _LAZY_ATTRS.get(name)
|
|
77
|
+
if mod_name is None:
|
|
78
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
79
|
+
from importlib import import_module
|
|
80
|
+
|
|
81
|
+
attr = getattr(import_module(f".{mod_name}", __name__), name)
|
|
82
|
+
globals()[name] = attr # cache; subsequent access skips this branch
|
|
83
|
+
return attr
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def __dir__() -> list[str]:
|
|
87
|
+
return sorted(set(_LAZY_ATTRS) | set(globals()))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# Literal (not computed) so PA-101's static AST check can see it. Keep in
|
|
91
|
+
# sync with the _LAZY_ATTRS keys above.
|
|
92
|
+
__all__ = [
|
|
93
|
+
"ChildUsage",
|
|
94
|
+
"MissingSystemDependencyError",
|
|
95
|
+
"RootScan",
|
|
96
|
+
"scan",
|
|
97
|
+
"scan_roots",
|
|
98
|
+
"find_duplicates",
|
|
99
|
+
"ApplyResult",
|
|
100
|
+
"PruneCandidate",
|
|
101
|
+
"PrunePlan",
|
|
102
|
+
"SkippedInUse",
|
|
103
|
+
"apply_prune",
|
|
104
|
+
"plan_prune",
|
|
105
|
+
"SweepCandidate",
|
|
106
|
+
"SweepPlan",
|
|
107
|
+
"SweepResult",
|
|
108
|
+
"SweptCandidate",
|
|
109
|
+
"SweptEntry",
|
|
110
|
+
"apply_sweep",
|
|
111
|
+
"plan_sweep",
|
|
112
|
+
"sweep_status",
|
|
113
|
+
"ArchiveManifest",
|
|
114
|
+
"ArchivePlan",
|
|
115
|
+
"RestorePlan",
|
|
116
|
+
"apply_archive",
|
|
117
|
+
"apply_restore",
|
|
118
|
+
"plan_archive",
|
|
119
|
+
"plan_restore",
|
|
120
|
+
"__version__",
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
# EOF
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Move-not-delete tiering: archive a directory to nas/nas2, with a manifest + restore.
|
|
4
|
+
|
|
5
|
+
The MVP verb behind ``scitex-storage archive`` / ``restore``. Built on
|
|
6
|
+
scitex-ssh's ``sync_dir`` rsync-over-ssh primitive (per scitex-ssh's own
|
|
7
|
+
design: it stays transport-only and policy-free — copy-verify-then-remove
|
|
8
|
+
tiering logic belongs here, not in the transport layer).
|
|
9
|
+
|
|
10
|
+
Design constraints:
|
|
11
|
+
|
|
12
|
+
* **Copy-verify-then-remove, never delete-then-copy.** :func:`apply_archive`
|
|
13
|
+
only removes the local source after ``sync_dir`` reports success; a
|
|
14
|
+
failed sync leaves the source completely untouched and raises loud.
|
|
15
|
+
* **The remote parent must exist before rsync does.** On a destination
|
|
16
|
+
that's never held archived data before, ``~/scitex-storage-archive/...``
|
|
17
|
+
simply doesn't exist yet — rsync then fails with "errors selecting
|
|
18
|
+
input/output files (code 3)". :func:`apply_archive` runs
|
|
19
|
+
``mkdir -p`` on the remote parent via ``exec_remote`` before ``sync_dir``.
|
|
20
|
+
Deliberately NOT ``rsync --mkpath`` (added in rsync 3.2.3; a real nas2
|
|
21
|
+
target still runs 3.0.7, where ``--mkpath`` is a hard unknown-option
|
|
22
|
+
error) — ``mkdir -p`` works on any rsync version. Found + verified by
|
|
23
|
+
scitex-ssh smoke-testing a real archive against real nas/nas2, not a
|
|
24
|
+
hypothetical (2026-07-11).
|
|
25
|
+
* **Never wrap a leading ``~`` in shell quotes.** Every remote path this
|
|
26
|
+
module builds a shell command from (the ``mkdir -p`` above, ``rm -rf``
|
|
27
|
+
in :func:`apply_restore`) goes through :func:`_quote_remote_path`, which
|
|
28
|
+
leaves a leading ``~/`` unquoted and only quotes the rest — a bare
|
|
29
|
+
``shlex.quote(path)`` turns ``~`` into a literal character (tilde-
|
|
30
|
+
expansion only applies to an unquoted leading ``~``), silently creating
|
|
31
|
+
a directory named ``~`` instead of resolving ``$HOME``. Also found by
|
|
32
|
+
scitex-ssh's real-nas2 smoke test: the stray ``~`` directory was sitting
|
|
33
|
+
in the remote home directory after the naive-quoting version "succeeded"
|
|
34
|
+
(2026-07-11).
|
|
35
|
+
* **Trailing slash controls "contents of" vs "the directory itself".**
|
|
36
|
+
rsync's classic gotcha: a source arg with NO trailing slash copies the
|
|
37
|
+
source directory itself into the destination (nesting one level:
|
|
38
|
+
``dest/<source-basename>/...``); WITH a trailing slash it copies the
|
|
39
|
+
source's *contents* directly into the destination. Both
|
|
40
|
+
:func:`apply_archive` (push) and :func:`apply_restore` (pull) want
|
|
41
|
+
"contents of," so the copy SIDE (``local`` for push, ``remote_path`` for
|
|
42
|
+
pull) always gets a trailing slash appended before being handed to
|
|
43
|
+
``sync_dir`` -- otherwise a restore lands two directories deep
|
|
44
|
+
(``source/<remote-basename>/<source-basename>/...``) with byte-correct
|
|
45
|
+
but wrongly-placed data, easy to miss if only checksums are checked.
|
|
46
|
+
scitex-ssh's own docstring is explicit that trailing-slash semantics are
|
|
47
|
+
the caller's to control; this is that layer. Found by scitex-ssh
|
|
48
|
+
smoke-testing a real archive+restore round-trip and diffing the actual
|
|
49
|
+
file paths, not just checksums (2026-07-11).
|
|
50
|
+
* **Manifest before delete.** The manifest is written *before* the local
|
|
51
|
+
source is removed, so a crash between "sync succeeded" and "manifest
|
|
52
|
+
written" fails safe (source still present; re-running `archive` is a
|
|
53
|
+
cheap no-op rsync).
|
|
54
|
+
* **Restore never destroys the archive by default.** :func:`apply_restore`
|
|
55
|
+
pulls the data back to its original local path; the remote copy is only
|
|
56
|
+
removed if the caller explicitly opts in via ``delete_remote=True``.
|
|
57
|
+
* **Aliases, not addresses.** ``destination`` is one of the ``~/.ssh/config``
|
|
58
|
+
aliases ``nas`` / ``nas2`` — scitex-ssh already handles mux discipline per
|
|
59
|
+
alias; this module never opens its own ssh connections.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
from __future__ import annotations
|
|
63
|
+
|
|
64
|
+
import json
|
|
65
|
+
import posixpath
|
|
66
|
+
import shlex
|
|
67
|
+
import shutil
|
|
68
|
+
import time
|
|
69
|
+
from dataclasses import asdict, dataclass
|
|
70
|
+
from pathlib import Path
|
|
71
|
+
|
|
72
|
+
from scitex_ssh import SSHResult, exec_remote, sync_dir
|
|
73
|
+
|
|
74
|
+
from ._scan import _measure_dir
|
|
75
|
+
|
|
76
|
+
DESTINATIONS: tuple[str, ...] = ("nas", "nas2")
|
|
77
|
+
DEFAULT_REMOTE_ROOT = "~/scitex-storage-archive"
|
|
78
|
+
|
|
79
|
+
# Remote paths this dangerous are never a legitimate archive/restore target
|
|
80
|
+
# -- a real path always has more structure than this after flattening a
|
|
81
|
+
# real local absolute path under DEFAULT_REMOTE_ROOT.
|
|
82
|
+
_UNSAFE_REMOTE_PATHS = {"", "/", "~", "."}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _quote_remote_path(path: str) -> str:
|
|
86
|
+
"""shlex.quote ``path`` for a remote shell without breaking a leading
|
|
87
|
+
``~`` home-dir shortcut.
|
|
88
|
+
|
|
89
|
+
Shell tilde-expansion only applies to an UNQUOTED (or quote-adjacent)
|
|
90
|
+
leading ``~`` -- naively wrapping the whole string in single quotes (a
|
|
91
|
+
bare ``shlex.quote(path)``) turns ``~`` into a literal character, so
|
|
92
|
+
``mkdir -p '~/scitex-storage-archive/x'`` creates a bogus directory
|
|
93
|
+
literally named ``~`` instead of expanding to ``$HOME``. Confirmed via
|
|
94
|
+
scitex-ssh smoke-testing a real ``mkdir`` on nas2 (2026-07-11) -- the
|
|
95
|
+
stray ``~`` dir was found sitting in the remote home directory.
|
|
96
|
+
"""
|
|
97
|
+
if path == "~":
|
|
98
|
+
return "~"
|
|
99
|
+
if path.startswith("~/"):
|
|
100
|
+
return "~/" + shlex.quote(path[2:])
|
|
101
|
+
return shlex.quote(path)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _as_dir_contents(path: str) -> str:
|
|
105
|
+
"""Append a trailing ``/`` (idempotent) so rsync copies ``path``'s
|
|
106
|
+
CONTENTS into the destination rather than nesting ``path`` itself one
|
|
107
|
+
level deeper as a subdirectory -- see the module docstring's trailing-
|
|
108
|
+
slash bullet."""
|
|
109
|
+
return path.rstrip("/") + "/"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _manifest_dir() -> Path:
|
|
113
|
+
"""Resolved fresh on every call (not a module-level constant) so tests
|
|
114
|
+
can sandbox it by setting ``$HOME`` before calling in — matching the
|
|
115
|
+
pattern already used for ``scan``'s default roots."""
|
|
116
|
+
return Path("~/.scitex/scitex-storage/runtime/archive-manifests").expanduser()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _default_remote_path(source: Path, remote_root: str = DEFAULT_REMOTE_ROOT) -> str:
|
|
120
|
+
"""Mirror ``source``'s absolute path under ``remote_root``, flattened."""
|
|
121
|
+
flattened = str(source).lstrip("/")
|
|
122
|
+
return f"{remote_root.rstrip('/')}/{flattened}"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _manifest_path(source: Path) -> Path:
|
|
126
|
+
"""Deterministic manifest filename for ``source`` (so restore can find it)."""
|
|
127
|
+
flattened = str(source).lstrip("/").replace("/", "--")
|
|
128
|
+
return _manifest_dir() / f"{flattened}.json"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass
|
|
132
|
+
class ArchivePlan:
|
|
133
|
+
"""The result of :func:`plan_archive` — never touches the network."""
|
|
134
|
+
|
|
135
|
+
source: Path
|
|
136
|
+
destination: str
|
|
137
|
+
remote_path: str
|
|
138
|
+
size_bytes: int
|
|
139
|
+
file_count: int
|
|
140
|
+
manifest_path: Path
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def plan_archive(
|
|
144
|
+
source: str | Path,
|
|
145
|
+
destination: str,
|
|
146
|
+
remote_path: str | None = None,
|
|
147
|
+
) -> ArchivePlan:
|
|
148
|
+
"""Compute (never execute) an archive plan for ``source`` -> ``destination``.
|
|
149
|
+
|
|
150
|
+
Read-only: stats ``source`` via the same walk :func:`~scitex_storage._scan.scan`
|
|
151
|
+
uses, never touches the network. Fail-loud on a missing / non-directory
|
|
152
|
+
``source`` or an unrecognised ``destination``.
|
|
153
|
+
"""
|
|
154
|
+
if destination not in DESTINATIONS:
|
|
155
|
+
raise ValueError(
|
|
156
|
+
f"destination must be one of {DESTINATIONS}, got {destination!r}"
|
|
157
|
+
)
|
|
158
|
+
source = Path(source).expanduser()
|
|
159
|
+
if not source.exists():
|
|
160
|
+
raise FileNotFoundError(f"path does not exist: {source}")
|
|
161
|
+
if not source.is_dir():
|
|
162
|
+
raise NotADirectoryError(f"not a directory: {source}")
|
|
163
|
+
source = source.resolve()
|
|
164
|
+
|
|
165
|
+
size, file_count, _newest_mtime, _err = _measure_dir(source)
|
|
166
|
+
remote = remote_path or _default_remote_path(source)
|
|
167
|
+
if remote.strip() in _UNSAFE_REMOTE_PATHS:
|
|
168
|
+
raise ValueError(f"refusing an unsafe remote path: {remote!r}")
|
|
169
|
+
|
|
170
|
+
return ArchivePlan(
|
|
171
|
+
source=source,
|
|
172
|
+
destination=destination,
|
|
173
|
+
remote_path=remote,
|
|
174
|
+
size_bytes=size,
|
|
175
|
+
file_count=file_count,
|
|
176
|
+
manifest_path=_manifest_path(source),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@dataclass
|
|
181
|
+
class ArchiveManifest:
|
|
182
|
+
"""Persisted record of one completed archive — the source of truth for restore."""
|
|
183
|
+
|
|
184
|
+
source: str
|
|
185
|
+
destination: str
|
|
186
|
+
remote_path: str
|
|
187
|
+
size_bytes: int
|
|
188
|
+
file_count: int
|
|
189
|
+
checksummed: bool
|
|
190
|
+
archived_at: float
|
|
191
|
+
|
|
192
|
+
def to_dict(self) -> dict:
|
|
193
|
+
return asdict(self)
|
|
194
|
+
|
|
195
|
+
@classmethod
|
|
196
|
+
def from_dict(cls, data: dict) -> ArchiveManifest:
|
|
197
|
+
return cls(**data)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def apply_archive(
|
|
201
|
+
plan: ArchivePlan,
|
|
202
|
+
*,
|
|
203
|
+
checksum: bool = True,
|
|
204
|
+
exclude: tuple[str, ...] = (),
|
|
205
|
+
runner=None,
|
|
206
|
+
) -> ArchiveManifest:
|
|
207
|
+
"""Execute an archive plan: push, verify, write manifest, THEN remove source.
|
|
208
|
+
|
|
209
|
+
``checksum`` (default on, since this precedes an irreversible local
|
|
210
|
+
delete) adds rsync's ``--checksum`` flag — reads every byte on both
|
|
211
|
+
sides instead of rsync's fast mtime+size quick-check. A non-zero rsync
|
|
212
|
+
exit raises immediately; the source is left completely untouched and no
|
|
213
|
+
manifest is written. ``runner`` is passed straight through to both
|
|
214
|
+
``exec_remote`` (the mkdir) and ``sync_dir`` (a ``subprocess.run``-
|
|
215
|
+
shaped invoker) — the same real-transport-free test seam scitex-ssh
|
|
216
|
+
itself exposes.
|
|
217
|
+
"""
|
|
218
|
+
remote_parent = posixpath.dirname(plan.remote_path)
|
|
219
|
+
if remote_parent and remote_parent not in _UNSAFE_REMOTE_PATHS:
|
|
220
|
+
mkdir_result = exec_remote(
|
|
221
|
+
plan.destination,
|
|
222
|
+
f"mkdir -p -- {_quote_remote_path(remote_parent)}",
|
|
223
|
+
runner=runner,
|
|
224
|
+
)
|
|
225
|
+
if not mkdir_result.success:
|
|
226
|
+
raise RuntimeError(
|
|
227
|
+
f"failed to create remote parent directory "
|
|
228
|
+
f"{plan.destination}:{remote_parent} "
|
|
229
|
+
f"(exit {mkdir_result.returncode}) -- source left untouched.\n"
|
|
230
|
+
f"--- stdout ---\n{mkdir_result.stdout}\n"
|
|
231
|
+
f"--- stderr ---\n{mkdir_result.stderr}"
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
extra_opts: tuple[str, ...] = ("--checksum",) if checksum else ()
|
|
235
|
+
result: SSHResult = sync_dir(
|
|
236
|
+
plan.destination,
|
|
237
|
+
_as_dir_contents(str(plan.source)),
|
|
238
|
+
plan.remote_path,
|
|
239
|
+
direction="push",
|
|
240
|
+
exclude=exclude,
|
|
241
|
+
extra_opts=extra_opts,
|
|
242
|
+
runner=runner,
|
|
243
|
+
)
|
|
244
|
+
if not result.success:
|
|
245
|
+
raise RuntimeError(
|
|
246
|
+
f"archive sync to {plan.destination}:{plan.remote_path} failed "
|
|
247
|
+
f"(exit {result.returncode}) -- source left untouched.\n"
|
|
248
|
+
f"--- stdout ---\n{result.stdout}\n--- stderr ---\n{result.stderr}"
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
manifest = ArchiveManifest(
|
|
252
|
+
source=str(plan.source),
|
|
253
|
+
destination=plan.destination,
|
|
254
|
+
remote_path=plan.remote_path,
|
|
255
|
+
size_bytes=plan.size_bytes,
|
|
256
|
+
file_count=plan.file_count,
|
|
257
|
+
checksummed=checksum,
|
|
258
|
+
archived_at=time.time(),
|
|
259
|
+
)
|
|
260
|
+
plan.manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
261
|
+
plan.manifest_path.write_text(json.dumps(manifest.to_dict(), indent=2))
|
|
262
|
+
|
|
263
|
+
shutil.rmtree(plan.source)
|
|
264
|
+
return manifest
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@dataclass
|
|
268
|
+
class RestorePlan:
|
|
269
|
+
"""The result of :func:`plan_restore` — never touches the network."""
|
|
270
|
+
|
|
271
|
+
manifest: ArchiveManifest
|
|
272
|
+
manifest_path: Path
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def plan_restore(source: str | Path) -> RestorePlan:
|
|
276
|
+
"""Load the manifest for ``source`` — read-only, never touches the network.
|
|
277
|
+
|
|
278
|
+
``source`` need not currently exist (it typically doesn't — archiving
|
|
279
|
+
removed it). Fail-loud if no manifest was ever written for this path.
|
|
280
|
+
"""
|
|
281
|
+
resolved = Path(source).expanduser().resolve()
|
|
282
|
+
manifest_path = _manifest_path(resolved)
|
|
283
|
+
if not manifest_path.exists():
|
|
284
|
+
raise FileNotFoundError(
|
|
285
|
+
f"no archive manifest found for {resolved} at {manifest_path} "
|
|
286
|
+
"-- was this directory ever archived from here?"
|
|
287
|
+
)
|
|
288
|
+
manifest = ArchiveManifest.from_dict(json.loads(manifest_path.read_text()))
|
|
289
|
+
return RestorePlan(manifest=manifest, manifest_path=manifest_path)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def apply_restore(
|
|
293
|
+
plan: RestorePlan, *, delete_remote: bool = False, runner=None
|
|
294
|
+
) -> Path:
|
|
295
|
+
"""Pull the archived directory back to its original local path.
|
|
296
|
+
|
|
297
|
+
Verifies via rsync's own exit code; raises loud on failure (nothing is
|
|
298
|
+
removed remotely in that case, regardless of ``delete_remote``). The
|
|
299
|
+
remote copy is only removed when ``delete_remote=True`` — off by
|
|
300
|
+
default, since restoring locally should not destroy the backup unless
|
|
301
|
+
explicitly asked. ``runner`` is passed straight through to both
|
|
302
|
+
``sync_dir`` and ``exec_remote``.
|
|
303
|
+
"""
|
|
304
|
+
manifest = plan.manifest
|
|
305
|
+
source = Path(manifest.source)
|
|
306
|
+
result: SSHResult = sync_dir(
|
|
307
|
+
manifest.destination,
|
|
308
|
+
str(source),
|
|
309
|
+
_as_dir_contents(manifest.remote_path),
|
|
310
|
+
direction="pull",
|
|
311
|
+
runner=runner,
|
|
312
|
+
)
|
|
313
|
+
if not result.success:
|
|
314
|
+
raise RuntimeError(
|
|
315
|
+
f"restore pull from {manifest.destination}:{manifest.remote_path} "
|
|
316
|
+
f"failed (exit {result.returncode}) -- remote copy untouched.\n"
|
|
317
|
+
f"--- stdout ---\n{result.stdout}\n--- stderr ---\n{result.stderr}"
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
if delete_remote:
|
|
321
|
+
if manifest.remote_path.strip() in _UNSAFE_REMOTE_PATHS:
|
|
322
|
+
raise ValueError(
|
|
323
|
+
f"refusing to delete an unsafe remote path: {manifest.remote_path!r}"
|
|
324
|
+
)
|
|
325
|
+
rm_result = exec_remote(
|
|
326
|
+
manifest.destination,
|
|
327
|
+
f"rm -rf -- {_quote_remote_path(manifest.remote_path)}",
|
|
328
|
+
runner=runner,
|
|
329
|
+
)
|
|
330
|
+
if not rm_result.success:
|
|
331
|
+
raise RuntimeError(
|
|
332
|
+
f"local restore succeeded, but removing the remote copy at "
|
|
333
|
+
f"{manifest.destination}:{manifest.remote_path} failed "
|
|
334
|
+
f"(exit {rm_result.returncode}) -- remote copy still present.\n"
|
|
335
|
+
f"--- stdout ---\n{rm_result.stdout}\n--- stderr ---\n{rm_result.stderr}"
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
return source
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# EOF
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""scitex-storage CLI — thin orchestrator; each verb lives in its own submodule."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from .. import __version__
|
|
12
|
+
from ._archive_cmd import archive_cmd, restore_cmd
|
|
13
|
+
from ._compat import spec_group_kwargs
|
|
14
|
+
from ._duplicates_cmd import find_duplicates_cmd
|
|
15
|
+
from ._images_cmd import images_group
|
|
16
|
+
from ._introspect import list_python_apis
|
|
17
|
+
from ._mcp_commands import mcp
|
|
18
|
+
from ._scan_cmd import scan_cmd
|
|
19
|
+
from ._sweep_cmd import sweep_cmd, sweep_status_cmd
|
|
20
|
+
|
|
21
|
+
CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _print_command_help(cmd, prefix: str, parent_ctx) -> None:
|
|
25
|
+
click.echo(f"\n{'=' * 50}")
|
|
26
|
+
click.echo(prefix)
|
|
27
|
+
click.echo("=" * 50)
|
|
28
|
+
sub_ctx = click.Context(cmd, info_name=prefix.split()[-1], parent=parent_ctx)
|
|
29
|
+
click.echo(cmd.get_help(sub_ctx))
|
|
30
|
+
if isinstance(cmd, click.Group):
|
|
31
|
+
for sub_name, sub_cmd in sorted(cmd.commands.items()):
|
|
32
|
+
_print_command_help(sub_cmd, f"{prefix} {sub_name}", sub_ctx)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@click.group(
|
|
36
|
+
invoke_without_command=True,
|
|
37
|
+
context_settings=CONTEXT_SETTINGS,
|
|
38
|
+
**spec_group_kwargs(
|
|
39
|
+
summary="Research-data storage triage (scan + duplicates + versioned-image rotation).",
|
|
40
|
+
description=(
|
|
41
|
+
"Discovery + rotation layers of a planned storage-tiering tool "
|
|
42
|
+
"(local SSD -> NAS SSD -> NAS HDD -> offline). `scan` is a "
|
|
43
|
+
"read-only, stat-only directory walk (via `fd`) that reports "
|
|
44
|
+
"the biggest space and inode (file-count) consumers per "
|
|
45
|
+
"top-level child of a root -- no file contents are ever read. "
|
|
46
|
+
"`find-duplicates` is a separate, explicitly opt-in verb (via "
|
|
47
|
+
"`fclones`) that DOES read file contents (to hash them) to "
|
|
48
|
+
"report exact-duplicate groups. `images prune` rotates a "
|
|
49
|
+
"directory of versioned files (e.g. dated SIF builds), always "
|
|
50
|
+
"excluding files any symlink in the directory currently "
|
|
51
|
+
"references. `sweep` tars an inode-hog directory in place "
|
|
52
|
+
"(many small files -> one tar, one inode), compute-node-only, "
|
|
53
|
+
"gated on an explicit per-directory confirm. `archive` moves a "
|
|
54
|
+
"directory to nas/nas2 over ssh (scitex-ssh's sync_dir), "
|
|
55
|
+
"verifying before removing the local copy and writing a "
|
|
56
|
+
"manifest `restore` reads back. Every mutating command "
|
|
57
|
+
"defaults to a dry-run.",
|
|
58
|
+
),
|
|
59
|
+
config_resolution=(
|
|
60
|
+
"scitex-storage has no configurable state yet — every command "
|
|
61
|
+
"takes its options on the command line. Runtime state written: "
|
|
62
|
+
"archive manifests under "
|
|
63
|
+
"~/.scitex/scitex-storage/runtime/archive-manifests/, one JSON "
|
|
64
|
+
"file per archived directory, read back by `restore`.",
|
|
65
|
+
),
|
|
66
|
+
version_of="scitex-storage",
|
|
67
|
+
command_categories=(
|
|
68
|
+
(
|
|
69
|
+
"Storage",
|
|
70
|
+
(
|
|
71
|
+
"scan",
|
|
72
|
+
"find-duplicates",
|
|
73
|
+
"images",
|
|
74
|
+
"sweep",
|
|
75
|
+
"sweep-status",
|
|
76
|
+
"archive",
|
|
77
|
+
"restore",
|
|
78
|
+
),
|
|
79
|
+
),
|
|
80
|
+
("Introspection", ("list-python-apis", "mcp")),
|
|
81
|
+
),
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
@click.version_option(
|
|
85
|
+
__version__,
|
|
86
|
+
"-V",
|
|
87
|
+
"--version",
|
|
88
|
+
prog_name="scitex-storage",
|
|
89
|
+
message="%(prog)s %(version)s",
|
|
90
|
+
)
|
|
91
|
+
@click.option("--help-recursive", is_flag=True, help="Show help for all commands.")
|
|
92
|
+
@click.option(
|
|
93
|
+
"--json",
|
|
94
|
+
"as_json",
|
|
95
|
+
is_flag=True,
|
|
96
|
+
help="Emit structured JSON output (propagates to subcommands that honour it).",
|
|
97
|
+
)
|
|
98
|
+
@click.pass_context
|
|
99
|
+
def main(ctx: click.Context, help_recursive: bool, as_json: bool) -> None:
|
|
100
|
+
ctx.ensure_object(dict)
|
|
101
|
+
ctx.obj["as_json"] = as_json
|
|
102
|
+
|
|
103
|
+
if help_recursive:
|
|
104
|
+
click.echo(f"scitex-storage {__version__}")
|
|
105
|
+
click.echo(main.get_help(ctx))
|
|
106
|
+
for name, cmd in sorted(main.commands.items()):
|
|
107
|
+
_print_command_help(cmd, f"scitex-storage {name}", ctx)
|
|
108
|
+
ctx.exit(0)
|
|
109
|
+
|
|
110
|
+
if ctx.invoked_subcommand is None:
|
|
111
|
+
click.echo(ctx.get_help())
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
main.add_command(scan_cmd)
|
|
115
|
+
main.add_command(find_duplicates_cmd)
|
|
116
|
+
main.add_command(images_group)
|
|
117
|
+
main.add_command(sweep_cmd)
|
|
118
|
+
main.add_command(sweep_status_cmd)
|
|
119
|
+
main.add_command(archive_cmd)
|
|
120
|
+
main.add_command(restore_cmd)
|
|
121
|
+
main.add_command(list_python_apis)
|
|
122
|
+
main.add_command(mcp)
|
|
123
|
+
|
|
124
|
+
# §1a: install-shell-completion + print-shell-completion (canonical leaves).
|
|
125
|
+
try:
|
|
126
|
+
from scitex_dev._cli._completion import attach_shell_completion
|
|
127
|
+
|
|
128
|
+
attach_shell_completion(main, prog_name="scitex-storage")
|
|
129
|
+
except ImportError:
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
if __name__ == "__main__": # pragma: no cover
|
|
134
|
+
sys.exit(main())
|
|
135
|
+
|
|
136
|
+
# EOF
|