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