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/manifest.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from batchvault import __version__
|
|
12
|
+
from batchvault.errors import ManifestError
|
|
13
|
+
from batchvault.names import validate_folder
|
|
14
|
+
|
|
15
|
+
SCHEMA_VERSION = 1
|
|
16
|
+
MANIFEST_NAME = "manifest.json"
|
|
17
|
+
_CHUNK = 1024 * 1024
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def hash_file(path: Path) -> str:
|
|
21
|
+
digest = hashlib.sha256()
|
|
22
|
+
with path.open("rb") as handle:
|
|
23
|
+
while chunk := handle.read(_CHUNK):
|
|
24
|
+
digest.update(chunk)
|
|
25
|
+
return digest.hexdigest()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _require(data: dict, key: str, kind: type, where: str):
|
|
29
|
+
value = data.get(key)
|
|
30
|
+
# bool is an int subclass; never accept it as a number.
|
|
31
|
+
if not isinstance(value, kind) or isinstance(value, bool) and kind is int:
|
|
32
|
+
raise ManifestError(f"{where}: '{key}' is missing or not a {kind.__name__}")
|
|
33
|
+
return value
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _folder(value, where: str) -> str:
|
|
37
|
+
if not isinstance(value, str):
|
|
38
|
+
raise ManifestError(f"{where}: 'folder' must be a string")
|
|
39
|
+
try:
|
|
40
|
+
return validate_folder(value)
|
|
41
|
+
except ValueError as exc:
|
|
42
|
+
raise ManifestError(f"{where}: {exc}") from exc
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class VolumeInfo:
|
|
47
|
+
file: str # relative to the manifest folder, with forward slashes
|
|
48
|
+
size: int
|
|
49
|
+
sha256: str
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_dict(cls, data, where: str) -> VolumeInfo:
|
|
53
|
+
if not isinstance(data, dict):
|
|
54
|
+
raise ManifestError(f"{where}: volume entry must be an object")
|
|
55
|
+
return cls(
|
|
56
|
+
file=_require(data, "file", str, where),
|
|
57
|
+
size=_require(data, "size", int, where),
|
|
58
|
+
sha256=_require(data, "sha256", str, where),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class ManifestEntry:
|
|
64
|
+
name: str
|
|
65
|
+
size: int
|
|
66
|
+
sha256: str
|
|
67
|
+
engine: str
|
|
68
|
+
format: str
|
|
69
|
+
level: int
|
|
70
|
+
volume_size: int
|
|
71
|
+
volumes: list[VolumeInfo] = field(default_factory=list)
|
|
72
|
+
encrypted: bool = False
|
|
73
|
+
mtime_ns: int = 0 # source modification time when archived; 0 if unknown
|
|
74
|
+
folder: str = "" # where the file lived, relative to --root ("" = flat)
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def path(self) -> str:
|
|
78
|
+
"""The file's path relative to the root, with forward slashes."""
|
|
79
|
+
return f"{self.folder}/{self.name}" if self.folder else self.name
|
|
80
|
+
|
|
81
|
+
def to_dict(self) -> dict:
|
|
82
|
+
return {
|
|
83
|
+
"name": self.name,
|
|
84
|
+
"size": self.size,
|
|
85
|
+
"sha256": self.sha256,
|
|
86
|
+
"engine": self.engine,
|
|
87
|
+
"format": self.format,
|
|
88
|
+
"level": self.level,
|
|
89
|
+
"volume_size": self.volume_size,
|
|
90
|
+
"encrypted": self.encrypted,
|
|
91
|
+
"mtime_ns": self.mtime_ns,
|
|
92
|
+
"folder": self.folder,
|
|
93
|
+
"volumes": [vars(volume) for volume in self.volumes],
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def from_dict(cls, data, index: int) -> ManifestEntry:
|
|
98
|
+
where = f"entries[{index}]"
|
|
99
|
+
if not isinstance(data, dict):
|
|
100
|
+
raise ManifestError(f"{where}: must be an object")
|
|
101
|
+
|
|
102
|
+
name = _require(data, "name", str, where)
|
|
103
|
+
if not name or "/" in name or "\\" in name or name in (".", ".."):
|
|
104
|
+
raise ManifestError(f"{where}: unsafe file name {name!r}")
|
|
105
|
+
|
|
106
|
+
raw_volumes = _require(data, "volumes", list, where)
|
|
107
|
+
if not raw_volumes:
|
|
108
|
+
raise ManifestError(f"{where}: no volumes listed")
|
|
109
|
+
|
|
110
|
+
return cls(
|
|
111
|
+
name=name,
|
|
112
|
+
size=_require(data, "size", int, where),
|
|
113
|
+
sha256=_require(data, "sha256", str, where),
|
|
114
|
+
engine=_require(data, "engine", str, where),
|
|
115
|
+
format=_require(data, "format", str, where),
|
|
116
|
+
level=_require(data, "level", int, where),
|
|
117
|
+
volume_size=_require(data, "volume_size", int, where),
|
|
118
|
+
volumes=[VolumeInfo.from_dict(v, where) for v in raw_volumes],
|
|
119
|
+
encrypted=bool(data.get("encrypted", False)),
|
|
120
|
+
mtime_ns=int(data.get("mtime_ns", 0) or 0),
|
|
121
|
+
folder=_folder(data.get("folder", ""), where),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def check_volumes(self, base_dir: Path) -> list[str]:
|
|
125
|
+
"""Problems found with the volumes on disk; empty if all are intact."""
|
|
126
|
+
problems = []
|
|
127
|
+
for volume in self.volumes:
|
|
128
|
+
path = base_dir / volume.file
|
|
129
|
+
if not path.is_file():
|
|
130
|
+
problems.append(f"missing volume {volume.file}")
|
|
131
|
+
elif path.stat().st_size != volume.size:
|
|
132
|
+
problems.append(f"volume {volume.file} has the wrong size")
|
|
133
|
+
elif hash_file(path) != volume.sha256:
|
|
134
|
+
problems.append(f"volume {volume.file} fails its checksum")
|
|
135
|
+
return problems
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass
|
|
139
|
+
class Manifest:
|
|
140
|
+
entries: dict[str, ManifestEntry] = field(default_factory=dict)
|
|
141
|
+
created: str = ""
|
|
142
|
+
|
|
143
|
+
def to_dict(self) -> dict:
|
|
144
|
+
return {
|
|
145
|
+
"schema_version": SCHEMA_VERSION,
|
|
146
|
+
"created": self.created or datetime.now(UTC).isoformat(timespec="seconds"),
|
|
147
|
+
"batchvault_version": __version__,
|
|
148
|
+
"entries": [entry.to_dict() for entry in self.entries.values()],
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
@classmethod
|
|
152
|
+
def from_dict(cls, data) -> Manifest:
|
|
153
|
+
if not isinstance(data, dict):
|
|
154
|
+
raise ManifestError("Manifest must be a JSON object")
|
|
155
|
+
|
|
156
|
+
version = data.get("schema_version")
|
|
157
|
+
if version != SCHEMA_VERSION:
|
|
158
|
+
raise ManifestError(
|
|
159
|
+
f"Unsupported manifest schema_version {version!r} "
|
|
160
|
+
f"(this version reads {SCHEMA_VERSION})."
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
entries = {}
|
|
164
|
+
for index, raw in enumerate(_require(data, "entries", list, "manifest")):
|
|
165
|
+
entry = ManifestEntry.from_dict(raw, index)
|
|
166
|
+
if entry.path in entries:
|
|
167
|
+
raise ManifestError(f"Duplicate entry for {entry.path!r}")
|
|
168
|
+
entries[entry.path] = entry
|
|
169
|
+
|
|
170
|
+
return cls(entries=entries, created=str(data.get("created", "")))
|
|
171
|
+
|
|
172
|
+
@classmethod
|
|
173
|
+
def from_bytes(cls, raw: bytes, source: Path) -> Manifest:
|
|
174
|
+
"""Parse manifest bytes already read from ``source``."""
|
|
175
|
+
try:
|
|
176
|
+
data = json.loads(raw.decode("utf-8"))
|
|
177
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
178
|
+
raise ManifestError(f"Manifest {source} is not valid JSON: {exc}") from exc
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
return cls.from_dict(data)
|
|
182
|
+
except ManifestError as exc:
|
|
183
|
+
raise ManifestError(f"{source}: {exc}") from exc
|
|
184
|
+
|
|
185
|
+
@classmethod
|
|
186
|
+
def load(cls, path: Path) -> Manifest:
|
|
187
|
+
try:
|
|
188
|
+
raw = path.read_bytes()
|
|
189
|
+
except OSError as exc:
|
|
190
|
+
raise ManifestError(f"Cannot read manifest {path}: {exc}") from exc
|
|
191
|
+
return cls.from_bytes(raw, path)
|
|
192
|
+
|
|
193
|
+
def save(self, path: Path) -> None:
|
|
194
|
+
"""Write atomically so an interrupted run cannot leave a half-written file."""
|
|
195
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
196
|
+
text = json.dumps(self.to_dict(), indent=2, ensure_ascii=False) + "\n"
|
|
197
|
+
|
|
198
|
+
# A fresh, unpredictable, owner-only file (not a fixed ".tmp" name that
|
|
199
|
+
# someone could pre-create as a link); the manifest lists file names.
|
|
200
|
+
fd, name = tempfile.mkstemp(
|
|
201
|
+
dir=path.parent, prefix=path.name + ".", suffix=".tmp"
|
|
202
|
+
)
|
|
203
|
+
temp = Path(name)
|
|
204
|
+
try:
|
|
205
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
|
|
206
|
+
handle.write(text)
|
|
207
|
+
os.replace(temp, path)
|
|
208
|
+
except BaseException:
|
|
209
|
+
temp.unlink(missing_ok=True)
|
|
210
|
+
raise
|
batchvault/models.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Status(StrEnum):
|
|
9
|
+
SUCCESS = "success"
|
|
10
|
+
FAILED = "failed"
|
|
11
|
+
SKIPPED = "skipped"
|
|
12
|
+
PARTIAL = "partial"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class FileResult:
|
|
17
|
+
"""Outcome of one file in a pack, unpack or verify run.
|
|
18
|
+
|
|
19
|
+
For ``pack`` the ``source`` is the original file and ``archive`` its first
|
|
20
|
+
volume. For ``unpack`` and ``verify`` the ``source`` is the archive (its first
|
|
21
|
+
volume) and, for ``unpack``, ``restored`` lists the files written.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
source: Path
|
|
25
|
+
status: Status
|
|
26
|
+
message: str = ""
|
|
27
|
+
size: int = 0
|
|
28
|
+
volumes: list[Path] = field(default_factory=list)
|
|
29
|
+
warnings: list[str] = field(default_factory=list)
|
|
30
|
+
archive: Path | None = None # first volume of the archive (pack)
|
|
31
|
+
stored_size: int = 0 # total size of the volumes (pack)
|
|
32
|
+
restored: list[Path] = field(default_factory=list) # files written (unpack)
|
|
33
|
+
folder: str = "" # folder of the file under the root ("" = flat)
|
|
34
|
+
name: str = "" # the original file's name (pack, unpack and verify)
|
|
35
|
+
level: int | None = None # compression level used (pack)
|
|
36
|
+
|
|
37
|
+
def to_dict(self) -> dict:
|
|
38
|
+
return {
|
|
39
|
+
"source": str(self.source),
|
|
40
|
+
"name": self.name,
|
|
41
|
+
"level": self.level,
|
|
42
|
+
"folder": self.folder,
|
|
43
|
+
"status": self.status.value,
|
|
44
|
+
"message": self.message,
|
|
45
|
+
"size": self.size,
|
|
46
|
+
"volumes": [str(volume) for volume in self.volumes],
|
|
47
|
+
"archive": str(self.archive) if self.archive else None,
|
|
48
|
+
"stored_size": self.stored_size,
|
|
49
|
+
"restored": [str(path) for path in self.restored],
|
|
50
|
+
"warnings": self.warnings,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class RunResult:
|
|
56
|
+
"""Structured outcome of a whole run."""
|
|
57
|
+
|
|
58
|
+
entries: list[FileResult] = field(default_factory=list)
|
|
59
|
+
manifest: Path | None = None
|
|
60
|
+
warnings: list[str] = field(default_factory=list)
|
|
61
|
+
engine: str = ""
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def status(self) -> Status:
|
|
65
|
+
statuses = {entry.status for entry in self.entries}
|
|
66
|
+
if not statuses or statuses == {Status.SUCCESS}:
|
|
67
|
+
return Status.SUCCESS
|
|
68
|
+
if statuses == {Status.SKIPPED}:
|
|
69
|
+
return Status.SKIPPED
|
|
70
|
+
if Status.SUCCESS in statuses:
|
|
71
|
+
return Status.PARTIAL
|
|
72
|
+
return Status.FAILED
|
|
73
|
+
|
|
74
|
+
def count(self, status: Status) -> int:
|
|
75
|
+
return sum(1 for entry in self.entries if entry.status == status)
|
|
76
|
+
|
|
77
|
+
def to_dict(self) -> dict:
|
|
78
|
+
return {
|
|
79
|
+
"status": self.status.value,
|
|
80
|
+
"engine": self.engine,
|
|
81
|
+
"manifest": str(self.manifest) if self.manifest else None,
|
|
82
|
+
"warnings": self.warnings,
|
|
83
|
+
"entries": [entry.to_dict() for entry in self.entries],
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class FileEntry:
|
|
89
|
+
"""A file found by ``list_files`` or an archive found by ``list_archives``."""
|
|
90
|
+
|
|
91
|
+
path: Path
|
|
92
|
+
size: int | None = None
|
|
93
|
+
root: Path | None = None # folder a file was listed from (``list files``)
|
|
94
|
+
|
|
95
|
+
def to_dict(self) -> dict:
|
|
96
|
+
data = {"path": str(self.path), "size": self.size}
|
|
97
|
+
if self.root is not None:
|
|
98
|
+
# Lets ``pack`` keep the listed folder's own name in the archives.
|
|
99
|
+
data["root"] = str(self.root)
|
|
100
|
+
return data
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True)
|
|
104
|
+
class ProgressEvent:
|
|
105
|
+
"""Progress report. Callbacks run on worker threads and must be thread-safe.
|
|
106
|
+
|
|
107
|
+
``kind`` is ``"plan"`` (once, before work: ``size`` is the total bytes if
|
|
108
|
+
known and ``count`` the number of files), then ``"start"`` and ``"finish"``
|
|
109
|
+
for each file.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
kind: str
|
|
113
|
+
source: Path
|
|
114
|
+
size: int = 0
|
|
115
|
+
status: Status | None = None
|
|
116
|
+
count: int = 0
|
|
117
|
+
|
|
118
|
+
def to_dict(self) -> dict:
|
|
119
|
+
return {
|
|
120
|
+
"event": self.kind,
|
|
121
|
+
"source": str(self.source),
|
|
122
|
+
"size": self.size,
|
|
123
|
+
"status": self.status.value if self.status else None,
|
|
124
|
+
"count": self.count,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class InspectEntry:
|
|
130
|
+
"""What the manifest (or, without one, the file names) say about an archive."""
|
|
131
|
+
|
|
132
|
+
name: str
|
|
133
|
+
path: Path # first volume
|
|
134
|
+
engine: str
|
|
135
|
+
format: str | None = None
|
|
136
|
+
level: int | None = None
|
|
137
|
+
encrypted: bool | None = None
|
|
138
|
+
size: int | None = None # original size
|
|
139
|
+
stored_size: int | None = None # total size of the volumes
|
|
140
|
+
volumes: int = 0
|
|
141
|
+
complete: bool | None = None # every listed volume is present
|
|
142
|
+
folder: str = "" # folder under the root the file came from ("" = flat)
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def ratio(self) -> float | None:
|
|
146
|
+
if self.size and self.stored_size is not None:
|
|
147
|
+
return self.stored_size / self.size
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
def to_dict(self) -> dict:
|
|
151
|
+
return {
|
|
152
|
+
"name": self.name,
|
|
153
|
+
"folder": self.folder,
|
|
154
|
+
"path": str(self.path),
|
|
155
|
+
"engine": self.engine,
|
|
156
|
+
"format": self.format,
|
|
157
|
+
"level": self.level,
|
|
158
|
+
"encrypted": self.encrypted,
|
|
159
|
+
"size": self.size,
|
|
160
|
+
"stored_size": self.stored_size,
|
|
161
|
+
"ratio": self.ratio,
|
|
162
|
+
"volumes": self.volumes,
|
|
163
|
+
"complete": self.complete,
|
|
164
|
+
}
|
batchvault/names.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Checks on file and folder names before they are written to disk."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
_WINDOWS_RESERVED = {
|
|
6
|
+
"con", "prn", "aux", "nul",
|
|
7
|
+
*(f"com{n}" for n in range(1, 10)),
|
|
8
|
+
*(f"lpt{n}" for n in range(1, 10)),
|
|
9
|
+
} # fmt: skip
|
|
10
|
+
_WINDOWS_BAD_CHARACTERS = '<>:"|?*'
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def unsafe_on_windows(name: str) -> bool:
|
|
14
|
+
"""Names Windows treats specially: drives (``C:x``), streams (``a:b``),
|
|
15
|
+
devices (``NUL``, ``con.txt``), and names ending in a dot or space."""
|
|
16
|
+
stem = name.split(".")[0].rstrip(" ").lower()
|
|
17
|
+
return (
|
|
18
|
+
any(char in name for char in _WINDOWS_BAD_CHARACTERS)
|
|
19
|
+
or name != name.rstrip(" .")
|
|
20
|
+
or stem in _WINDOWS_RESERVED
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def validate_folder(folder: str) -> str:
|
|
25
|
+
"""Check a stored folder path (forward slashes, relative, no ``..``).
|
|
26
|
+
|
|
27
|
+
Returns it unchanged; raises ValueError if it could point outside the folder
|
|
28
|
+
it is restored into.
|
|
29
|
+
"""
|
|
30
|
+
if folder == "":
|
|
31
|
+
return folder
|
|
32
|
+
if "\\" in folder or "\x00" in folder or folder.startswith("/"):
|
|
33
|
+
raise ValueError(f"unsafe folder {folder!r}")
|
|
34
|
+
if any(part in ("", ".", "..") for part in folder.split("/")):
|
|
35
|
+
raise ValueError(f"unsafe folder {folder!r}")
|
|
36
|
+
return folder
|
batchvault/notify.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Webhook notifications. Used by the command-line interface only."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import platform
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.request
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
|
+
|
|
11
|
+
from batchvault.errors import OptionsError
|
|
12
|
+
from batchvault.models import RunResult, Status
|
|
13
|
+
|
|
14
|
+
NOTIFY_ON = ("failure", "success", "always")
|
|
15
|
+
TIMEOUT_SECONDS = 10
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def should_notify(when: str, result: RunResult) -> bool:
|
|
19
|
+
if when == "always":
|
|
20
|
+
return True
|
|
21
|
+
ok = result.status == Status.SUCCESS
|
|
22
|
+
return ok if when == "success" else not ok
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def validate_url(url: str) -> str:
|
|
26
|
+
parsed = urlparse(url)
|
|
27
|
+
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
|
28
|
+
raise OptionsError("The notification URL must start with http:// or https://")
|
|
29
|
+
return url
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def is_insecure(url: str) -> bool:
|
|
33
|
+
"""True for plain http:// to anything other than this machine."""
|
|
34
|
+
parsed = urlparse(url)
|
|
35
|
+
local = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
|
36
|
+
return parsed.scheme == "http" and not local
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_payload(command: str, result: RunResult) -> dict:
|
|
40
|
+
counts = ", ".join(
|
|
41
|
+
f"{result.count(status)} {status.value}"
|
|
42
|
+
for status in (Status.SUCCESS, Status.SKIPPED, Status.FAILED)
|
|
43
|
+
if result.count(status)
|
|
44
|
+
)
|
|
45
|
+
text = (
|
|
46
|
+
f"BatchVault {command} on {platform.node()}: "
|
|
47
|
+
f"{result.status.value} ({counts or 'nothing to do'})"
|
|
48
|
+
)
|
|
49
|
+
# "text" is read by Slack and Teams, "content" by Discord.
|
|
50
|
+
return {
|
|
51
|
+
"text": text,
|
|
52
|
+
"content": text,
|
|
53
|
+
"command": command,
|
|
54
|
+
"host": platform.node(),
|
|
55
|
+
"result": result.to_dict(),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def send(url: str, payload: dict) -> str | None:
|
|
60
|
+
"""POST the payload as JSON. Returns an error message, or None on success."""
|
|
61
|
+
request = urllib.request.Request(
|
|
62
|
+
validate_url(url),
|
|
63
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
64
|
+
headers={"Content-Type": "application/json"},
|
|
65
|
+
method="POST",
|
|
66
|
+
)
|
|
67
|
+
try:
|
|
68
|
+
with urllib.request.urlopen(request, timeout=TIMEOUT_SECONDS):
|
|
69
|
+
return None
|
|
70
|
+
except urllib.error.HTTPError as exc:
|
|
71
|
+
return f"the server answered {exc.code}"
|
|
72
|
+
except (urllib.error.URLError, OSError, ValueError) as exc:
|
|
73
|
+
return str(getattr(exc, "reason", exc))
|
batchvault/options.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
from batchvault.errors import OptionsError
|
|
7
|
+
|
|
8
|
+
KIB = 1024
|
|
9
|
+
MIB = 1024 * KIB
|
|
10
|
+
GIB = 1024 * MIB
|
|
11
|
+
|
|
12
|
+
_SIZE_UNITS = {"": 1, "k": KIB, "m": MIB, "g": GIB}
|
|
13
|
+
_SIZE_RE = re.compile(r"^\s*(\d+)\s*([kmg]?)b?\s*$", re.IGNORECASE)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Compression formats of the native engine. The 7-Zip engine always writes 7z.
|
|
17
|
+
FORMATS = ("xz", "gzip", "bzip2", "store")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_size(value: str | int) -> int:
|
|
21
|
+
"""Convert a size such as ``10m`` or ``1048576`` to bytes."""
|
|
22
|
+
if isinstance(value, int):
|
|
23
|
+
size = value
|
|
24
|
+
else:
|
|
25
|
+
match = _SIZE_RE.match(value)
|
|
26
|
+
if not match:
|
|
27
|
+
raise OptionsError(
|
|
28
|
+
f"Invalid size {value!r}. Use a number with an optional "
|
|
29
|
+
"k, m or g suffix, e.g. 500k, 10m, 2g."
|
|
30
|
+
)
|
|
31
|
+
size = int(match.group(1)) * _SIZE_UNITS[match.group(2).lower()]
|
|
32
|
+
|
|
33
|
+
if size < 1:
|
|
34
|
+
raise OptionsError("Size must be at least 1 byte.")
|
|
35
|
+
return size
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def format_size(size: int | None) -> str:
|
|
39
|
+
"""Human-readable size such as ``12.3 MiB``; ``-`` when unknown."""
|
|
40
|
+
if size is None:
|
|
41
|
+
return "-"
|
|
42
|
+
value = float(size)
|
|
43
|
+
for unit in ("B", "KiB", "MiB", "GiB"):
|
|
44
|
+
if value < 1024:
|
|
45
|
+
return f"{value:.0f} {unit}" if unit == "B" else f"{value:.1f} {unit}"
|
|
46
|
+
value /= 1024
|
|
47
|
+
return f"{value:.1f} TiB"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class PackOptions:
|
|
52
|
+
"""Settings for creating an archive."""
|
|
53
|
+
|
|
54
|
+
level: int = 5 # a fixed level; "auto" is resolved per file before this
|
|
55
|
+
volume_size: int = 10 * MIB
|
|
56
|
+
jobs: int = 0
|
|
57
|
+
force: bool = False
|
|
58
|
+
format: str = "xz"
|
|
59
|
+
password: str | None = field(default=None, repr=False)
|
|
60
|
+
|
|
61
|
+
def __post_init__(self):
|
|
62
|
+
if self.format not in FORMATS:
|
|
63
|
+
raise OptionsError(
|
|
64
|
+
f"Unknown format {self.format!r}. Choose from: {', '.join(FORMATS)}."
|
|
65
|
+
)
|
|
66
|
+
if not 0 <= self.level <= 9:
|
|
67
|
+
raise OptionsError("Compression level must be between 0 and 9.")
|
|
68
|
+
if self.volume_size < 1:
|
|
69
|
+
raise OptionsError("Volume size must be at least 1 byte.")
|
|
70
|
+
if self.jobs < 0:
|
|
71
|
+
raise OptionsError("Jobs cannot be negative.")
|
batchvault/py.typed
ADDED
|
File without changes
|
batchvault/security.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
# Credentials embedded in a URL: scheme://user:password@host
|
|
6
|
+
_URL_CREDENTIALS = re.compile(r"(?<=://)[^/\s:@]+(:[^/\s@]*)?@")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def redact(text: str) -> str:
|
|
10
|
+
"""Hide credentials embedded in URLs."""
|
|
11
|
+
return _URL_CREDENTIALS.sub("***@", text)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
SECRET_OPTIONS = ("--notify",)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def redact_arguments(argv: list[str]) -> list[str]:
|
|
18
|
+
"""Hide the values of options that carry secrets (webhook URLs contain one)."""
|
|
19
|
+
hidden = []
|
|
20
|
+
mask_next = False
|
|
21
|
+
for arg in argv:
|
|
22
|
+
if mask_next:
|
|
23
|
+
hidden.append("***")
|
|
24
|
+
mask_next = False
|
|
25
|
+
elif arg in SECRET_OPTIONS:
|
|
26
|
+
hidden.append(arg)
|
|
27
|
+
mask_next = True
|
|
28
|
+
elif any(arg.startswith(f"{option}=") for option in SECRET_OPTIONS):
|
|
29
|
+
hidden.append(arg.split("=", 1)[0] + "=***")
|
|
30
|
+
else:
|
|
31
|
+
hidden.append(redact(arg))
|
|
32
|
+
return hidden
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def restrict_permissions(path: Path) -> None:
|
|
36
|
+
"""Make a file readable by its owner only (no effect on Windows)."""
|
|
37
|
+
try:
|
|
38
|
+
os.chmod(path, 0o600)
|
|
39
|
+
except OSError:
|
|
40
|
+
pass
|