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.
Files changed (104) hide show
  1. scitex_storage/__init__.py +123 -0
  2. scitex_storage/__main__.py +12 -0
  3. scitex_storage/_archive.py +341 -0
  4. scitex_storage/_cli/__init__.py +136 -0
  5. scitex_storage/_cli/_archive_cmd.py +188 -0
  6. scitex_storage/_cli/_compat.py +121 -0
  7. scitex_storage/_cli/_duplicates_cmd.py +60 -0
  8. scitex_storage/_cli/_images_cmd.py +90 -0
  9. scitex_storage/_cli/_introspect.py +59 -0
  10. scitex_storage/_cli/_mcp_commands.py +62 -0
  11. scitex_storage/_cli/_scan_cmd.py +110 -0
  12. scitex_storage/_cli/_sweep_cmd.py +142 -0
  13. scitex_storage/_duplicates.py +118 -0
  14. scitex_storage/_images.py +237 -0
  15. scitex_storage/_report.py +443 -0
  16. scitex_storage/_scan.py +324 -0
  17. scitex_storage/_sphinx_html/_modules/index.html +142 -0
  18. scitex_storage/_sphinx_html/_modules/scitex_storage/_scan.html +376 -0
  19. scitex_storage/_sphinx_html/_sources/api/scitex_storage.rst.txt +7 -0
  20. scitex_storage/_sphinx_html/_sources/index.rst.txt +29 -0
  21. scitex_storage/_sphinx_html/_sources/installation.rst.txt +23 -0
  22. scitex_storage/_sphinx_html/_sources/quickstart.rst.txt +7 -0
  23. scitex_storage/_sphinx_html/_static/_sphinx_javascript_frameworks_compat.js +123 -0
  24. scitex_storage/_sphinx_html/_static/base-stemmer.js +476 -0
  25. scitex_storage/_sphinx_html/_static/basic.css +906 -0
  26. scitex_storage/_sphinx_html/_static/check-solid.svg +4 -0
  27. scitex_storage/_sphinx_html/_static/clipboard.min.js +7 -0
  28. scitex_storage/_sphinx_html/_static/copy-button.svg +5 -0
  29. scitex_storage/_sphinx_html/_static/copybutton.css +94 -0
  30. scitex_storage/_sphinx_html/_static/copybutton.js +248 -0
  31. scitex_storage/_sphinx_html/_static/copybutton_funcs.js +73 -0
  32. scitex_storage/_sphinx_html/_static/css/badge_only.css +1 -0
  33. scitex_storage/_sphinx_html/_static/css/fonts/Roboto-Slab-Bold.woff +0 -0
  34. scitex_storage/_sphinx_html/_static/css/fonts/Roboto-Slab-Bold.woff2 +0 -0
  35. scitex_storage/_sphinx_html/_static/css/fonts/Roboto-Slab-Regular.woff +0 -0
  36. scitex_storage/_sphinx_html/_static/css/fonts/Roboto-Slab-Regular.woff2 +0 -0
  37. scitex_storage/_sphinx_html/_static/css/fonts/fontawesome-webfont.eot +0 -0
  38. scitex_storage/_sphinx_html/_static/css/fonts/fontawesome-webfont.svg +2671 -0
  39. scitex_storage/_sphinx_html/_static/css/fonts/fontawesome-webfont.ttf +0 -0
  40. scitex_storage/_sphinx_html/_static/css/fonts/fontawesome-webfont.woff +0 -0
  41. scitex_storage/_sphinx_html/_static/css/fonts/fontawesome-webfont.woff2 +0 -0
  42. scitex_storage/_sphinx_html/_static/css/fonts/lato-bold-italic.woff +0 -0
  43. scitex_storage/_sphinx_html/_static/css/fonts/lato-bold-italic.woff2 +0 -0
  44. scitex_storage/_sphinx_html/_static/css/fonts/lato-bold.woff +0 -0
  45. scitex_storage/_sphinx_html/_static/css/fonts/lato-bold.woff2 +0 -0
  46. scitex_storage/_sphinx_html/_static/css/fonts/lato-normal-italic.woff +0 -0
  47. scitex_storage/_sphinx_html/_static/css/fonts/lato-normal-italic.woff2 +0 -0
  48. scitex_storage/_sphinx_html/_static/css/fonts/lato-normal.woff +0 -0
  49. scitex_storage/_sphinx_html/_static/css/fonts/lato-normal.woff2 +0 -0
  50. scitex_storage/_sphinx_html/_static/css/theme.css +4 -0
  51. scitex_storage/_sphinx_html/_static/doctools.js +150 -0
  52. scitex_storage/_sphinx_html/_static/documentation_options.js +13 -0
  53. scitex_storage/_sphinx_html/_static/english-stemmer.js +1066 -0
  54. scitex_storage/_sphinx_html/_static/file.png +0 -0
  55. scitex_storage/_sphinx_html/_static/fonts/Lato/lato-bold.eot +0 -0
  56. scitex_storage/_sphinx_html/_static/fonts/Lato/lato-bold.ttf +0 -0
  57. scitex_storage/_sphinx_html/_static/fonts/Lato/lato-bold.woff +0 -0
  58. scitex_storage/_sphinx_html/_static/fonts/Lato/lato-bold.woff2 +0 -0
  59. scitex_storage/_sphinx_html/_static/fonts/Lato/lato-bolditalic.eot +0 -0
  60. scitex_storage/_sphinx_html/_static/fonts/Lato/lato-bolditalic.ttf +0 -0
  61. scitex_storage/_sphinx_html/_static/fonts/Lato/lato-bolditalic.woff +0 -0
  62. scitex_storage/_sphinx_html/_static/fonts/Lato/lato-bolditalic.woff2 +0 -0
  63. scitex_storage/_sphinx_html/_static/fonts/Lato/lato-italic.eot +0 -0
  64. scitex_storage/_sphinx_html/_static/fonts/Lato/lato-italic.ttf +0 -0
  65. scitex_storage/_sphinx_html/_static/fonts/Lato/lato-italic.woff +0 -0
  66. scitex_storage/_sphinx_html/_static/fonts/Lato/lato-italic.woff2 +0 -0
  67. scitex_storage/_sphinx_html/_static/fonts/Lato/lato-regular.eot +0 -0
  68. scitex_storage/_sphinx_html/_static/fonts/Lato/lato-regular.ttf +0 -0
  69. scitex_storage/_sphinx_html/_static/fonts/Lato/lato-regular.woff +0 -0
  70. scitex_storage/_sphinx_html/_static/fonts/Lato/lato-regular.woff2 +0 -0
  71. scitex_storage/_sphinx_html/_static/fonts/RobotoSlab/roboto-slab-v7-bold.eot +0 -0
  72. scitex_storage/_sphinx_html/_static/fonts/RobotoSlab/roboto-slab-v7-bold.ttf +0 -0
  73. scitex_storage/_sphinx_html/_static/fonts/RobotoSlab/roboto-slab-v7-bold.woff +0 -0
  74. scitex_storage/_sphinx_html/_static/fonts/RobotoSlab/roboto-slab-v7-bold.woff2 +0 -0
  75. scitex_storage/_sphinx_html/_static/fonts/RobotoSlab/roboto-slab-v7-regular.eot +0 -0
  76. scitex_storage/_sphinx_html/_static/fonts/RobotoSlab/roboto-slab-v7-regular.ttf +0 -0
  77. scitex_storage/_sphinx_html/_static/fonts/RobotoSlab/roboto-slab-v7-regular.woff +0 -0
  78. scitex_storage/_sphinx_html/_static/fonts/RobotoSlab/roboto-slab-v7-regular.woff2 +0 -0
  79. scitex_storage/_sphinx_html/_static/jquery.js +2 -0
  80. scitex_storage/_sphinx_html/_static/js/badge_only.js +1 -0
  81. scitex_storage/_sphinx_html/_static/js/theme.js +1 -0
  82. scitex_storage/_sphinx_html/_static/js/versions.js +228 -0
  83. scitex_storage/_sphinx_html/_static/language_data.js +13 -0
  84. scitex_storage/_sphinx_html/_static/minus.png +0 -0
  85. scitex_storage/_sphinx_html/_static/plus.png +0 -0
  86. scitex_storage/_sphinx_html/_static/pygments.css +75 -0
  87. scitex_storage/_sphinx_html/_static/searchtools.js +693 -0
  88. scitex_storage/_sphinx_html/_static/sphinx_highlight.js +159 -0
  89. scitex_storage/_sphinx_html/api/scitex_storage.html +314 -0
  90. scitex_storage/_sphinx_html/genindex.html +292 -0
  91. scitex_storage/_sphinx_html/index.html +185 -0
  92. scitex_storage/_sphinx_html/installation.html +170 -0
  93. scitex_storage/_sphinx_html/objects.inv +0 -0
  94. scitex_storage/_sphinx_html/py-modindex.html +164 -0
  95. scitex_storage/_sphinx_html/quickstart.html +203 -0
  96. scitex_storage/_sphinx_html/search.html +161 -0
  97. scitex_storage/_sphinx_html/searchindex.js +1 -0
  98. scitex_storage/_sweep.py +384 -0
  99. scitex_storage/_system_deps.py +91 -0
  100. scitex_storage-0.2.0.dist-info/METADATA +444 -0
  101. scitex_storage-0.2.0.dist-info/RECORD +104 -0
  102. scitex_storage-0.2.0.dist-info/WHEEL +4 -0
  103. scitex_storage-0.2.0.dist-info/entry_points.txt +11 -0
  104. 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,12 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """``python -m scitex_storage`` entry point — forwards to the CLI."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from ._cli import main
8
+
9
+ if __name__ == "__main__":
10
+ main()
11
+
12
+ # 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