batchvault 0.1.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.
- batchvault/__init__.py +61 -0
- batchvault/__main__.py +3 -0
- batchvault/api.py +884 -0
- batchvault/blocks.py +176 -0
- batchvault/cli.py +806 -0
- batchvault/config.py +169 -0
- batchvault/crypto.py +193 -0
- batchvault/doctor.py +67 -0
- batchvault/engines/__init__.py +0 -0
- batchvault/engines/base.py +57 -0
- batchvault/engines/native.py +237 -0
- batchvault/engines/registry.py +61 -0
- batchvault/engines/sevenzip.py +303 -0
- batchvault/errors.py +38 -0
- batchvault/eta.py +39 -0
- batchvault/filters.py +51 -0
- batchvault/inputs.py +226 -0
- batchvault/levels.py +169 -0
- batchvault/locate.py +140 -0
- batchvault/logs.py +86 -0
- batchvault/manifest.py +210 -0
- batchvault/models.py +164 -0
- batchvault/names.py +36 -0
- batchvault/notify.py +73 -0
- batchvault/options.py +71 -0
- batchvault/py.typed +0 -0
- batchvault/security.py +40 -0
- batchvault/signing.py +152 -0
- batchvault/ui.py +298 -0
- batchvault/volumes.py +201 -0
- batchvault-0.1.0.dist-info/METADATA +306 -0
- batchvault-0.1.0.dist-info/RECORD +35 -0
- batchvault-0.1.0.dist-info/WHEEL +4 -0
- batchvault-0.1.0.dist-info/entry_points.txt +2 -0
- batchvault-0.1.0.dist-info/licenses/LICENSE +21 -0
batchvault/signing.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Ed25519 signatures for manifests (needs ``batchvault[crypto]``).
|
|
2
|
+
|
|
3
|
+
The signature is stored next to the manifest as ``<manifest>.sig`` and covers
|
|
4
|
+
the exact bytes of the manifest file. Anyone with the public key can verify it;
|
|
5
|
+
only the holder of the private key can create it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from batchvault.crypto import require_cryptography
|
|
15
|
+
from batchvault.errors import InputError, SignatureError
|
|
16
|
+
|
|
17
|
+
SIGNATURE_SUFFIX = ".sig"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def signature_path(manifest_path: Path) -> Path:
|
|
21
|
+
return manifest_path.with_name(manifest_path.name + SIGNATURE_SUFFIX)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _write_private(path: Path, data: bytes) -> None:
|
|
25
|
+
# Create the file readable by its owner only, before any key is written.
|
|
26
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
27
|
+
with os.fdopen(fd, "wb") as handle:
|
|
28
|
+
handle.write(data)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def generate_keypair(
|
|
32
|
+
private_path: Path,
|
|
33
|
+
public_path: Path,
|
|
34
|
+
*,
|
|
35
|
+
password: str | None = None,
|
|
36
|
+
overwrite: bool = False,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Create an Ed25519 key pair as PEM files.
|
|
39
|
+
|
|
40
|
+
The private key is encrypted with ``password`` when one is given.
|
|
41
|
+
"""
|
|
42
|
+
require_cryptography()
|
|
43
|
+
from cryptography.hazmat.primitives import serialization
|
|
44
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
45
|
+
|
|
46
|
+
for path in (private_path, public_path):
|
|
47
|
+
if path.exists() and not overwrite:
|
|
48
|
+
raise InputError(f"{path} already exists. Use --force to replace it.")
|
|
49
|
+
|
|
50
|
+
key = Ed25519PrivateKey.generate()
|
|
51
|
+
encryption = (
|
|
52
|
+
serialization.BestAvailableEncryption(password.encode("utf-8"))
|
|
53
|
+
if password
|
|
54
|
+
else serialization.NoEncryption()
|
|
55
|
+
)
|
|
56
|
+
_write_private(
|
|
57
|
+
private_path,
|
|
58
|
+
key.private_bytes(
|
|
59
|
+
serialization.Encoding.PEM,
|
|
60
|
+
serialization.PrivateFormat.PKCS8,
|
|
61
|
+
encryption,
|
|
62
|
+
),
|
|
63
|
+
)
|
|
64
|
+
public_path.write_bytes(
|
|
65
|
+
key.public_key().public_bytes(
|
|
66
|
+
serialization.Encoding.PEM,
|
|
67
|
+
serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def load_private_key(path: Path, password: str | None = None):
|
|
73
|
+
require_cryptography()
|
|
74
|
+
from cryptography.hazmat.primitives import serialization
|
|
75
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
key = serialization.load_pem_private_key(
|
|
79
|
+
path.read_bytes(), password.encode("utf-8") if password else None
|
|
80
|
+
)
|
|
81
|
+
except OSError as exc:
|
|
82
|
+
raise SignatureError(f"Cannot read private key {path}: {exc}") from exc
|
|
83
|
+
except (ValueError, TypeError) as exc:
|
|
84
|
+
raise SignatureError(
|
|
85
|
+
f"Cannot load private key {path} (wrong password or not a key): {exc}"
|
|
86
|
+
) from exc
|
|
87
|
+
|
|
88
|
+
if not isinstance(key, Ed25519PrivateKey):
|
|
89
|
+
raise SignatureError(f"{path} is not an Ed25519 private key.")
|
|
90
|
+
return key
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def load_public_key(path: Path):
|
|
94
|
+
require_cryptography()
|
|
95
|
+
from cryptography.hazmat.primitives import serialization
|
|
96
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
key = serialization.load_pem_public_key(path.read_bytes())
|
|
100
|
+
except OSError as exc:
|
|
101
|
+
raise SignatureError(f"Cannot read public key {path}: {exc}") from exc
|
|
102
|
+
except (ValueError, TypeError) as exc:
|
|
103
|
+
raise SignatureError(f"Cannot load public key {path}: {exc}") from exc
|
|
104
|
+
|
|
105
|
+
if not isinstance(key, Ed25519PublicKey):
|
|
106
|
+
raise SignatureError(f"{path} is not an Ed25519 public key.")
|
|
107
|
+
return key
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def sign_manifest(manifest_path: Path, private_key) -> Path:
|
|
111
|
+
"""Sign the manifest with a loaded private key; return the signature file."""
|
|
112
|
+
signature = private_key.sign(manifest_path.read_bytes())
|
|
113
|
+
target = signature_path(manifest_path)
|
|
114
|
+
target.write_text(base64.b64encode(signature).decode("ascii") + "\n")
|
|
115
|
+
return target
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def verify_manifest_bytes(
|
|
119
|
+
data: bytes, manifest_path: Path, public_key_path: Path
|
|
120
|
+
) -> None:
|
|
121
|
+
"""Raise SignatureError unless ``data`` carries a valid signature.
|
|
122
|
+
|
|
123
|
+
``data`` is the manifest content, already read from ``manifest_path``; the
|
|
124
|
+
signature file sits next to it. Checking the bytes that are then parsed
|
|
125
|
+
leaves no gap in which the file could be swapped.
|
|
126
|
+
"""
|
|
127
|
+
from cryptography.exceptions import InvalidSignature
|
|
128
|
+
|
|
129
|
+
public_key = load_public_key(public_key_path)
|
|
130
|
+
sig_file = signature_path(manifest_path)
|
|
131
|
+
if not sig_file.is_file():
|
|
132
|
+
raise SignatureError(
|
|
133
|
+
f"{manifest_path.name} is not signed (no {sig_file.name})."
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
signature = base64.b64decode(sig_file.read_text().strip(), validate=True)
|
|
138
|
+
public_key.verify(signature, data)
|
|
139
|
+
except (ValueError, InvalidSignature) as exc:
|
|
140
|
+
raise SignatureError(
|
|
141
|
+
f"Signature check failed for {manifest_path.name}: the manifest was "
|
|
142
|
+
"changed or signed with a different key."
|
|
143
|
+
) from exc
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def verify_manifest_signature(manifest_path: Path, public_key_path: Path) -> None:
|
|
147
|
+
"""Raise SignatureError unless the manifest has a valid signature."""
|
|
148
|
+
try:
|
|
149
|
+
data = manifest_path.read_bytes()
|
|
150
|
+
except OSError as exc:
|
|
151
|
+
raise SignatureError(f"Cannot read {manifest_path}: {exc}") from exc
|
|
152
|
+
verify_manifest_bytes(data, manifest_path, public_key_path)
|
batchvault/ui.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Rich terminal output. Only the command-line interface imports this module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.markup import escape
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.progress import (
|
|
12
|
+
BarColumn,
|
|
13
|
+
Progress,
|
|
14
|
+
ProgressColumn,
|
|
15
|
+
SpinnerColumn,
|
|
16
|
+
TaskProgressColumn,
|
|
17
|
+
TextColumn,
|
|
18
|
+
TimeElapsedColumn,
|
|
19
|
+
)
|
|
20
|
+
from rich.table import Table
|
|
21
|
+
from rich.text import Text
|
|
22
|
+
|
|
23
|
+
from batchvault.doctor import Check
|
|
24
|
+
from batchvault.eta import EtaEstimator, format_duration
|
|
25
|
+
from batchvault.models import InspectEntry, ProgressEvent, RunResult, Status
|
|
26
|
+
from batchvault.options import format_size
|
|
27
|
+
|
|
28
|
+
STATUS_STYLE = {
|
|
29
|
+
Status.SUCCESS: ("OK", "green"),
|
|
30
|
+
Status.SKIPPED: ("SKIPPED", "yellow"),
|
|
31
|
+
Status.FAILED: ("FAILED", "bold red"),
|
|
32
|
+
Status.PARTIAL: ("PARTIAL", "yellow"),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class EtaColumn(ProgressColumn):
|
|
37
|
+
"""Shows the run's own ETA for tasks created with an ``estimator`` field."""
|
|
38
|
+
|
|
39
|
+
def render(self, task):
|
|
40
|
+
estimator = task.fields.get("estimator")
|
|
41
|
+
if estimator is None:
|
|
42
|
+
return Text("")
|
|
43
|
+
|
|
44
|
+
remaining = estimator.estimate(task.completed, task.total or 0)
|
|
45
|
+
if remaining is None:
|
|
46
|
+
return Text("ETA estimating…", style="dim")
|
|
47
|
+
return Text(f"ETA {format_duration(remaining)}", style="progress.remaining")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def status_text(status: Status) -> Text:
|
|
51
|
+
label, style = STATUS_STYLE[status]
|
|
52
|
+
return Text(label, style=style)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class RunProgress:
|
|
56
|
+
"""Progress callback that draws an overall bar and one line per active file.
|
|
57
|
+
|
|
58
|
+
Use as a context manager and pass the instance as ``progress=``. With
|
|
59
|
+
``enabled=False`` it does nothing.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(self, console: Console, label: str, enabled: bool = True):
|
|
63
|
+
self._enabled = enabled
|
|
64
|
+
self._label = label
|
|
65
|
+
self._lock = threading.Lock()
|
|
66
|
+
self._eta = EtaEstimator()
|
|
67
|
+
self._by_bytes = False
|
|
68
|
+
self._overall = None
|
|
69
|
+
self._tasks: dict[Path, int] = {}
|
|
70
|
+
self._progress = Progress(
|
|
71
|
+
SpinnerColumn(),
|
|
72
|
+
TextColumn("[bold cyan]{task.description}"),
|
|
73
|
+
BarColumn(),
|
|
74
|
+
TaskProgressColumn(),
|
|
75
|
+
TimeElapsedColumn(),
|
|
76
|
+
EtaColumn(),
|
|
77
|
+
console=console,
|
|
78
|
+
transient=True,
|
|
79
|
+
disable=not enabled,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def __enter__(self):
|
|
83
|
+
if self._enabled:
|
|
84
|
+
self._progress.start()
|
|
85
|
+
return self
|
|
86
|
+
|
|
87
|
+
def __exit__(self, *exc_info):
|
|
88
|
+
self._progress.stop()
|
|
89
|
+
|
|
90
|
+
def pause(self) -> None:
|
|
91
|
+
"""Stop drawing, e.g. before asking the user a question."""
|
|
92
|
+
self._progress.stop()
|
|
93
|
+
|
|
94
|
+
def __call__(self, event: ProgressEvent) -> None:
|
|
95
|
+
if not self._enabled:
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
with self._lock:
|
|
99
|
+
if event.kind == "plan":
|
|
100
|
+
self._by_bytes = event.size > 0
|
|
101
|
+
total = event.size if self._by_bytes else event.count
|
|
102
|
+
self._overall = self._progress.add_task(
|
|
103
|
+
self._label, total=total, estimator=self._eta
|
|
104
|
+
)
|
|
105
|
+
self._eta.start()
|
|
106
|
+
elif event.kind == "start":
|
|
107
|
+
task = self._progress.add_task(escape(event.source.name), total=None)
|
|
108
|
+
self._tasks[event.source] = task
|
|
109
|
+
elif event.kind == "finish":
|
|
110
|
+
self._finish(event)
|
|
111
|
+
|
|
112
|
+
def _finish(self, event: ProgressEvent) -> None:
|
|
113
|
+
task = self._tasks.pop(event.source, None)
|
|
114
|
+
if task is not None:
|
|
115
|
+
self._progress.remove_task(task)
|
|
116
|
+
if self._overall is not None:
|
|
117
|
+
self._progress.advance(
|
|
118
|
+
self._overall, event.size if self._by_bytes else 1
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
label, style = STATUS_STYLE.get(event.status, ("", ""))
|
|
122
|
+
self._progress.console.print(
|
|
123
|
+
Text.assemble((f"{label:<8}", style), event.source.name)
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def print_run_info(console: Console, title: str, rows: list[tuple[str, str]]) -> None:
|
|
128
|
+
info = Table.grid(padding=(0, 2))
|
|
129
|
+
info.add_column(style="bold cyan")
|
|
130
|
+
info.add_column()
|
|
131
|
+
for key, value in rows:
|
|
132
|
+
info.add_row(key, Text(value))
|
|
133
|
+
console.print(Panel(info, title=title, border_style="cyan"))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# Terms used on screen: a SOURCE is an original file; an ARCHIVE is the set of
|
|
137
|
+
# volumes made from it. Each table names its columns accordingly.
|
|
138
|
+
SOURCE_STYLE = "cyan"
|
|
139
|
+
ARCHIVE_STYLE = "magenta"
|
|
140
|
+
|
|
141
|
+
SUMMARY_TITLES = {
|
|
142
|
+
"pack": "Pack: source files -> archives",
|
|
143
|
+
"unpack": "Unpack: archives -> restored files",
|
|
144
|
+
"verify": "Verify: archives",
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _shown(folder: str, name: str) -> str:
|
|
149
|
+
return f"{folder}/{name}" if folder else name
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _detail_text(entry) -> Text:
|
|
153
|
+
style = "dim" if entry.status == Status.SUCCESS else ""
|
|
154
|
+
text = Text(entry.message, style=style)
|
|
155
|
+
for warning in entry.warnings:
|
|
156
|
+
text.append(f"\n{warning}", style="yellow")
|
|
157
|
+
return text
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def print_summary(
|
|
161
|
+
console: Console, result: RunResult, title: str | None = None, kind: str = "pack"
|
|
162
|
+
) -> None:
|
|
163
|
+
"""Print one row per file. ``kind`` is pack, unpack or verify."""
|
|
164
|
+
table = Table(title=title or SUMMARY_TITLES.get(kind, ""), show_lines=False)
|
|
165
|
+
|
|
166
|
+
# The same columns as `inspect`: the original file, its archive, the outcome
|
|
167
|
+
# and an explanation.
|
|
168
|
+
table.add_column("Source", style=SOURCE_STYLE, overflow="fold")
|
|
169
|
+
table.add_column("Archive", style=ARCHIVE_STYLE, overflow="fold")
|
|
170
|
+
table.add_column("Status")
|
|
171
|
+
table.add_column("Details", overflow="fold")
|
|
172
|
+
|
|
173
|
+
for entry in result.entries:
|
|
174
|
+
# pack: the source is the original file and the archive its first volume.
|
|
175
|
+
# unpack and verify: the "source" is the archive's first volume.
|
|
176
|
+
if kind == "pack":
|
|
177
|
+
archive = entry.archive.name if entry.archive else "-"
|
|
178
|
+
original = entry.name or entry.source.name
|
|
179
|
+
else:
|
|
180
|
+
archive = entry.source.name
|
|
181
|
+
original = entry.name or "-"
|
|
182
|
+
table.add_row(
|
|
183
|
+
Text(_shown(entry.folder, original)),
|
|
184
|
+
Text(archive),
|
|
185
|
+
status_text(entry.status),
|
|
186
|
+
_detail_text(entry),
|
|
187
|
+
)
|
|
188
|
+
console.print(table)
|
|
189
|
+
|
|
190
|
+
for warning in result.warnings:
|
|
191
|
+
console.print(Text(f"Warning: {warning}", style="yellow"))
|
|
192
|
+
|
|
193
|
+
counts = ", ".join(
|
|
194
|
+
f"{result.count(status)} {label.lower()}"
|
|
195
|
+
for status, (label, _) in STATUS_STYLE.items()
|
|
196
|
+
if status != Status.PARTIAL and result.count(status)
|
|
197
|
+
)
|
|
198
|
+
console.print(f"[bold]Summary:[/bold] {escape(counts or 'nothing to do')}")
|
|
199
|
+
if result.manifest and any(e.status == Status.SUCCESS for e in result.entries):
|
|
200
|
+
console.print(f"Manifest: {escape(str(result.manifest))}")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _archive_status(entry: InspectEntry) -> Text:
|
|
204
|
+
if entry.complete is True:
|
|
205
|
+
return Text("OK", style="green")
|
|
206
|
+
if entry.complete is False:
|
|
207
|
+
return Text("MISSING", style="bold red")
|
|
208
|
+
return Text("-", style="dim") # no manifest, so completeness is unknown
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _inspect_details(entry: InspectEntry) -> Text:
|
|
212
|
+
notes = []
|
|
213
|
+
if entry.encrypted:
|
|
214
|
+
notes.append(("encrypted", "yellow"))
|
|
215
|
+
if entry.complete is False:
|
|
216
|
+
notes.append(("some volumes are missing", "bold red"))
|
|
217
|
+
if entry.complete is None:
|
|
218
|
+
notes.append(("no manifest: sizes and checks unknown", "dim"))
|
|
219
|
+
|
|
220
|
+
text = Text()
|
|
221
|
+
for index, (note, style) in enumerate(notes):
|
|
222
|
+
if index:
|
|
223
|
+
text.append(", ")
|
|
224
|
+
text.append(note, style=style)
|
|
225
|
+
return text
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def print_inspect(
|
|
229
|
+
console: Console, entries: list[InspectEntry], source: str | None = None
|
|
230
|
+
) -> None:
|
|
231
|
+
"""Describe archives with the same layout and colours as the result tables."""
|
|
232
|
+
original = sum(e.size or 0 for e in entries)
|
|
233
|
+
stored = sum(e.stored_size or 0 for e in entries)
|
|
234
|
+
missing = sum(1 for e in entries if e.complete is False)
|
|
235
|
+
encrypted = sum(1 for e in entries if e.encrypted)
|
|
236
|
+
|
|
237
|
+
rows = []
|
|
238
|
+
if source:
|
|
239
|
+
rows.append(("Archives", source))
|
|
240
|
+
rows.append(("Files", str(len(entries))))
|
|
241
|
+
if original:
|
|
242
|
+
sizes = f"{format_size(original)} -> {format_size(stored)}"
|
|
243
|
+
rows.append(("Size", f"{sizes} ({stored / original:.0%})"))
|
|
244
|
+
else:
|
|
245
|
+
rows.append(("Stored size", format_size(stored)))
|
|
246
|
+
if encrypted:
|
|
247
|
+
rows.append(("Encrypted", f"{encrypted} of {len(entries)}"))
|
|
248
|
+
print_run_info(console, "BatchVault inspect", rows)
|
|
249
|
+
|
|
250
|
+
table = Table(title="Inspect: archives", show_lines=False)
|
|
251
|
+
table.add_column("Source", style=SOURCE_STYLE, overflow="fold")
|
|
252
|
+
table.add_column("Archive", style=ARCHIVE_STYLE, overflow="fold")
|
|
253
|
+
table.add_column("Status")
|
|
254
|
+
table.add_column("Engine")
|
|
255
|
+
table.add_column("Format")
|
|
256
|
+
table.add_column("Level", justify="right")
|
|
257
|
+
table.add_column("Original", justify="right")
|
|
258
|
+
table.add_column("Stored", justify="right")
|
|
259
|
+
table.add_column("Ratio", justify="right")
|
|
260
|
+
table.add_column("Volumes", justify="right")
|
|
261
|
+
table.add_column("Details", overflow="fold")
|
|
262
|
+
|
|
263
|
+
for entry in entries:
|
|
264
|
+
ratio = f"{entry.ratio:.0%}" if entry.ratio is not None else "-"
|
|
265
|
+
table.add_row(
|
|
266
|
+
Text(_shown(entry.folder, entry.name)),
|
|
267
|
+
Text(entry.path.name),
|
|
268
|
+
_archive_status(entry),
|
|
269
|
+
entry.engine,
|
|
270
|
+
entry.format or "-",
|
|
271
|
+
"-" if entry.level is None else str(entry.level),
|
|
272
|
+
format_size(entry.size),
|
|
273
|
+
format_size(entry.stored_size),
|
|
274
|
+
ratio,
|
|
275
|
+
str(entry.volumes),
|
|
276
|
+
_inspect_details(entry),
|
|
277
|
+
)
|
|
278
|
+
console.print(table)
|
|
279
|
+
|
|
280
|
+
summary = f"{len(entries)} archive{'' if len(entries) == 1 else 's'}"
|
|
281
|
+
if missing:
|
|
282
|
+
summary += f", {missing} with missing volumes"
|
|
283
|
+
console.print(f"[bold]Summary:[/bold] {escape(summary)}")
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def print_checks(console: Console, checks: list[Check]) -> None:
|
|
287
|
+
table = Table(title="BatchVault doctor")
|
|
288
|
+
table.add_column("Check")
|
|
289
|
+
table.add_column("Result")
|
|
290
|
+
table.add_column("Details", overflow="fold")
|
|
291
|
+
|
|
292
|
+
for check in checks:
|
|
293
|
+
if check.ok:
|
|
294
|
+
result = Text("OK", style="green")
|
|
295
|
+
else:
|
|
296
|
+
result = Text("FAILED" if check.required else "MISSING", style="red")
|
|
297
|
+
table.add_row(check.name, result, Text(check.detail))
|
|
298
|
+
console.print(table)
|
batchvault/volumes.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import time
|
|
8
|
+
import unicodedata
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from batchvault.errors import VolumeError
|
|
12
|
+
from batchvault.security import restrict_permissions
|
|
13
|
+
|
|
14
|
+
VOLUME_RE = re.compile(r"^(?P<base>.+\.(?:7z|bv))\.(?P<number>\d{3,})$", re.IGNORECASE)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def split_volume_name(name: str) -> tuple[str, int] | None:
|
|
18
|
+
"""Return ``(base, number)`` for ``intro.mp4.7z.003``, or None if not a volume."""
|
|
19
|
+
match = VOLUME_RE.match(name)
|
|
20
|
+
if not match:
|
|
21
|
+
return None
|
|
22
|
+
return match.group("base"), int(match.group("number"))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _same_name(a: str, b: str) -> bool:
|
|
26
|
+
"""Compare names ignoring case and Unicode form (macOS decomposes accents)."""
|
|
27
|
+
return (
|
|
28
|
+
unicodedata.normalize("NFC", a).casefold()
|
|
29
|
+
== unicodedata.normalize("NFC", b).casefold()
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def existing_volumes(directory: Path, base: str) -> list[Path]:
|
|
34
|
+
"""Volumes of ``base`` already present in ``directory``, in volume order."""
|
|
35
|
+
if not directory.is_dir():
|
|
36
|
+
return []
|
|
37
|
+
|
|
38
|
+
found = []
|
|
39
|
+
for path in directory.iterdir():
|
|
40
|
+
parsed = split_volume_name(path.name)
|
|
41
|
+
if path.is_file() and parsed and _same_name(parsed[0], base):
|
|
42
|
+
found.append((parsed[1], path))
|
|
43
|
+
return [path for _, path in sorted(found)]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def find_volumes(path: Path) -> list[Path]:
|
|
47
|
+
"""Find every volume of the set that ``path`` belongs to.
|
|
48
|
+
|
|
49
|
+
Any volume of the set may be given. Raises VolumeError if the path is not a
|
|
50
|
+
volume or if a volume inside the numbered range is missing. A missing
|
|
51
|
+
*trailing* volume cannot be detected without a manifest.
|
|
52
|
+
"""
|
|
53
|
+
parsed = split_volume_name(path.name)
|
|
54
|
+
if parsed is None:
|
|
55
|
+
raise VolumeError(f"Not an archive volume: {path}")
|
|
56
|
+
|
|
57
|
+
volumes = existing_volumes(path.parent, parsed[0])
|
|
58
|
+
numbers = [split_volume_name(volume.name)[1] for volume in volumes]
|
|
59
|
+
|
|
60
|
+
missing = sorted(set(range(1, max(numbers) + 1)) - set(numbers))
|
|
61
|
+
if missing:
|
|
62
|
+
listed = ", ".join(f"{number:03d}" for number in missing)
|
|
63
|
+
raise VolumeError(
|
|
64
|
+
f"Missing volume(s) {listed} for {parsed[0]} in {path.parent}"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
return volumes
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
STAGING_PREFIX = ".batchvault-"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def cleanup_staging(root: Path, max_age: float = 86400.0) -> int:
|
|
74
|
+
"""Remove staging folders left behind by a killed run; return how many.
|
|
75
|
+
|
|
76
|
+
Looks through the whole tree under ``root``. Only folders untouched for
|
|
77
|
+
``max_age`` seconds are removed, so a run in progress is never disturbed.
|
|
78
|
+
"""
|
|
79
|
+
if not root.is_dir():
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
removed = 0
|
|
83
|
+
now = time.time()
|
|
84
|
+
for current, dirnames, _ in os.walk(root): # does not follow links
|
|
85
|
+
for name in list(dirnames):
|
|
86
|
+
if not name.startswith(STAGING_PREFIX):
|
|
87
|
+
continue
|
|
88
|
+
dirnames.remove(name) # never look inside a staging folder
|
|
89
|
+
item = Path(current) / name
|
|
90
|
+
try:
|
|
91
|
+
if not item.is_symlink() and now - item.stat().st_mtime > max_age:
|
|
92
|
+
shutil.rmtree(item)
|
|
93
|
+
removed += 1
|
|
94
|
+
except OSError:
|
|
95
|
+
continue
|
|
96
|
+
return removed
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def install_volumes(built: list[Path], dest_dir: Path, existing: list[Path]):
|
|
100
|
+
"""Move a finished volume set into ``dest_dir``, replacing ``existing``.
|
|
101
|
+
|
|
102
|
+
Old volumes are removed only here, after the new set was built completely.
|
|
103
|
+
"""
|
|
104
|
+
for old in existing:
|
|
105
|
+
old.unlink()
|
|
106
|
+
|
|
107
|
+
installed = []
|
|
108
|
+
for volume in built:
|
|
109
|
+
# Archives may hold private data; do not let umask make them readable by
|
|
110
|
+
# other users (no effect on Windows, where folder permissions apply).
|
|
111
|
+
restrict_permissions(volume)
|
|
112
|
+
target = dest_dir / volume.name
|
|
113
|
+
os.replace(volume, target)
|
|
114
|
+
installed.append(target)
|
|
115
|
+
return installed
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class VolumeWriter:
|
|
119
|
+
"""Write-only file object that rolls over to a new volume every N bytes."""
|
|
120
|
+
|
|
121
|
+
def __init__(self, directory: Path, base: str, volume_size: int):
|
|
122
|
+
self._directory = directory
|
|
123
|
+
self._base = base
|
|
124
|
+
self._volume_size = volume_size
|
|
125
|
+
self._file = None
|
|
126
|
+
self._room = 0
|
|
127
|
+
self.volumes: list[Path] = []
|
|
128
|
+
|
|
129
|
+
def write(self, data) -> int:
|
|
130
|
+
view = memoryview(data)
|
|
131
|
+
total = len(view)
|
|
132
|
+
|
|
133
|
+
while len(view):
|
|
134
|
+
if self._room == 0:
|
|
135
|
+
self._next_volume()
|
|
136
|
+
chunk = view[: self._room]
|
|
137
|
+
self._file.write(chunk)
|
|
138
|
+
self._room -= len(chunk)
|
|
139
|
+
view = view[len(chunk) :]
|
|
140
|
+
|
|
141
|
+
return total
|
|
142
|
+
|
|
143
|
+
def _next_volume(self) -> None:
|
|
144
|
+
self._close_current()
|
|
145
|
+
number = len(self.volumes) + 1
|
|
146
|
+
path = self._directory / f"{self._base}.{number:03d}"
|
|
147
|
+
self._file = path.open("wb")
|
|
148
|
+
self.volumes.append(path)
|
|
149
|
+
self._room = self._volume_size
|
|
150
|
+
|
|
151
|
+
def _close_current(self) -> None:
|
|
152
|
+
if self._file is not None:
|
|
153
|
+
self._file.close()
|
|
154
|
+
self._file = None
|
|
155
|
+
|
|
156
|
+
def close(self) -> None:
|
|
157
|
+
self._close_current()
|
|
158
|
+
|
|
159
|
+
def __enter__(self):
|
|
160
|
+
return self
|
|
161
|
+
|
|
162
|
+
def __exit__(self, *exc_info):
|
|
163
|
+
self.close()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class VolumeReader(io.RawIOBase):
|
|
167
|
+
"""Read-only file object that reads a volume set as one continuous stream."""
|
|
168
|
+
|
|
169
|
+
def __init__(self, paths):
|
|
170
|
+
super().__init__()
|
|
171
|
+
self._paths = list(paths)
|
|
172
|
+
self._index = 0
|
|
173
|
+
self._file = None
|
|
174
|
+
|
|
175
|
+
def readable(self) -> bool:
|
|
176
|
+
return True
|
|
177
|
+
|
|
178
|
+
def readinto(self, buffer) -> int:
|
|
179
|
+
view = memoryview(buffer).cast("B")
|
|
180
|
+
if not len(view):
|
|
181
|
+
return 0
|
|
182
|
+
|
|
183
|
+
while True:
|
|
184
|
+
if self._file is None:
|
|
185
|
+
if self._index >= len(self._paths):
|
|
186
|
+
return 0
|
|
187
|
+
self._file = self._paths[self._index].open("rb")
|
|
188
|
+
self._index += 1
|
|
189
|
+
|
|
190
|
+
count = self._file.readinto(view)
|
|
191
|
+
if count:
|
|
192
|
+
return count
|
|
193
|
+
|
|
194
|
+
self._file.close()
|
|
195
|
+
self._file = None
|
|
196
|
+
|
|
197
|
+
def close(self) -> None:
|
|
198
|
+
if self._file is not None:
|
|
199
|
+
self._file.close()
|
|
200
|
+
self._file = None
|
|
201
|
+
super().close()
|