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/filters.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pathspec
|
|
7
|
+
|
|
8
|
+
from batchvault.errors import InputError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def load_patterns(values: Iterable[str]) -> list[str]:
|
|
12
|
+
"""Expand ``@file`` references into the patterns listed in that file."""
|
|
13
|
+
patterns = []
|
|
14
|
+
for value in values:
|
|
15
|
+
if value.startswith("@"):
|
|
16
|
+
path = Path(value[1:]).expanduser()
|
|
17
|
+
try:
|
|
18
|
+
lines = path.read_text(encoding="utf-8-sig").splitlines()
|
|
19
|
+
except OSError as exc:
|
|
20
|
+
raise InputError(f"Cannot read pattern file {path}: {exc}") from exc
|
|
21
|
+
patterns.extend(
|
|
22
|
+
line.strip()
|
|
23
|
+
for line in lines
|
|
24
|
+
if line.strip() and not line.lstrip().startswith("#")
|
|
25
|
+
)
|
|
26
|
+
elif value.strip():
|
|
27
|
+
patterns.append(value.strip())
|
|
28
|
+
return patterns
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _spec(patterns: list[str]):
|
|
32
|
+
return pathspec.GitIgnoreSpec.from_lines(patterns) if patterns else None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class PathFilter:
|
|
36
|
+
"""Include/exclude filter using gitignore-style patterns (``*``, ``**``, ``/``).
|
|
37
|
+
|
|
38
|
+
Exclude wins. If any include pattern is given, only matching paths are kept.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, include: Iterable[str] = (), exclude: Iterable[str] = ()):
|
|
42
|
+
self._include = _spec(load_patterns(include))
|
|
43
|
+
self._exclude = _spec(load_patterns(exclude))
|
|
44
|
+
|
|
45
|
+
def matches(self, path: str) -> bool:
|
|
46
|
+
path = path.replace("\\", "/").lstrip("/")
|
|
47
|
+
if self._exclude and self._exclude.match_file(path):
|
|
48
|
+
return False
|
|
49
|
+
if self._include and not self._include.match_file(path):
|
|
50
|
+
return False
|
|
51
|
+
return True
|
batchvault/inputs.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from batchvault.errors import InputError
|
|
10
|
+
from batchvault.filters import PathFilter
|
|
11
|
+
from batchvault.manifest import MANIFEST_NAME
|
|
12
|
+
from batchvault.volumes import split_volume_name
|
|
13
|
+
|
|
14
|
+
# A text list written by ``list files`` starts with this comment. It records the
|
|
15
|
+
# folder the files were listed from, so ``pack`` keeps that folder's name.
|
|
16
|
+
ROOT_HEADER = "# batchvault-root:"
|
|
17
|
+
|
|
18
|
+
ListItem = tuple[str, str, str | None] # (label, path, root the list recorded)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def read_list_items(list_path: Path) -> list[ListItem]:
|
|
22
|
+
"""Read a list file as ``(label, path, root)`` items.
|
|
23
|
+
|
|
24
|
+
Text lists have one path per line; blank lines and ``#`` comments are
|
|
25
|
+
ignored, except the ``# batchvault-root:`` header written by ``list files``.
|
|
26
|
+
JSON lists are an array of paths or ``{"path": ..., "root": ...}`` objects (as
|
|
27
|
+
printed by ``batchvault list files``), or an object with such an array under
|
|
28
|
+
``"files"``. ``root`` is the folder the file was listed from, if recorded.
|
|
29
|
+
"""
|
|
30
|
+
if str(list_path) == "-":
|
|
31
|
+
list_path = Path("<stdin>")
|
|
32
|
+
text = sys.stdin.read().lstrip("")
|
|
33
|
+
else:
|
|
34
|
+
try:
|
|
35
|
+
text = list_path.read_text(encoding="utf-8-sig")
|
|
36
|
+
except OSError as exc:
|
|
37
|
+
raise InputError(f"Cannot read list file {list_path}: {exc}") from exc
|
|
38
|
+
|
|
39
|
+
if list_path.suffix.lower() == ".json" or text.lstrip().startswith(("[", "{")):
|
|
40
|
+
return _json_items(list_path, text)
|
|
41
|
+
|
|
42
|
+
items = []
|
|
43
|
+
root = None
|
|
44
|
+
for number, line in enumerate(text.splitlines(), start=1):
|
|
45
|
+
entry = line.strip()
|
|
46
|
+
if entry.startswith(ROOT_HEADER):
|
|
47
|
+
root = entry[len(ROOT_HEADER) :].strip() or None
|
|
48
|
+
elif entry and not entry.startswith("#"):
|
|
49
|
+
items.append((f"{list_path}:{number}", entry, root))
|
|
50
|
+
return items
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def read_list_entries(list_path: Path) -> list[tuple[str, str]]:
|
|
54
|
+
"""Read a list file as ``(label, path)`` pairs (see ``read_list_items``)."""
|
|
55
|
+
return [(label, entry) for label, entry, _ in read_list_items(list_path)]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _json_items(list_path: Path, text: str) -> list[ListItem]:
|
|
59
|
+
try:
|
|
60
|
+
data = json.loads(text)
|
|
61
|
+
except json.JSONDecodeError as exc:
|
|
62
|
+
raise InputError(f"{list_path} is not valid JSON: {exc}") from exc
|
|
63
|
+
|
|
64
|
+
default_root = None
|
|
65
|
+
if isinstance(data, dict):
|
|
66
|
+
default_root = data.get("root") if isinstance(data.get("root"), str) else None
|
|
67
|
+
data = data.get("files")
|
|
68
|
+
if not isinstance(data, list):
|
|
69
|
+
raise InputError(f"{list_path}: expected a JSON array of paths")
|
|
70
|
+
|
|
71
|
+
items = []
|
|
72
|
+
for index, item in enumerate(data, start=1):
|
|
73
|
+
value = item.get("path") if isinstance(item, dict) else item
|
|
74
|
+
if not isinstance(value, str) or not value.strip():
|
|
75
|
+
raise InputError(f"{list_path}: item {index} has no path")
|
|
76
|
+
root = item.get("root") if isinstance(item, dict) else None
|
|
77
|
+
if not isinstance(root, str) or not root.strip():
|
|
78
|
+
root = default_root
|
|
79
|
+
items.append((f"{list_path}[{index}]", value.strip(), root))
|
|
80
|
+
return items
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def relative_folder(path: Path, root: Path) -> str:
|
|
84
|
+
"""The folder of ``path`` relative to ``root``, with forward slashes.
|
|
85
|
+
|
|
86
|
+
Returns ``""`` when the file sits directly in ``root``.
|
|
87
|
+
"""
|
|
88
|
+
try:
|
|
89
|
+
relative = path.parent.relative_to(root)
|
|
90
|
+
except ValueError:
|
|
91
|
+
raise InputError(
|
|
92
|
+
f"{path} is not inside the root folder {root}. Choose a root that "
|
|
93
|
+
"contains all the files, or leave --root out."
|
|
94
|
+
) from None
|
|
95
|
+
return relative.as_posix() if relative.parts else ""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def assign_folders(
|
|
99
|
+
items: list[tuple[Path, Path | None]], root: Path | None = None
|
|
100
|
+
) -> dict[Path, str]:
|
|
101
|
+
"""Decide the folder each file is stored under (``""`` = not in a subfolder).
|
|
102
|
+
|
|
103
|
+
- With an explicit ``root``, every file is stored relative to it.
|
|
104
|
+
- A file from a ``list files`` list is stored relative to the folder that
|
|
105
|
+
listing recorded, so the listed folder's own name is kept.
|
|
106
|
+
- Other files are stored relative to the deepest folder they all share, so the
|
|
107
|
+
folders below it are kept and files directly in it are not nested.
|
|
108
|
+
"""
|
|
109
|
+
if root is not None:
|
|
110
|
+
return {path: relative_folder(path, root) for path, _ in items}
|
|
111
|
+
|
|
112
|
+
folders: dict[Path, str] = {}
|
|
113
|
+
unlisted = []
|
|
114
|
+
for path, recorded in items:
|
|
115
|
+
if recorded is not None:
|
|
116
|
+
try:
|
|
117
|
+
folders[path] = relative_folder(path, recorded)
|
|
118
|
+
continue
|
|
119
|
+
except InputError:
|
|
120
|
+
pass # the list was edited since it was made; use the shared folder
|
|
121
|
+
unlisted.append(path)
|
|
122
|
+
|
|
123
|
+
if unlisted:
|
|
124
|
+
try:
|
|
125
|
+
shared = Path(os.path.commonpath([str(p.parent) for p in unlisted]))
|
|
126
|
+
except ValueError:
|
|
127
|
+
raise InputError(
|
|
128
|
+
"These files are on different drives, so there is no folder they "
|
|
129
|
+
"share. Archive each drive separately, or pass --root."
|
|
130
|
+
) from None
|
|
131
|
+
for path in unlisted:
|
|
132
|
+
folders[path] = relative_folder(path, shared)
|
|
133
|
+
return folders
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def looks_like_archive(path: Path) -> bool:
|
|
137
|
+
return split_volume_name(path.name) is not None or path.name == MANIFEST_NAME
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def gather_sources(
|
|
141
|
+
files: Iterable[str | Path] = (),
|
|
142
|
+
list_file: str | Path | None = None,
|
|
143
|
+
*,
|
|
144
|
+
path_filter: PathFilter | None = None,
|
|
145
|
+
allow_archives: bool = False,
|
|
146
|
+
root: Path | None = None,
|
|
147
|
+
) -> list[tuple[Path, str]]:
|
|
148
|
+
"""Turn arguments and/or a list file into unique, existing source files.
|
|
149
|
+
|
|
150
|
+
Returns ``(file, folder)`` pairs, where ``folder`` is the folder the file is
|
|
151
|
+
stored under (see ``assign_folders``). Relative paths are resolved from the
|
|
152
|
+
current directory. Files with the same name are fine when their folders
|
|
153
|
+
differ.
|
|
154
|
+
"""
|
|
155
|
+
if root is not None:
|
|
156
|
+
root = Path(root).expanduser().resolve()
|
|
157
|
+
|
|
158
|
+
items: list[ListItem] = [("argument", str(file), None) for file in files]
|
|
159
|
+
if list_file is not None:
|
|
160
|
+
items += read_list_items(
|
|
161
|
+
Path(list_file) if str(list_file) == "-" else Path(list_file).expanduser()
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
sources: dict[str, tuple[Path, Path | None]] = {}
|
|
165
|
+
|
|
166
|
+
for label, entry, recorded in items:
|
|
167
|
+
path = Path(entry).expanduser()
|
|
168
|
+
if not path.is_absolute():
|
|
169
|
+
path = Path.cwd() / path
|
|
170
|
+
if not path.is_file():
|
|
171
|
+
raise InputError(f"{label}: source file not found: {entry}")
|
|
172
|
+
|
|
173
|
+
path = path.resolve()
|
|
174
|
+
if looks_like_archive(path) and not allow_archives:
|
|
175
|
+
raise InputError(
|
|
176
|
+
f"{label}: {path.name} looks like an archive volume or manifest. "
|
|
177
|
+
"Use allow_archives / --allow-archives to archive it anyway."
|
|
178
|
+
)
|
|
179
|
+
if path_filter and not path_filter.matches(path.as_posix()):
|
|
180
|
+
continue
|
|
181
|
+
|
|
182
|
+
key = os.path.normcase(str(path))
|
|
183
|
+
if key not in sources:
|
|
184
|
+
hint = Path(recorded).expanduser().resolve() if recorded else None
|
|
185
|
+
sources[key] = (path, hint)
|
|
186
|
+
|
|
187
|
+
if not sources:
|
|
188
|
+
raise InputError("No input files to archive.")
|
|
189
|
+
|
|
190
|
+
folders = assign_folders(list(sources.values()), root)
|
|
191
|
+
|
|
192
|
+
# Each file gets its own archive folder named after it, inside its stored
|
|
193
|
+
# folder; two files may not end up in the same place.
|
|
194
|
+
taken: dict[tuple[str, str], Path] = {}
|
|
195
|
+
for path, _ in sources.values():
|
|
196
|
+
place = (folders[path].casefold(), path.name.casefold())
|
|
197
|
+
if place in taken:
|
|
198
|
+
raise InputError(
|
|
199
|
+
"Two source files would use the same archive folder: "
|
|
200
|
+
f"{taken[place]} and {path}. Archive them separately, or keep "
|
|
201
|
+
"their folders apart with --root."
|
|
202
|
+
)
|
|
203
|
+
taken[place] = path
|
|
204
|
+
|
|
205
|
+
return [(path, folders[path]) for path, _ in sources.values()]
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def collect_sources(
|
|
209
|
+
files: Iterable[str | Path] = (),
|
|
210
|
+
list_file: str | Path | None = None,
|
|
211
|
+
*,
|
|
212
|
+
path_filter: PathFilter | None = None,
|
|
213
|
+
allow_archives: bool = False,
|
|
214
|
+
root: Path | None = None,
|
|
215
|
+
) -> list[Path]:
|
|
216
|
+
"""Like ``gather_sources``, returning only the files."""
|
|
217
|
+
return [
|
|
218
|
+
path
|
|
219
|
+
for path, _ in gather_sources(
|
|
220
|
+
files,
|
|
221
|
+
list_file,
|
|
222
|
+
path_filter=path_filter,
|
|
223
|
+
allow_archives=allow_archives,
|
|
224
|
+
root=root,
|
|
225
|
+
)
|
|
226
|
+
]
|
batchvault/levels.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Pick a compression level for each file from its type (``--level auto``).
|
|
2
|
+
|
|
3
|
+
The choice uses only the file's signature (its first bytes) and extension, so it
|
|
4
|
+
is instant and predictable. Level 0 means "do not try": the file is already
|
|
5
|
+
compressed, so a stronger level would only cost time and memory.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from batchvault.errors import OptionsError
|
|
15
|
+
|
|
16
|
+
AUTO = "auto"
|
|
17
|
+
DEFAULT_LEVEL = 5
|
|
18
|
+
|
|
19
|
+
TEXT_LEVEL = 6
|
|
20
|
+
MEDIA_LEVEL = 6
|
|
21
|
+
BINARY_LEVEL = 5
|
|
22
|
+
PDF_LEVEL = 3
|
|
23
|
+
INCOMPRESSIBLE_LEVEL = 0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class LevelChoice:
|
|
28
|
+
level: int
|
|
29
|
+
kind: str # a short reason, shown next to the result
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def parse_level(value: int | str) -> int | str:
|
|
33
|
+
"""Validate a level: 0 to 9, or ``"auto"``."""
|
|
34
|
+
if isinstance(value, str):
|
|
35
|
+
text = value.strip().lower()
|
|
36
|
+
if text == AUTO:
|
|
37
|
+
return AUTO
|
|
38
|
+
try:
|
|
39
|
+
value = int(text)
|
|
40
|
+
except ValueError:
|
|
41
|
+
raise OptionsError(
|
|
42
|
+
f"Invalid compression level {value!r}. Use 0-9 or auto."
|
|
43
|
+
) from None
|
|
44
|
+
|
|
45
|
+
if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= 9:
|
|
46
|
+
raise OptionsError("Compression level must be between 0 and 9, or auto.")
|
|
47
|
+
return value
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def level_argument(text: str) -> int | str:
|
|
51
|
+
"""argparse type for ``--level``; the range is checked by ``choices``."""
|
|
52
|
+
value = text.strip().lower()
|
|
53
|
+
if value == AUTO:
|
|
54
|
+
return AUTO
|
|
55
|
+
try:
|
|
56
|
+
return int(value)
|
|
57
|
+
except ValueError:
|
|
58
|
+
raise argparse.ArgumentTypeError(
|
|
59
|
+
f"invalid level {text!r}: use 0-9 or auto"
|
|
60
|
+
) from None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _extensions(text: str) -> frozenset[str]:
|
|
64
|
+
return frozenset(text.split())
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# Formats that are already compressed (or encrypted): nothing left to squeeze.
|
|
68
|
+
INCOMPRESSIBLE = _extensions(
|
|
69
|
+
"""
|
|
70
|
+
zip 7z rar gz tgz xz txz bz2 tbz2 zst lz4 lzma z cab jar war ear apk ipa
|
|
71
|
+
docx xlsx pptx odt ods odp epub
|
|
72
|
+
jpg jpeg png gif webp heic heif avif cr2 nef arw dng orf rw2
|
|
73
|
+
mp4 m4v mov mkv webm avi wmv flv mpg mpeg m2ts 3gp
|
|
74
|
+
mp3 aac m4a ogg oga opus flac wma wv
|
|
75
|
+
"""
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Plain text and similar: very repetitive, so a strong level pays off.
|
|
79
|
+
TEXT = _extensions(
|
|
80
|
+
"""
|
|
81
|
+
txt log csv tsv json jsonl ndjson xml html htm xhtml css scss sass less
|
|
82
|
+
js mjs jsx tsx md markdown rst yaml yml toml ini cfg conf properties sql
|
|
83
|
+
py pyi java kt c h cc cpp hpp cs go rs rb php swift sh bash zsh bat cmd ps1
|
|
84
|
+
tex bib srt vtt ass svg rtf eml ics vcf gpx kml diff patch gradle tf
|
|
85
|
+
ipynb r pl lua dart scala vue
|
|
86
|
+
"""
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Uncompressed pictures and sound.
|
|
90
|
+
UNCOMPRESSED_MEDIA = _extensions(
|
|
91
|
+
"wav aiff aif bmp tif tiff ppm pgm pbm tga dds pcm psd"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# Programs, disk images, databases and containers: compressible, but the
|
|
95
|
+
# strongest levels gain little for the memory they need.
|
|
96
|
+
BINARY = _extensions(
|
|
97
|
+
"""
|
|
98
|
+
exe dll so dylib o a lib class pyc wasm iso img vhd vhdx vmdk qcow2 vdi
|
|
99
|
+
db sqlite sqlite3 mdb bak dump pak tar
|
|
100
|
+
"""
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
PDF = _extensions("pdf")
|
|
104
|
+
|
|
105
|
+
# (offset, bytes) at the start of a file that mark an already compressed format.
|
|
106
|
+
_SIGNATURES = (
|
|
107
|
+
(0, b"PK\x03\x04"), # zip, docx, jar, apk, epub, ...
|
|
108
|
+
(0, b"PK\x05\x06"),
|
|
109
|
+
(0, b"\x1f\x8b"), # gzip
|
|
110
|
+
(0, b"\xfd7zXZ\x00"), # xz
|
|
111
|
+
(0, b"7z\xbc\xaf\x27\x1c"),
|
|
112
|
+
(0, b"Rar!\x1a\x07"),
|
|
113
|
+
(0, b"\x28\xb5\x2f\xfd"), # zstd
|
|
114
|
+
(0, b"\x04\x22\x4d\x18"), # lz4
|
|
115
|
+
(0, b"MSCF"), # cab
|
|
116
|
+
(0, b"\xff\xd8\xff"), # jpeg
|
|
117
|
+
(0, b"\x89PNG\r\n\x1a\n"),
|
|
118
|
+
(0, b"GIF87a"),
|
|
119
|
+
(0, b"GIF89a"),
|
|
120
|
+
(4, b"ftyp"), # mp4, mov, heic, m4a
|
|
121
|
+
(0, b"\x1a\x45\xdf\xa3"), # matroska, webm
|
|
122
|
+
(0, b"OggS"),
|
|
123
|
+
(0, b"fLaC"),
|
|
124
|
+
(0, b"\x00\x00\x01\xba"), # mpeg program stream
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _has_compressed_signature(head: bytes) -> bool:
|
|
129
|
+
if any(head[at : at + len(mark)] == mark for at, mark in _SIGNATURES):
|
|
130
|
+
return True
|
|
131
|
+
if head[:4] == b"RIFF" and head[8:12] == b"WEBP":
|
|
132
|
+
return True
|
|
133
|
+
# bzip2: "BZh", a block size digit and the block magic. The longer check keeps
|
|
134
|
+
# a text file that happens to start with "BZh" from being misjudged.
|
|
135
|
+
if head[:3] == b"BZh" and head[3:4] in b"123456789" and head[4:10] == b"1AY&SY":
|
|
136
|
+
return True
|
|
137
|
+
# ID3v2 tag (mp3): version, flags, then a size whose bytes are below 0x80.
|
|
138
|
+
return (
|
|
139
|
+
head[:3] == b"ID3"
|
|
140
|
+
and head[3:4] in (b"\x02", b"\x03", b"\x04")
|
|
141
|
+
and head[4:5] == b"\x00"
|
|
142
|
+
and len(head) >= 10
|
|
143
|
+
and all(byte < 0x80 for byte in head[6:10])
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def choose_level(path: Path) -> LevelChoice:
|
|
148
|
+
"""The best compression level for this file, judged by its type."""
|
|
149
|
+
try:
|
|
150
|
+
with path.open("rb") as handle:
|
|
151
|
+
head = handle.read(16)
|
|
152
|
+
except OSError:
|
|
153
|
+
head = b""
|
|
154
|
+
|
|
155
|
+
if _has_compressed_signature(head):
|
|
156
|
+
return LevelChoice(INCOMPRESSIBLE_LEVEL, "already compressed")
|
|
157
|
+
|
|
158
|
+
extension = path.suffix.lower().lstrip(".")
|
|
159
|
+
if extension in INCOMPRESSIBLE:
|
|
160
|
+
return LevelChoice(INCOMPRESSIBLE_LEVEL, "already compressed")
|
|
161
|
+
if extension in TEXT:
|
|
162
|
+
return LevelChoice(TEXT_LEVEL, "text")
|
|
163
|
+
if extension in UNCOMPRESSED_MEDIA:
|
|
164
|
+
return LevelChoice(MEDIA_LEVEL, "uncompressed media")
|
|
165
|
+
if extension in BINARY:
|
|
166
|
+
return LevelChoice(BINARY_LEVEL, "binary data")
|
|
167
|
+
if extension in PDF:
|
|
168
|
+
return LevelChoice(PDF_LEVEL, "pdf")
|
|
169
|
+
return LevelChoice(DEFAULT_LEVEL, "unknown type")
|
batchvault/locate.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Work out which archives a path given to unpack, verify or list refers to."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from batchvault.errors import InputError, ManifestError
|
|
10
|
+
from batchvault.inputs import read_list_entries
|
|
11
|
+
from batchvault.manifest import MANIFEST_NAME, Manifest, ManifestEntry
|
|
12
|
+
from batchvault.volumes import split_volume_name
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Target:
|
|
17
|
+
"""One archive to process. ``entry`` is None when no manifest is available."""
|
|
18
|
+
|
|
19
|
+
volume: Path # first volume (or the given one when there is no manifest)
|
|
20
|
+
entry: ManifestEntry | None = None
|
|
21
|
+
base_dir: Path | None = None # folder the entry's volume paths are relative to
|
|
22
|
+
manifest: Path | None = None # the manifest file the entry came from
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def name(self) -> str:
|
|
26
|
+
if self.entry:
|
|
27
|
+
return self.entry.name
|
|
28
|
+
base = split_volume_name(self.volume.name)[0]
|
|
29
|
+
return base.rsplit(".", 1)[0]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _same(a: Path, b: Path) -> bool:
|
|
33
|
+
try:
|
|
34
|
+
return a.resolve() == b.resolve()
|
|
35
|
+
except OSError:
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _find_manifest(*folders: Path) -> Path | None:
|
|
40
|
+
for folder in folders:
|
|
41
|
+
candidate = folder / MANIFEST_NAME
|
|
42
|
+
if candidate.is_file():
|
|
43
|
+
return candidate
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
Loader = Callable[[Path], Manifest]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _manifest_targets(
|
|
51
|
+
manifest_path: Path, under: Path | None = None, load: Loader = Manifest.load
|
|
52
|
+
) -> list[Target]:
|
|
53
|
+
manifest = load(manifest_path)
|
|
54
|
+
base = manifest_path.parent
|
|
55
|
+
|
|
56
|
+
targets = []
|
|
57
|
+
for entry in manifest.entries.values():
|
|
58
|
+
first = base / entry.volumes[0].file
|
|
59
|
+
if under is None or _is_under(first, under):
|
|
60
|
+
targets.append(Target(first, entry, base, manifest_path))
|
|
61
|
+
return targets
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _is_under(path: Path, folder: Path) -> bool:
|
|
65
|
+
try:
|
|
66
|
+
return path.resolve().is_relative_to(folder.resolve())
|
|
67
|
+
except OSError:
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _volume_target(volume: Path, load: Loader = Manifest.load) -> Target:
|
|
72
|
+
"""Target for one volume path, using a nearby manifest when it knows it."""
|
|
73
|
+
if split_volume_name(volume.name) is None:
|
|
74
|
+
raise InputError(f"Not an archive volume: {volume}")
|
|
75
|
+
if not volume.is_file():
|
|
76
|
+
raise InputError(f"Archive volume not found: {volume}")
|
|
77
|
+
|
|
78
|
+
manifest_path = _find_manifest(volume.parent, volume.parent.parent)
|
|
79
|
+
if manifest_path:
|
|
80
|
+
base = manifest_path.parent
|
|
81
|
+
for entry in load(manifest_path).entries.values():
|
|
82
|
+
if any(_same(base / v.file, volume) for v in entry.volumes):
|
|
83
|
+
return Target(base / entry.volumes[0].file, entry, base, manifest_path)
|
|
84
|
+
|
|
85
|
+
return Target(volume)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def resolve_targets(
|
|
89
|
+
source: str | Path, load: Loader = Manifest.load
|
|
90
|
+
) -> list[Target]:
|
|
91
|
+
"""Archives referred to by a manifest, folder, volume file or list file.
|
|
92
|
+
|
|
93
|
+
Every manifest is read through ``load``, so a caller can verify a manifest's
|
|
94
|
+
signature on the very bytes that are then parsed.
|
|
95
|
+
"""
|
|
96
|
+
path = Path(source).expanduser()
|
|
97
|
+
if not path.exists():
|
|
98
|
+
raise InputError(f"Path not found: {path}")
|
|
99
|
+
|
|
100
|
+
if path.is_dir():
|
|
101
|
+
manifest_path = _find_manifest(path, path.parent)
|
|
102
|
+
if manifest_path:
|
|
103
|
+
under = None if manifest_path.parent == path else path
|
|
104
|
+
targets = _manifest_targets(manifest_path, under, load)
|
|
105
|
+
else:
|
|
106
|
+
targets = [
|
|
107
|
+
Target(found)
|
|
108
|
+
for found in sorted(path.rglob("*"))
|
|
109
|
+
if found.is_file()
|
|
110
|
+
and (parsed := split_volume_name(found.name))
|
|
111
|
+
and parsed[1] == 1
|
|
112
|
+
]
|
|
113
|
+
if not targets:
|
|
114
|
+
raise InputError(f"No archives found in {path}")
|
|
115
|
+
return targets
|
|
116
|
+
|
|
117
|
+
if split_volume_name(path.name):
|
|
118
|
+
return [_volume_target(path, load)]
|
|
119
|
+
|
|
120
|
+
if path.name == MANIFEST_NAME or path.suffix.lower() == ".json":
|
|
121
|
+
try:
|
|
122
|
+
return _manifest_targets(path, None, load)
|
|
123
|
+
except ManifestError:
|
|
124
|
+
# A JSON list of volume paths is also accepted; fall through.
|
|
125
|
+
if path.name == MANIFEST_NAME:
|
|
126
|
+
raise
|
|
127
|
+
|
|
128
|
+
targets = []
|
|
129
|
+
for label, entry in read_list_entries(path):
|
|
130
|
+
volume = Path(entry).expanduser()
|
|
131
|
+
if not volume.is_absolute():
|
|
132
|
+
volume = path.parent / volume
|
|
133
|
+
try:
|
|
134
|
+
targets.append(_volume_target(volume, load))
|
|
135
|
+
except InputError as exc:
|
|
136
|
+
raise InputError(f"{label}: {exc}") from exc
|
|
137
|
+
|
|
138
|
+
if not targets:
|
|
139
|
+
raise InputError(f"No archive paths found in {path}")
|
|
140
|
+
return targets
|
batchvault/logs.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from batchvault.security import redact, restrict_permissions
|
|
8
|
+
|
|
9
|
+
LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR")
|
|
10
|
+
DEFAULT_LEVEL = "INFO"
|
|
11
|
+
LOGGER_NAME = "batchvault"
|
|
12
|
+
ENV_LOG_DIR = "BATCHVAULT_LOG_DIR"
|
|
13
|
+
KEEP_LOGS = 20
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RedactingFormatter(logging.Formatter):
|
|
17
|
+
"""Hides credentials embedded in URLs, including in tracebacks."""
|
|
18
|
+
|
|
19
|
+
def format(self, record):
|
|
20
|
+
return redact(super().format(record))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def default_log_dir() -> Path:
|
|
24
|
+
"""The conventional per-user log folder for the current OS."""
|
|
25
|
+
override = os.environ.get(ENV_LOG_DIR)
|
|
26
|
+
if override:
|
|
27
|
+
return Path(override).expanduser()
|
|
28
|
+
|
|
29
|
+
home = Path.home()
|
|
30
|
+
|
|
31
|
+
if sys.platform == "win32":
|
|
32
|
+
base = Path(os.environ.get("LOCALAPPDATA") or home / "AppData" / "Local")
|
|
33
|
+
return base / "BatchVault" / "Logs"
|
|
34
|
+
if sys.platform == "darwin":
|
|
35
|
+
return home / "Library" / "Logs" / "BatchVault"
|
|
36
|
+
|
|
37
|
+
state = os.environ.get("XDG_STATE_HOME")
|
|
38
|
+
return (Path(state) if state else home / ".local" / "state") / "batchvault"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def prune_logs(directory: Path, keep: int = KEEP_LOGS) -> None:
|
|
42
|
+
"""Delete the oldest per-run log files beyond ``keep``."""
|
|
43
|
+
try:
|
|
44
|
+
logs = sorted(directory.glob("batchvault-*.log"))
|
|
45
|
+
for old in logs[:-keep] if keep else logs:
|
|
46
|
+
old.unlink(missing_ok=True)
|
|
47
|
+
except OSError:
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def setup_logging(level=DEFAULT_LEVEL, log_file=None):
|
|
52
|
+
"""Log to a file for this run. Returns its path, or None if it can't be opened."""
|
|
53
|
+
logger = logging.getLogger(LOGGER_NAME)
|
|
54
|
+
logger.setLevel(getattr(logging, level.upper()))
|
|
55
|
+
close_logging()
|
|
56
|
+
|
|
57
|
+
if log_file:
|
|
58
|
+
path = Path(log_file).expanduser()
|
|
59
|
+
else:
|
|
60
|
+
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
61
|
+
path = default_log_dir() / f"batchvault-{stamp}-{os.getpid()}.log"
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
handler = logging.FileHandler(path, encoding="utf-8")
|
|
66
|
+
except OSError:
|
|
67
|
+
logger.addHandler(logging.NullHandler())
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
restrict_permissions(path)
|
|
71
|
+
handler.setFormatter(
|
|
72
|
+
RedactingFormatter("%(asctime)s %(levelname)-7s %(name)s: %(message)s")
|
|
73
|
+
)
|
|
74
|
+
logger.addHandler(handler)
|
|
75
|
+
|
|
76
|
+
if not log_file:
|
|
77
|
+
prune_logs(path.parent)
|
|
78
|
+
return path.resolve()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def close_logging() -> None:
|
|
82
|
+
logger = logging.getLogger(LOGGER_NAME)
|
|
83
|
+
for handler in list(logger.handlers):
|
|
84
|
+
handler.close()
|
|
85
|
+
logger.removeHandler(handler)
|
|
86
|
+
logger.addHandler(logging.NullHandler())
|