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/blocks.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Block-parallel compression for the native engine.
|
|
2
|
+
|
|
3
|
+
The tar stream is cut into independent blocks. Each block is compressed as its
|
|
4
|
+
own xz, gzip or bzip2 member on a thread pool and the members are written in
|
|
5
|
+
order. Concatenated members are a valid single stream for all three formats, so
|
|
6
|
+
the joined volumes can be read by the standard tools too.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import bz2
|
|
12
|
+
import gzip
|
|
13
|
+
from collections import deque
|
|
14
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
15
|
+
|
|
16
|
+
from batchvault import crypto
|
|
17
|
+
from batchvault.errors import EngineError, EngineNotFoundError
|
|
18
|
+
from batchvault.options import FORMATS, MIB
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
import lzma
|
|
22
|
+
except ImportError: # Python built without liblzma
|
|
23
|
+
lzma = None
|
|
24
|
+
|
|
25
|
+
BLOCK_SIZE = 16 * MIB
|
|
26
|
+
|
|
27
|
+
_XZ_MAGIC = b"\xfd7zXZ\x00"
|
|
28
|
+
_GZIP_MAGIC = b"\x1f\x8b"
|
|
29
|
+
_BZIP2_MAGIC = b"BZh"
|
|
30
|
+
_TAR_MAGIC_OFFSET = 257
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def check_format(fmt: str) -> None:
|
|
34
|
+
if fmt not in FORMATS:
|
|
35
|
+
raise EngineError(f"Unknown format: {fmt}")
|
|
36
|
+
if fmt == "xz" and lzma is None:
|
|
37
|
+
raise EngineNotFoundError(
|
|
38
|
+
"This Python was built without lzma support, so the xz format "
|
|
39
|
+
"is unavailable. Use --format gzip or bzip2."
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def compress_block(fmt: str, level: int, data: bytes) -> bytes:
|
|
44
|
+
"""Compress one block as a complete, independent member."""
|
|
45
|
+
if fmt == "xz":
|
|
46
|
+
return lzma.compress(data, format=lzma.FORMAT_XZ, preset=level)
|
|
47
|
+
if fmt == "gzip":
|
|
48
|
+
return gzip.compress(data, compresslevel=level, mtime=0)
|
|
49
|
+
if fmt == "bzip2":
|
|
50
|
+
# bzip2 has no level 0; the lowest level is the closest match.
|
|
51
|
+
return bz2.compress(data, compresslevel=max(level, 1))
|
|
52
|
+
return data
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def open_decompressed(fmt: str, fileobj):
|
|
56
|
+
"""Wrap a file object so reading it yields the uncompressed tar stream."""
|
|
57
|
+
check_format(fmt)
|
|
58
|
+
if fmt == "xz":
|
|
59
|
+
return lzma.LZMAFile(fileobj, "rb")
|
|
60
|
+
if fmt == "gzip":
|
|
61
|
+
return gzip.GzipFile(fileobj=fileobj, mode="rb")
|
|
62
|
+
if fmt == "bzip2":
|
|
63
|
+
return bz2.BZ2File(fileobj, "rb")
|
|
64
|
+
return fileobj
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def sniff_archive(path) -> tuple[str, bool]:
|
|
68
|
+
"""Identify ``(format, encrypted)`` from the first bytes of a volume."""
|
|
69
|
+
with open(path, "rb") as handle:
|
|
70
|
+
head = handle.read(max(_TAR_MAGIC_OFFSET + 5, crypto.HEADER_SIZE))
|
|
71
|
+
|
|
72
|
+
if crypto.is_encrypted_header(head):
|
|
73
|
+
return crypto.parse_header(head)[0], True
|
|
74
|
+
if head.startswith(_XZ_MAGIC):
|
|
75
|
+
return "xz", False
|
|
76
|
+
if head.startswith(_GZIP_MAGIC):
|
|
77
|
+
return "gzip", False
|
|
78
|
+
if head.startswith(_BZIP2_MAGIC):
|
|
79
|
+
return "bzip2", False
|
|
80
|
+
if head[_TAR_MAGIC_OFFSET : _TAR_MAGIC_OFFSET + 5] == b"ustar":
|
|
81
|
+
return "store", False
|
|
82
|
+
raise EngineError(f"Unrecognised archive format: {path}")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def sniff_format(path) -> str:
|
|
86
|
+
"""Identify the compression format from the first bytes of a volume."""
|
|
87
|
+
return sniff_archive(path)[0]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _process(fmt: str, level: int, block: bytes, cipher, index: int) -> bytes:
|
|
91
|
+
data = compress_block(fmt, level, block)
|
|
92
|
+
return cipher.encrypt(index, data) if cipher else data
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class BlockWriter:
|
|
96
|
+
"""Write-only file object that compresses fixed-size blocks in parallel.
|
|
97
|
+
|
|
98
|
+
At most ``2 * jobs`` blocks are in flight, which bounds memory use.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def __init__(
|
|
102
|
+
self,
|
|
103
|
+
sink,
|
|
104
|
+
fmt: str,
|
|
105
|
+
level: int,
|
|
106
|
+
block_size: int,
|
|
107
|
+
jobs: int,
|
|
108
|
+
cipher=None,
|
|
109
|
+
header: bytes = b"",
|
|
110
|
+
):
|
|
111
|
+
self._sink = sink
|
|
112
|
+
self._fmt = fmt
|
|
113
|
+
self._level = level
|
|
114
|
+
self._block_size = block_size
|
|
115
|
+
self._cipher = cipher
|
|
116
|
+
self._max_pending = jobs * 2
|
|
117
|
+
# With no jobs, blocks are compressed in the calling thread.
|
|
118
|
+
self._executor = ThreadPoolExecutor(max_workers=jobs) if jobs else None
|
|
119
|
+
self._pending = deque()
|
|
120
|
+
self._buffer = bytearray()
|
|
121
|
+
self._count = 0
|
|
122
|
+
if cipher:
|
|
123
|
+
sink.write(header)
|
|
124
|
+
|
|
125
|
+
def write(self, data) -> int:
|
|
126
|
+
self._buffer += data
|
|
127
|
+
while len(self._buffer) >= self._block_size:
|
|
128
|
+
block = bytes(self._buffer[: self._block_size])
|
|
129
|
+
del self._buffer[: self._block_size]
|
|
130
|
+
self._submit(block)
|
|
131
|
+
return len(data)
|
|
132
|
+
|
|
133
|
+
def _submit(self, block: bytes) -> None:
|
|
134
|
+
if self._executor is None:
|
|
135
|
+
self._sink.write(
|
|
136
|
+
_process(self._fmt, self._level, block, self._cipher, self._count)
|
|
137
|
+
)
|
|
138
|
+
self._count += 1
|
|
139
|
+
return
|
|
140
|
+
self._pending.append(
|
|
141
|
+
self._executor.submit(
|
|
142
|
+
_process, self._fmt, self._level, block, self._cipher, self._count
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
self._count += 1
|
|
146
|
+
while len(self._pending) > self._max_pending:
|
|
147
|
+
self._write_oldest()
|
|
148
|
+
|
|
149
|
+
def _write_oldest(self) -> None:
|
|
150
|
+
self._sink.write(self._pending.popleft().result())
|
|
151
|
+
|
|
152
|
+
def close(self) -> None:
|
|
153
|
+
try:
|
|
154
|
+
if self._buffer:
|
|
155
|
+
self._submit(bytes(self._buffer))
|
|
156
|
+
self._buffer.clear()
|
|
157
|
+
while self._pending:
|
|
158
|
+
self._write_oldest()
|
|
159
|
+
if self._cipher:
|
|
160
|
+
# The end marker lets a reader detect a truncated archive.
|
|
161
|
+
self._sink.write(self._cipher.encrypt(self._count, b"", last=True))
|
|
162
|
+
finally:
|
|
163
|
+
self._shutdown()
|
|
164
|
+
|
|
165
|
+
def _shutdown(self) -> None:
|
|
166
|
+
if self._executor:
|
|
167
|
+
self._executor.shutdown(wait=True, cancel_futures=True)
|
|
168
|
+
|
|
169
|
+
def __enter__(self):
|
|
170
|
+
return self
|
|
171
|
+
|
|
172
|
+
def __exit__(self, exc_type, *exc_info):
|
|
173
|
+
if exc_type is None:
|
|
174
|
+
self.close()
|
|
175
|
+
else:
|
|
176
|
+
self._shutdown()
|