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