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/config.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Project configuration from ``batchvault.toml`` or ``[tool.batchvault]``.
|
|
2
|
+
|
|
3
|
+
Layout (keys use the same names as the command-line options)::
|
|
4
|
+
|
|
5
|
+
[defaults] # applies to every command that has the option
|
|
6
|
+
jobs = 4
|
|
7
|
+
|
|
8
|
+
[log]
|
|
9
|
+
level = "DEBUG" # --log-level
|
|
10
|
+
file = "run.log" # --log-file
|
|
11
|
+
|
|
12
|
+
[notify]
|
|
13
|
+
url = "https://hooks.example.com/..." # --notify
|
|
14
|
+
on = "failure" # --notify-on
|
|
15
|
+
|
|
16
|
+
[pack] # per command: pack, unpack, verify, inspect
|
|
17
|
+
level = 7
|
|
18
|
+
volume-size = "100m"
|
|
19
|
+
include = ["*.mp4"]
|
|
20
|
+
|
|
21
|
+
Command-line options override the file. List options (``include``,
|
|
22
|
+
``exclude``) from the file and the command line are combined.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import tomllib
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from batchvault.errors import ConfigError
|
|
31
|
+
|
|
32
|
+
CONFIG_FILE = "batchvault.toml"
|
|
33
|
+
PYPROJECT = "pyproject.toml"
|
|
34
|
+
|
|
35
|
+
COMMAND_TABLES = ("pack", "unpack", "verify", "inspect")
|
|
36
|
+
# Tables that map their own key names onto command-line options.
|
|
37
|
+
SECTION_KEYS = {
|
|
38
|
+
"log": {"level": "log_level", "file": "log_file"},
|
|
39
|
+
"notify": {"url": "notify", "on": "notify_on"},
|
|
40
|
+
}
|
|
41
|
+
KNOWN_TABLES = {"defaults", *COMMAND_TABLES, *SECTION_KEYS}
|
|
42
|
+
|
|
43
|
+
# Options that run a program, send data out, choose which keys to trust, read a
|
|
44
|
+
# password from somewhere or delete files. A config file found automatically in
|
|
45
|
+
# the current or a parent folder may belong to someone else (a cloned
|
|
46
|
+
# repository, a downloaded folder), so it may not set these. A file named with
|
|
47
|
+
# --config or BATCHVAULT_CONFIG is trusted.
|
|
48
|
+
RESTRICTED_OPTIONS = frozenset(
|
|
49
|
+
{
|
|
50
|
+
"seven_zip",
|
|
51
|
+
"notify",
|
|
52
|
+
"delete_source",
|
|
53
|
+
"yes",
|
|
54
|
+
"public_key",
|
|
55
|
+
"sign_key",
|
|
56
|
+
"password_file",
|
|
57
|
+
"log_file",
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _tool_section(pyproject: Path) -> dict | None:
|
|
63
|
+
try:
|
|
64
|
+
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
|
|
65
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
66
|
+
return None
|
|
67
|
+
section = data.get("tool", {}).get("batchvault")
|
|
68
|
+
return section if isinstance(section, dict) else None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def find_config(start: Path | None = None) -> Path | None:
|
|
72
|
+
"""Look for a config file in ``start`` and then each parent folder."""
|
|
73
|
+
folder = (start or Path.cwd()).resolve()
|
|
74
|
+
for candidate in (folder, *folder.parents):
|
|
75
|
+
if (candidate / CONFIG_FILE).is_file():
|
|
76
|
+
return candidate / CONFIG_FILE
|
|
77
|
+
pyproject = candidate / PYPROJECT
|
|
78
|
+
if pyproject.is_file() and _tool_section(pyproject) is not None:
|
|
79
|
+
return pyproject
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def load_config(path: Path) -> dict:
|
|
84
|
+
try:
|
|
85
|
+
text = path.read_text(encoding="utf-8")
|
|
86
|
+
data = tomllib.loads(text)
|
|
87
|
+
except OSError as exc:
|
|
88
|
+
raise ConfigError(f"Cannot read config file {path}: {exc}") from exc
|
|
89
|
+
except tomllib.TOMLDecodeError as exc:
|
|
90
|
+
raise ConfigError(f"Config file {path} is not valid TOML: {exc}") from exc
|
|
91
|
+
|
|
92
|
+
if path.name == PYPROJECT:
|
|
93
|
+
data = data.get("tool", {}).get("batchvault", {})
|
|
94
|
+
|
|
95
|
+
unknown = sorted(set(data) - KNOWN_TABLES)
|
|
96
|
+
if unknown:
|
|
97
|
+
raise ConfigError(
|
|
98
|
+
f"{path}: unknown section(s): {', '.join(unknown)}. "
|
|
99
|
+
f"Known: {', '.join(sorted(KNOWN_TABLES))}."
|
|
100
|
+
)
|
|
101
|
+
for name, table in data.items():
|
|
102
|
+
if not isinstance(table, dict):
|
|
103
|
+
raise ConfigError(f"{path}: [{name}] must be a table")
|
|
104
|
+
return data
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def reject_restricted(config: dict, path: Path) -> None:
|
|
108
|
+
"""Refuse restricted options in a config file that was found automatically."""
|
|
109
|
+
for table_name, table in config.items():
|
|
110
|
+
mapping = SECTION_KEYS.get(table_name)
|
|
111
|
+
for key in table:
|
|
112
|
+
dest = mapping.get(key, key) if mapping else key.replace("-", "_")
|
|
113
|
+
if dest in RESTRICTED_OPTIONS:
|
|
114
|
+
raise ConfigError(
|
|
115
|
+
f"{path} sets '{key}' in [{table_name}]. A config file found "
|
|
116
|
+
"automatically cannot set options that run programs, send data, "
|
|
117
|
+
"choose trusted keys, read passwords or delete files. If you "
|
|
118
|
+
f"trust this file, run with: --config {path}"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _check_value(dest: str, value, action, where: str) -> None:
|
|
123
|
+
if action.choices is not None and value not in action.choices:
|
|
124
|
+
choices = ", ".join(str(choice) for choice in action.choices)
|
|
125
|
+
raise ConfigError(f"{where}: '{dest}' must be one of: {choices}")
|
|
126
|
+
|
|
127
|
+
if action.nargs == 0: # on/off flag
|
|
128
|
+
if not isinstance(value, bool):
|
|
129
|
+
raise ConfigError(f"{where}: '{dest}' must be true or false")
|
|
130
|
+
elif action.type is int:
|
|
131
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
132
|
+
raise ConfigError(f"{where}: '{dest}' must be a whole number")
|
|
133
|
+
elif action.default is not None and isinstance(action.default, list):
|
|
134
|
+
if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
|
|
135
|
+
raise ConfigError(f"{where}: '{dest}' must be a list of strings")
|
|
136
|
+
elif not isinstance(value, str | int | float) or isinstance(value, bool):
|
|
137
|
+
raise ConfigError(f"{where}: '{dest}' has an unsupported value")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def defaults_for(config: dict, command: str, actions: dict) -> dict:
|
|
141
|
+
"""Option defaults for ``command`` (``actions`` maps option name to Action)."""
|
|
142
|
+
merged: dict = {}
|
|
143
|
+
|
|
144
|
+
for key, value in config.get("defaults", {}).items():
|
|
145
|
+
dest = key.replace("-", "_")
|
|
146
|
+
if dest in actions:
|
|
147
|
+
_check_value(dest, value, actions[dest], "[defaults]")
|
|
148
|
+
merged[dest] = value
|
|
149
|
+
|
|
150
|
+
for section, mapping in SECTION_KEYS.items():
|
|
151
|
+
for key, value in config.get(section, {}).items():
|
|
152
|
+
dest = mapping.get(key)
|
|
153
|
+
if dest is None:
|
|
154
|
+
raise ConfigError(
|
|
155
|
+
f"Unknown key '{key}' in [{section}]. "
|
|
156
|
+
f"Known: {', '.join(mapping)}."
|
|
157
|
+
)
|
|
158
|
+
if dest in actions:
|
|
159
|
+
_check_value(dest, value, actions[dest], f"[{section}]")
|
|
160
|
+
merged[dest] = value
|
|
161
|
+
|
|
162
|
+
for key, value in config.get(command, {}).items():
|
|
163
|
+
dest = key.replace("-", "_")
|
|
164
|
+
if dest not in actions:
|
|
165
|
+
raise ConfigError(f"Unknown option '{key}' in [{command}].")
|
|
166
|
+
_check_value(dest, value, actions[dest], f"[{command}]")
|
|
167
|
+
merged[dest] = value
|
|
168
|
+
|
|
169
|
+
return merged
|
batchvault/crypto.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Optional encryption for the native engine.
|
|
2
|
+
|
|
3
|
+
Needs the ``cryptography`` package (``pip install "batchvault[crypto]"``).
|
|
4
|
+
|
|
5
|
+
An encrypted archive is a header followed by one record per compressed block
|
|
6
|
+
and a final terminator record. Each record is AES-256-GCM encrypted with a
|
|
7
|
+
unique nonce; the header, the record number and a "last record" flag are
|
|
8
|
+
authenticated, so reordering, dropping or truncating records is detected. The
|
|
9
|
+
key comes from the password with scrypt and a random per-archive salt.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import io
|
|
16
|
+
import os
|
|
17
|
+
import struct
|
|
18
|
+
|
|
19
|
+
from batchvault.errors import EngineError, EngineNotFoundError
|
|
20
|
+
|
|
21
|
+
MAGIC = b"BVENC1\x00\x00"
|
|
22
|
+
FORMAT_IDS = {"xz": 1, "gzip": 2, "bzip2": 3, "store": 4}
|
|
23
|
+
_ID_FORMATS = {value: key for key, value in FORMAT_IDS.items()}
|
|
24
|
+
SALT_SIZE = 16
|
|
25
|
+
PREFIX_SIZE = 4
|
|
26
|
+
HEADER_SIZE = len(MAGIC) + 2 + SALT_SIZE + PREFIX_SIZE
|
|
27
|
+
|
|
28
|
+
# scrypt cost: N = 2**log2_n (about 128 MiB of memory at the default), r = 8, p = 1.
|
|
29
|
+
# The value used is stored in the archive header so the default can rise later,
|
|
30
|
+
# and header values outside this range are refused: a damaged or hostile header
|
|
31
|
+
# must not be able to ask for terabytes of memory.
|
|
32
|
+
DEFAULT_LOG2_N = 17
|
|
33
|
+
KDF_LOG2_N_RANGE = (14, 18)
|
|
34
|
+
_SCRYPT_R = 8
|
|
35
|
+
_SCRYPT_P = 1
|
|
36
|
+
|
|
37
|
+
_LENGTH = struct.Struct(">I")
|
|
38
|
+
_INDEX = struct.Struct(">Q")
|
|
39
|
+
# A record holds one compressed block (16 MiB by default); anything near this
|
|
40
|
+
# size is damage, and the limit stops a bad length from allocating gigabytes.
|
|
41
|
+
_MAX_RECORD = 128 * 1024 * 1024
|
|
42
|
+
|
|
43
|
+
INSTALL_HINT = (
|
|
44
|
+
"Encryption and signing need the optional 'cryptography' package: "
|
|
45
|
+
'pip install "batchvault[crypto]"'
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def require_cryptography() -> None:
|
|
50
|
+
try:
|
|
51
|
+
import cryptography # noqa: F401
|
|
52
|
+
except ImportError as exc:
|
|
53
|
+
raise EngineNotFoundError(INSTALL_HINT) from exc
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def make_header(fmt: str) -> bytes:
|
|
57
|
+
return (
|
|
58
|
+
MAGIC
|
|
59
|
+
+ bytes([FORMAT_IDS[fmt], DEFAULT_LOG2_N])
|
|
60
|
+
+ os.urandom(SALT_SIZE + PREFIX_SIZE)
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def is_encrypted_header(data: bytes) -> bool:
|
|
65
|
+
return data.startswith(MAGIC)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def parse_header(data: bytes) -> tuple[str, int, bytes, bytes]:
|
|
69
|
+
"""Return ``(format, log2_n, salt, nonce_prefix)`` from an archive header."""
|
|
70
|
+
if len(data) < HEADER_SIZE or not is_encrypted_header(data):
|
|
71
|
+
raise EngineError("Not an encrypted BatchVault archive.")
|
|
72
|
+
fmt = _ID_FORMATS.get(data[len(MAGIC)])
|
|
73
|
+
if fmt is None:
|
|
74
|
+
raise EngineError("Unknown format in encrypted archive header.")
|
|
75
|
+
|
|
76
|
+
log2_n = data[len(MAGIC) + 1]
|
|
77
|
+
low, high = KDF_LOG2_N_RANGE
|
|
78
|
+
if not low <= log2_n <= high:
|
|
79
|
+
raise EngineError("Unsupported key setting in the encrypted archive header.")
|
|
80
|
+
|
|
81
|
+
start = len(MAGIC) + 2
|
|
82
|
+
salt = data[start : start + SALT_SIZE]
|
|
83
|
+
return fmt, log2_n, salt, data[start + SALT_SIZE : HEADER_SIZE]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class BlockCipher:
|
|
87
|
+
"""Encrypts and decrypts numbered records for one archive."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, password: str, header: bytes):
|
|
90
|
+
require_cryptography()
|
|
91
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
92
|
+
|
|
93
|
+
_, log2_n, salt, prefix = parse_header(header)
|
|
94
|
+
n = 1 << log2_n
|
|
95
|
+
key = hashlib.scrypt(
|
|
96
|
+
password.encode("utf-8"),
|
|
97
|
+
salt=salt,
|
|
98
|
+
n=n,
|
|
99
|
+
r=_SCRYPT_R,
|
|
100
|
+
p=_SCRYPT_P,
|
|
101
|
+
maxmem=130 * n * _SCRYPT_R, # scrypt needs about 128 * n * r bytes
|
|
102
|
+
dklen=32,
|
|
103
|
+
)
|
|
104
|
+
self._aes = AESGCM(key)
|
|
105
|
+
self._prefix = prefix
|
|
106
|
+
self._header = header
|
|
107
|
+
|
|
108
|
+
def _nonce(self, index: int) -> bytes:
|
|
109
|
+
return self._prefix + _INDEX.pack(index)
|
|
110
|
+
|
|
111
|
+
def _aad(self, index: int, last: bool) -> bytes:
|
|
112
|
+
return self._header + _INDEX.pack(index) + (b"\x01" if last else b"\x00")
|
|
113
|
+
|
|
114
|
+
def encrypt(self, index: int, data: bytes, last: bool = False) -> bytes:
|
|
115
|
+
"""Return a length-prefixed encrypted record."""
|
|
116
|
+
sealed = self._aes.encrypt(self._nonce(index), data, self._aad(index, last))
|
|
117
|
+
return _LENGTH.pack(len(sealed)) + sealed
|
|
118
|
+
|
|
119
|
+
def decrypt(self, index: int, sealed: bytes) -> tuple[bytes, bool]:
|
|
120
|
+
"""Return ``(data, is_last)``; raise EngineError on a wrong password."""
|
|
121
|
+
from cryptography.exceptions import InvalidTag
|
|
122
|
+
|
|
123
|
+
for last in (False, True):
|
|
124
|
+
try:
|
|
125
|
+
data = self._aes.decrypt(
|
|
126
|
+
self._nonce(index), sealed, self._aad(index, last)
|
|
127
|
+
)
|
|
128
|
+
except InvalidTag:
|
|
129
|
+
continue
|
|
130
|
+
return data, last
|
|
131
|
+
raise EngineError("Wrong password, or the encrypted archive is damaged.")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _read_exact(source, count: int) -> bytes:
|
|
135
|
+
chunks = []
|
|
136
|
+
while count:
|
|
137
|
+
chunk = source.read(count)
|
|
138
|
+
if not chunk:
|
|
139
|
+
break
|
|
140
|
+
chunks.append(chunk)
|
|
141
|
+
count -= len(chunk)
|
|
142
|
+
return b"".join(chunks)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class DecryptingReader(io.RawIOBase):
|
|
146
|
+
"""Read-only stream of the decrypted (still compressed) block data."""
|
|
147
|
+
|
|
148
|
+
def __init__(self, source, cipher: BlockCipher):
|
|
149
|
+
super().__init__()
|
|
150
|
+
self._source = source
|
|
151
|
+
self._cipher = cipher
|
|
152
|
+
self._buffer = memoryview(b"")
|
|
153
|
+
self._index = 0
|
|
154
|
+
self._done = False
|
|
155
|
+
|
|
156
|
+
def readable(self) -> bool:
|
|
157
|
+
return True
|
|
158
|
+
|
|
159
|
+
def _next_record(self) -> None:
|
|
160
|
+
head = _read_exact(self._source, _LENGTH.size)
|
|
161
|
+
if len(head) < _LENGTH.size:
|
|
162
|
+
raise EngineError("Encrypted archive is truncated (no end marker).")
|
|
163
|
+
|
|
164
|
+
(length,) = _LENGTH.unpack(head)
|
|
165
|
+
if length > _MAX_RECORD:
|
|
166
|
+
raise EngineError("Encrypted archive is damaged (bad record size).")
|
|
167
|
+
|
|
168
|
+
sealed = _read_exact(self._source, length)
|
|
169
|
+
if len(sealed) < length:
|
|
170
|
+
raise EngineError("Encrypted archive is truncated.")
|
|
171
|
+
|
|
172
|
+
data, self._done = self._cipher.decrypt(self._index, sealed)
|
|
173
|
+
self._index += 1
|
|
174
|
+
self._buffer = memoryview(data)
|
|
175
|
+
|
|
176
|
+
def readinto(self, buffer) -> int:
|
|
177
|
+
view = memoryview(buffer).cast("B")
|
|
178
|
+
while not len(self._buffer):
|
|
179
|
+
if self._done:
|
|
180
|
+
return 0
|
|
181
|
+
self._next_record()
|
|
182
|
+
|
|
183
|
+
count = min(len(view), len(self._buffer))
|
|
184
|
+
view[:count] = self._buffer[:count]
|
|
185
|
+
self._buffer = self._buffer[count:]
|
|
186
|
+
return count
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def open_decrypted(source, password: str) -> tuple[str, DecryptingReader]:
|
|
190
|
+
"""Read the header from ``source``; return the inner format and a reader."""
|
|
191
|
+
header = _read_exact(source, HEADER_SIZE)
|
|
192
|
+
fmt = parse_header(header)[0]
|
|
193
|
+
return fmt, DecryptingReader(source, BlockCipher(password, header))
|
batchvault/doctor.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Checks that BatchVault can run on this machine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
from batchvault.engines.sevenzip import find_7zip
|
|
12
|
+
from batchvault.errors import EngineNotFoundError
|
|
13
|
+
from batchvault.logs import default_log_dir
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Check:
|
|
18
|
+
name: str
|
|
19
|
+
ok: bool
|
|
20
|
+
detail: str
|
|
21
|
+
required: bool = False # a failed required check makes doctor exit non-zero
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _module(name: str, label: str, required: bool = False) -> Check:
|
|
25
|
+
try:
|
|
26
|
+
__import__(name)
|
|
27
|
+
except ImportError:
|
|
28
|
+
return Check(label, False, "not available in this Python build", required)
|
|
29
|
+
return Check(label, True, "available", required)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _log_dir_check() -> Check:
|
|
33
|
+
folder = default_log_dir()
|
|
34
|
+
try:
|
|
35
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
with tempfile.NamedTemporaryFile(dir=folder):
|
|
37
|
+
pass
|
|
38
|
+
except OSError as exc:
|
|
39
|
+
return Check("Log folder", False, f"{folder} is not writable: {exc}")
|
|
40
|
+
return Check("Log folder", True, str(folder))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def run_checks(seven_zip: str | None = None) -> list[Check]:
|
|
44
|
+
checks = [
|
|
45
|
+
Check(
|
|
46
|
+
"Python",
|
|
47
|
+
sys.version_info >= (3, 11, 4), # safe tar extraction filters
|
|
48
|
+
f"{platform.python_version()} on {platform.system()}",
|
|
49
|
+
required=True,
|
|
50
|
+
),
|
|
51
|
+
_module("zlib", "zlib (gzip, store)", required=True),
|
|
52
|
+
_module("lzma", "lzma (xz format)"),
|
|
53
|
+
_module("bz2", "bz2 (bzip2 format)"),
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
checks.append(Check("7-Zip (optional)", True, str(find_7zip(seven_zip))))
|
|
58
|
+
except EngineNotFoundError as exc:
|
|
59
|
+
checks.append(Check("7-Zip (optional)", False, str(exc)))
|
|
60
|
+
|
|
61
|
+
checks.append(_log_dir_check())
|
|
62
|
+
checks.append(Check("CPU threads", True, str(os.cpu_count() or 1)))
|
|
63
|
+
return checks
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def passed(checks: list[Check]) -> bool:
|
|
67
|
+
return all(check.ok for check in checks if check.required)
|
|
File without changes
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from batchvault.options import PackOptions
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class Capabilities:
|
|
12
|
+
"""What an engine supports, used to warn about unsupported settings."""
|
|
13
|
+
|
|
14
|
+
levels: range
|
|
15
|
+
threads_per_file: bool
|
|
16
|
+
archive_suffix: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Engine(ABC):
|
|
20
|
+
"""Common interface implemented by every compression engine."""
|
|
21
|
+
|
|
22
|
+
name: str
|
|
23
|
+
capabilities: Capabilities
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def is_available(self) -> bool:
|
|
27
|
+
"""True if the engine can run on this machine."""
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def pack(self, source: Path, dest_dir: Path, options: PackOptions) -> list[Path]:
|
|
31
|
+
"""Archive ``source`` into volumes inside ``dest_dir``; return the volumes.
|
|
32
|
+
|
|
33
|
+
Encrypts when ``options.password`` is set. Raises OutputExistsError if
|
|
34
|
+
volumes already exist and ``options.force`` is false. Existing volumes
|
|
35
|
+
are only removed once the new set is complete.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def unpack(
|
|
40
|
+
self,
|
|
41
|
+
volume: Path,
|
|
42
|
+
output_dir: Path,
|
|
43
|
+
*,
|
|
44
|
+
force: bool = False,
|
|
45
|
+
password: str | None = None,
|
|
46
|
+
max_bytes: int | None = None,
|
|
47
|
+
) -> list[Path]:
|
|
48
|
+
"""Restore the files in the volume set containing ``volume``.
|
|
49
|
+
|
|
50
|
+
Raises OutputExistsError if a destination exists and ``force`` is false.
|
|
51
|
+
With ``max_bytes``, an archive that would expand beyond that many bytes
|
|
52
|
+
is refused before anything is written.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
@abstractmethod
|
|
56
|
+
def test(self, volume: Path, *, password: str | None = None) -> None:
|
|
57
|
+
"""Check the volume set's integrity; raise EngineError if it fails."""
|