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/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