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.
@@ -0,0 +1,237 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import tarfile
5
+ import tempfile
6
+ import zlib
7
+ from pathlib import Path, PurePosixPath
8
+
9
+ from batchvault import crypto
10
+ from batchvault.blocks import (
11
+ BLOCK_SIZE,
12
+ BlockWriter,
13
+ check_format,
14
+ open_decompressed,
15
+ sniff_archive,
16
+ )
17
+ from batchvault.engines.base import Capabilities, Engine
18
+ from batchvault.errors import EngineError, OutputExistsError
19
+ from batchvault.names import unsafe_on_windows as _unsafe_on_windows
20
+ from batchvault.options import MIB, PackOptions
21
+ from batchvault.volumes import (
22
+ VolumeReader,
23
+ VolumeWriter,
24
+ existing_volumes,
25
+ find_volumes,
26
+ install_volumes,
27
+ )
28
+
29
+ try:
30
+ import lzma
31
+
32
+ _LZMA_ERRORS: tuple[type[Exception], ...] = (lzma.LZMAError,)
33
+ except ImportError:
34
+ _LZMA_ERRORS = ()
35
+
36
+ # Errors raised while reading damaged, truncated or unreadable archive data.
37
+ _READ_ERRORS = (tarfile.TarError, EOFError, zlib.error, OSError, *_LZMA_ERRORS)
38
+
39
+ _STREAM_BUFFER = MIB
40
+ _CHUNK = MIB
41
+ # Data allowed after the tar end marker; a real archive has only padding.
42
+ _MAX_TRAILING = 4 * MIB
43
+
44
+
45
+ def _normalize(info: tarfile.TarInfo) -> tarfile.TarInfo:
46
+ """Drop ownership so archives do not depend on the user who made them."""
47
+ info.uid = info.gid = 0
48
+ info.uname = info.gname = ""
49
+ return info
50
+
51
+
52
+ class _Budget:
53
+ """Caps the total size of the files restored from one archive."""
54
+
55
+ def __init__(self, limit: int | None):
56
+ self.remaining = limit
57
+
58
+ def charge(self, member: tarfile.TarInfo) -> None:
59
+ if self.remaining is None:
60
+ return
61
+ if member.size > self.remaining:
62
+ raise EngineError(
63
+ f"{member.name!r} would expand beyond the allowed size; "
64
+ "the archive was not restored."
65
+ )
66
+ self.remaining -= member.size
67
+
68
+
69
+ def _check_member(member: tarfile.TarInfo, windows: bool | None = None) -> None:
70
+ """Accept only a plain, single-part regular file name that is safe to write."""
71
+ if windows is None:
72
+ windows = os.name == "nt"
73
+
74
+ path = PurePosixPath(member.name)
75
+ if (
76
+ not member.isfile()
77
+ or path.is_absolute()
78
+ or len(path.parts) != 1
79
+ or path.name in ("", ".", "..")
80
+ or "\\" in member.name
81
+ or (windows and _unsafe_on_windows(member.name))
82
+ ):
83
+ raise EngineError(f"Unsupported or unsafe entry in archive: {member.name!r}")
84
+
85
+
86
+ class NativeEngine(Engine):
87
+ """Standard-library engine: tar stream, block-parallel compression."""
88
+
89
+ name = "native"
90
+ capabilities = Capabilities(
91
+ levels=range(0, 10), threads_per_file=True, archive_suffix=".bv"
92
+ )
93
+
94
+ def __init__(self, block_size: int = BLOCK_SIZE):
95
+ self._block_size = block_size
96
+
97
+ def is_available(self) -> bool:
98
+ return True
99
+
100
+ def pack(self, source: Path, dest_dir: Path, options: PackOptions) -> list[Path]:
101
+ if not source.is_file():
102
+ raise FileNotFoundError(f"Source file not found: {source}")
103
+ check_format(options.format)
104
+
105
+ base = f"{source.name}{self.capabilities.archive_suffix}"
106
+ existing = existing_volumes(dest_dir, base)
107
+ if existing and not options.force:
108
+ raise OutputExistsError(
109
+ f"Archive volumes already exist in {dest_dir}. Use --force to replace."
110
+ )
111
+
112
+ header = cipher = None
113
+ if options.password:
114
+ header = crypto.make_header(options.format)
115
+ cipher = crypto.BlockCipher(options.password, header)
116
+
117
+ dest_dir.mkdir(parents=True, exist_ok=True)
118
+
119
+ # Build in a temporary folder so a failed run never damages what exists.
120
+ with tempfile.TemporaryDirectory(dir=dest_dir, prefix=".batchvault-") as tmp:
121
+ with VolumeWriter(Path(tmp), base, options.volume_size) as volumes:
122
+ with BlockWriter(
123
+ volumes,
124
+ options.format,
125
+ options.level,
126
+ self._block_size,
127
+ options.jobs,
128
+ cipher=cipher,
129
+ header=header or b"",
130
+ ) as blocks:
131
+ with tarfile.open(
132
+ fileobj=blocks,
133
+ mode="w|",
134
+ format=tarfile.PAX_FORMAT,
135
+ bufsize=_STREAM_BUFFER,
136
+ ) as tar:
137
+ tar.add(source, arcname=source.name, filter=_normalize)
138
+
139
+ return install_volumes(volumes.volumes, dest_dir, existing)
140
+
141
+ def unpack(
142
+ self,
143
+ volume: Path,
144
+ output_dir: Path,
145
+ *,
146
+ force: bool = False,
147
+ password: str | None = None,
148
+ max_bytes: int | None = None,
149
+ ) -> list[Path]:
150
+ volumes = find_volumes(volume)
151
+ output_dir.mkdir(parents=True, exist_ok=True)
152
+ budget = _Budget(max_bytes)
153
+
154
+ def handle(member, tar) -> None:
155
+ budget.charge(member) # refuse a decompression bomb before writing
156
+ self._extract(member, tar, staging)
157
+
158
+ with tempfile.TemporaryDirectory(dir=output_dir, prefix=".batchvault-") as tmp:
159
+ staging = Path(tmp)
160
+ self._read(volumes, handle, password)
161
+
162
+ extracted = sorted(staging.iterdir())
163
+ if not extracted:
164
+ raise EngineError(f"No files found in archive {volumes[0]}")
165
+
166
+ conflicts = [
167
+ output_dir / item.name
168
+ for item in extracted
169
+ if (output_dir / item.name).exists()
170
+ ]
171
+ if conflicts and not force:
172
+ names = ", ".join(path.name for path in conflicts)
173
+ raise OutputExistsError(
174
+ f"Destination already exists: {names}. Use --force to overwrite."
175
+ )
176
+
177
+ restored = []
178
+ for item in extracted:
179
+ target = output_dir / item.name
180
+ os.replace(item, target)
181
+ restored.append(target)
182
+
183
+ return restored
184
+
185
+ def test(self, volume: Path, *, password: str | None = None) -> None:
186
+ self._read(find_volumes(volume), self._drain_member, password)
187
+
188
+ @staticmethod
189
+ def _extract(member, tar, staging: Path) -> None:
190
+ # Belt and braces next to _check_member and the "data" filter: whatever
191
+ # the name looks like, the file must end up inside the staging folder.
192
+ root = staging.resolve()
193
+ if root not in (root / member.name).resolve().parents:
194
+ raise EngineError(f"Unsafe entry in archive: {member.name!r}")
195
+ tar.extract(member, staging, filter="data")
196
+
197
+ @staticmethod
198
+ def _drain_member(member, tar) -> None:
199
+ handle = tar.extractfile(member)
200
+ while handle.read(_CHUNK):
201
+ pass
202
+
203
+ @staticmethod
204
+ def _read(volumes: list[Path], handle_member, password: str | None) -> None:
205
+ """Stream the archive and call ``handle_member(member, tar)`` for each file."""
206
+ fmt, encrypted = sniff_archive(volumes[0])
207
+ if encrypted and not password:
208
+ raise EngineError(
209
+ f"{volumes[0].name} is encrypted. A password is required."
210
+ )
211
+
212
+ try:
213
+ with VolumeReader(volumes) as reader:
214
+ source = reader
215
+ if encrypted:
216
+ fmt, source = crypto.open_decrypted(reader, password)
217
+
218
+ with open_decompressed(fmt, source) as stream:
219
+ with tarfile.open(fileobj=stream, mode="r|") as tar:
220
+ for member in tar:
221
+ _check_member(member)
222
+ handle_member(member, tar)
223
+
224
+ # Read to the very end so a truncated stream is detected. A
225
+ # real archive has almost nothing after the tar end marker.
226
+ trailing = 0
227
+ while chunk := stream.read(_CHUNK):
228
+ trailing += len(chunk)
229
+ if trailing > _MAX_TRAILING:
230
+ raise EngineError(
231
+ f"{volumes[0].name} has unexpected data after the "
232
+ "end of the archive."
233
+ )
234
+ except _READ_ERRORS as exc:
235
+ raise EngineError(
236
+ f"Archive is corrupt or incomplete ({volumes[0].name}): {exc}"
237
+ ) from exc
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from batchvault.engines.base import Engine
6
+ from batchvault.engines.native import NativeEngine
7
+ from batchvault.engines.sevenzip import SevenZipEngine, find_7zip
8
+ from batchvault.errors import InputError
9
+ from batchvault.volumes import split_volume_name
10
+
11
+ ENGINE_CHOICES = ("auto", "native", "7zip")
12
+
13
+
14
+ def get_engine(name: str = "auto", seven_zip: str | Path | None = None) -> Engine:
15
+ """Return the engine to create archives with.
16
+
17
+ ``auto`` uses 7-Zip when it is found (or when a path to it was given) and
18
+ falls back to the native engine.
19
+ """
20
+ if name == "native":
21
+ return NativeEngine()
22
+
23
+ if name in ("7zip", "7z"):
24
+ find_7zip(seven_zip) # raises EngineNotFoundError with install help
25
+ return SevenZipEngine(seven_zip)
26
+
27
+ if name == "auto":
28
+ if seven_zip:
29
+ find_7zip(seven_zip)
30
+ return SevenZipEngine(seven_zip)
31
+ sevenzip = SevenZipEngine()
32
+ return sevenzip if sevenzip.is_available() else NativeEngine()
33
+
34
+ choices = ", ".join(ENGINE_CHOICES)
35
+ raise InputError(f"Unknown engine {name!r}. Choose from: {choices}.")
36
+
37
+
38
+ def engine_for_volume(volume: Path, seven_zip: str | Path | None = None) -> Engine:
39
+ """Pick the engine that can read a volume, from its file name."""
40
+ parsed = split_volume_name(volume.name)
41
+ if parsed is None:
42
+ raise InputError(f"Not an archive volume: {volume}")
43
+ if parsed[0].lower().endswith(".7z"):
44
+ find_7zip(seven_zip)
45
+ return SevenZipEngine(seven_zip)
46
+ return NativeEngine()
47
+
48
+
49
+ def option_warnings(
50
+ engine: Engine, format_given: str | None, level: int
51
+ ) -> list[str]:
52
+ """Settings the chosen engine cannot honour exactly."""
53
+ warnings = []
54
+ if engine.name == "7zip" and format_given not in (None, "xz"):
55
+ warnings.append(
56
+ f"The 7-Zip engine always writes 7z (LZMA2); --format {format_given} "
57
+ "is ignored."
58
+ )
59
+ if engine.name == "native" and format_given == "bzip2" and level == 0:
60
+ warnings.append("bzip2 has no level 0; level 1 was used.")
61
+ return warnings
@@ -0,0 +1,303 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import subprocess
5
+ import sys
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+ from batchvault.engines.base import Capabilities, Engine
10
+ from batchvault.errors import EngineError, EngineNotFoundError, OutputExistsError
11
+ from batchvault.options import PackOptions
12
+ from batchvault.volumes import existing_volumes, find_volumes, install_volumes
13
+
14
+ ENV_VAR = "BATCHVAULT_7Z"
15
+ COMMANDS = ("7zz", "7z", "7za", "7zr")
16
+ WINDOWS_PATHS = (
17
+ Path(r"C:\Program Files\7-Zip\7z.exe"),
18
+ Path(r"C:\Program Files (x86)\7-Zip\7z.exe"),
19
+ )
20
+ OUTPUT_TAIL_LINES = 15
21
+ LIMIT_POLL_SECONDS = 0.05
22
+
23
+
24
+ def _search_path(command: str) -> Path | None:
25
+ """Find ``command`` in the folders listed in ``PATH``.
26
+
27
+ Unlike ``shutil.which``, this never looks in the current folder (Windows does
28
+ that first) and ignores empty and relative ``PATH`` entries, so a program
29
+ planted in the folder you happen to be working in is never run.
30
+ """
31
+ cwd = Path.cwd().resolve()
32
+ if sys.platform == "win32":
33
+ extensions = os.environ.get("PATHEXT", ".COM;.EXE;.BAT;.CMD").split(";")
34
+ names = [command + extension.lower() for extension in extensions if extension]
35
+ else:
36
+ names = [command]
37
+
38
+ for entry in os.environ.get("PATH", "").split(os.pathsep):
39
+ folder = Path(entry) if entry else None
40
+ if folder is None or not folder.is_absolute():
41
+ continue
42
+ try:
43
+ if folder.resolve() == cwd:
44
+ continue
45
+ except OSError:
46
+ continue
47
+
48
+ for name in names:
49
+ candidate = folder / name
50
+ if candidate.is_file() and os.access(candidate, os.X_OK):
51
+ return candidate
52
+ return None
53
+
54
+
55
+ def find_7zip(explicit: str | Path | None = None) -> Path:
56
+ """Locate a 7-Zip command-line executable.
57
+
58
+ Order: explicit path, ``BATCHVAULT_7Z``, ``PATH`` (never the current folder),
59
+ then well-known Windows install folders.
60
+ """
61
+ configured = explicit or os.environ.get(ENV_VAR)
62
+ if configured:
63
+ path = Path(configured).expanduser()
64
+ if path.is_file():
65
+ return path
66
+ raise EngineNotFoundError(f"7-Zip executable not found: {configured}")
67
+
68
+ for command in COMMANDS:
69
+ found = _search_path(command)
70
+ if found:
71
+ return found
72
+
73
+ if sys.platform == "win32":
74
+ for candidate in WINDOWS_PATHS:
75
+ if candidate.is_file():
76
+ return candidate
77
+
78
+ raise EngineNotFoundError(
79
+ "Could not find 7-Zip. Install it from https://www.7-zip.org/ (7zz on "
80
+ f"Linux and macOS), or set the path with --7z or {ENV_VAR}."
81
+ )
82
+
83
+
84
+ def build_pack_command(
85
+ executable: Path, archive: Path, source: Path, options: PackOptions
86
+ ) -> list[str]:
87
+ command = [
88
+ str(executable),
89
+ "a",
90
+ "-t7z",
91
+ f"-mx={options.level}",
92
+ "-m0=LZMA2",
93
+ f"-mmt={max(options.jobs, 1)}",
94
+ f"-v{options.volume_size}b",
95
+ "-y",
96
+ # A file name containing * or ? is a name, not a pattern; without this
97
+ # 7-Zip would archive every neighbour it matches.
98
+ "-spd",
99
+ ]
100
+ if options.password:
101
+ # 7-Zip only takes the password on its command line, where other users
102
+ # of this machine could see it in the process list while it runs.
103
+ command += [f"-p{options.password}", "-mhe=on"]
104
+ return [*command, str(archive), str(source)]
105
+
106
+
107
+ def _password_args(password: str | None) -> list[str]:
108
+ return [f"-p{password}"] if password else []
109
+
110
+
111
+ def build_unpack_command(
112
+ executable: Path, first_volume: Path, target: Path, password: str | None = None
113
+ ):
114
+ return [
115
+ str(executable),
116
+ "e",
117
+ str(first_volume),
118
+ f"-o{target}",
119
+ "-y",
120
+ "-spd",
121
+ *_password_args(password),
122
+ ]
123
+
124
+
125
+ def build_test_command(
126
+ executable: Path, first_volume: Path, password: str | None = None
127
+ ) -> list[str]:
128
+ return [
129
+ str(executable), "t", str(first_volume), "-spd", *_password_args(password)
130
+ ] # fmt: skip
131
+
132
+
133
+ def folder_size(folder: Path) -> int:
134
+ """Total size of the files below ``folder`` (files that vanish are ignored)."""
135
+ total = 0
136
+ for root, _, names in os.walk(folder):
137
+ for name in names:
138
+ try:
139
+ total += (Path(root) / name).stat().st_size
140
+ except OSError:
141
+ continue
142
+ return total
143
+
144
+
145
+ class SevenZipEngine(Engine):
146
+ name = "7zip"
147
+ capabilities = Capabilities(
148
+ levels=range(0, 10), threads_per_file=True, archive_suffix=".7z"
149
+ )
150
+
151
+ def __init__(self, executable: str | Path | None = None):
152
+ self._explicit = executable
153
+ self._executable: Path | None = None
154
+
155
+ @property
156
+ def executable(self) -> Path:
157
+ if self._executable is None:
158
+ self._executable = find_7zip(self._explicit)
159
+ return self._executable
160
+
161
+ def is_available(self) -> bool:
162
+ try:
163
+ find_7zip(self._explicit)
164
+ except EngineNotFoundError:
165
+ return False
166
+ return True
167
+
168
+ @staticmethod
169
+ def _failure(code: int, output: str) -> EngineError:
170
+ tail = "\n".join(output.strip().splitlines()[-OUTPUT_TAIL_LINES:])
171
+ return EngineError(f"7-Zip failed (exit code {code}).\n{tail}".rstrip())
172
+
173
+ def _run(self, command: list[str]) -> str:
174
+ """Run 7-Zip; return its standard output, raise EngineError on failure."""
175
+ result = subprocess.run(
176
+ command,
177
+ capture_output=True,
178
+ stdin=subprocess.DEVNULL, # never wait for a password prompt
179
+ text=True,
180
+ encoding="utf-8",
181
+ errors="replace",
182
+ check=False,
183
+ )
184
+ if result.returncode != 0:
185
+ raise self._failure(result.returncode, result.stderr or result.stdout)
186
+ return result.stdout
187
+
188
+ def _run_limited(self, command: list[str], watch: Path, max_bytes: int) -> str:
189
+ """Like ``_run``, but stop the program if ``watch`` grows past ``max_bytes``.
190
+
191
+ 7-Zip writes as it extracts, so a decompression bomb is cut off while it
192
+ is still small instead of filling the disk. Output goes to a temporary
193
+ file so a full pipe can never stall the program.
194
+ """
195
+ too_big = EngineError(
196
+ f"The archive would expand beyond the allowed size ({max_bytes} bytes); "
197
+ "extraction was stopped."
198
+ )
199
+
200
+ with tempfile.TemporaryFile() as output:
201
+ process = subprocess.Popen(
202
+ command,
203
+ stdin=subprocess.DEVNULL,
204
+ stdout=output,
205
+ stderr=subprocess.STDOUT,
206
+ )
207
+ try:
208
+ while True:
209
+ try:
210
+ code = process.wait(timeout=LIMIT_POLL_SECONDS)
211
+ break
212
+ except subprocess.TimeoutExpired:
213
+ if folder_size(watch) > max_bytes:
214
+ raise too_big from None
215
+ finally:
216
+ if process.poll() is None:
217
+ process.kill()
218
+ process.wait()
219
+
220
+ output.seek(0)
221
+ text = output.read().decode("utf-8", errors="replace")
222
+
223
+ if folder_size(watch) > max_bytes: # it finished between two checks
224
+ raise too_big
225
+ if code != 0:
226
+ raise self._failure(code, text)
227
+ return text
228
+
229
+ def pack(self, source: Path, dest_dir: Path, options: PackOptions) -> list[Path]:
230
+ if not source.is_file():
231
+ raise FileNotFoundError(f"Source file not found: {source}")
232
+
233
+ base = f"{source.name}{self.capabilities.archive_suffix}"
234
+ existing = existing_volumes(dest_dir, base)
235
+ if existing and not options.force:
236
+ raise OutputExistsError(
237
+ f"Archive volumes already exist in {dest_dir}. Use --force to replace."
238
+ )
239
+
240
+ dest_dir.mkdir(parents=True, exist_ok=True)
241
+
242
+ # Build in a temporary folder so a failed run never damages what exists.
243
+ with tempfile.TemporaryDirectory(dir=dest_dir, prefix=".batchvault-") as tmp:
244
+ staging = Path(tmp)
245
+ self._run(
246
+ build_pack_command(self.executable, staging / base, source, options)
247
+ )
248
+
249
+ built = existing_volumes(staging, base)
250
+ if not built:
251
+ raise EngineError(f"7-Zip produced no volumes for {source}")
252
+
253
+ return install_volumes(built, dest_dir, existing)
254
+
255
+ def unpack(
256
+ self,
257
+ volume: Path,
258
+ output_dir: Path,
259
+ *,
260
+ force: bool = False,
261
+ password: str | None = None,
262
+ max_bytes: int | None = None,
263
+ ) -> list[Path]:
264
+ first = find_volumes(volume)[0]
265
+ output_dir.mkdir(parents=True, exist_ok=True)
266
+
267
+ with tempfile.TemporaryDirectory(dir=output_dir, prefix=".batchvault-") as tmp:
268
+ staging = Path(tmp)
269
+ command = build_unpack_command(self.executable, first, staging, password)
270
+ if max_bytes is None:
271
+ self._run(command)
272
+ else:
273
+ self._run_limited(command, staging, max_bytes)
274
+
275
+ # Never accept links or other special files from an archive.
276
+ if any(p.is_symlink() or not p.is_file() for p in staging.iterdir()):
277
+ raise EngineError(
278
+ f"{first.name} contains a link or special file, which is refused."
279
+ )
280
+
281
+ extracted = sorted(staging.iterdir())
282
+ if not extracted:
283
+ raise EngineError(f"7-Zip extracted no files from {first}")
284
+
285
+ conflicts = [output_dir / p.name for p in extracted]
286
+ conflicts = [path for path in conflicts if path.exists()]
287
+ if conflicts and not force:
288
+ names = ", ".join(path.name for path in conflicts)
289
+ raise OutputExistsError(
290
+ f"Destination already exists: {names}. Use --force to overwrite."
291
+ )
292
+
293
+ restored = []
294
+ for item in extracted:
295
+ target = output_dir / item.name
296
+ os.replace(item, target)
297
+ restored.append(target)
298
+
299
+ return restored
300
+
301
+ def test(self, volume: Path, *, password: str | None = None) -> None:
302
+ first = find_volumes(volume)[0]
303
+ self._run(build_test_command(self.executable, first, password))
batchvault/errors.py ADDED
@@ -0,0 +1,38 @@
1
+ class BatchVaultError(Exception):
2
+ """Base class for all BatchVault errors."""
3
+
4
+
5
+ class EngineNotFoundError(BatchVaultError):
6
+ """The requested compression engine is not available."""
7
+
8
+
9
+ class OptionsError(BatchVaultError, ValueError):
10
+ """An option value is invalid."""
11
+
12
+
13
+ class ConfigError(BatchVaultError):
14
+ """The configuration file is unreadable or contains invalid settings."""
15
+
16
+
17
+ class SignatureError(BatchVaultError):
18
+ """A manifest signature is missing, invalid or cannot be created."""
19
+
20
+
21
+ class InputError(BatchVaultError):
22
+ """An input file, list or path is missing or unusable."""
23
+
24
+
25
+ class EngineError(BatchVaultError):
26
+ """A compression engine failed to run or reported an error."""
27
+
28
+
29
+ class OutputExistsError(BatchVaultError):
30
+ """The destination already exists and replacing it was not requested."""
31
+
32
+
33
+ class ManifestError(BatchVaultError):
34
+ """A manifest is missing, malformed or fails validation."""
35
+
36
+
37
+ class VolumeError(BatchVaultError):
38
+ """Archive volumes are missing, incomplete or corrupt."""
batchvault/eta.py ADDED
@@ -0,0 +1,39 @@
1
+ import time
2
+
3
+ # Wait for this much real run time before trusting the measured speed.
4
+ MIN_SECONDS = 5.0
5
+
6
+
7
+ def format_duration(seconds):
8
+ seconds = max(0, int(seconds))
9
+ hours, remainder = divmod(seconds, 3600)
10
+ minutes, secs = divmod(remainder, 60)
11
+ return f"{hours}:{minutes:02d}:{secs:02d}"
12
+
13
+
14
+ class EtaEstimator:
15
+ """ETA from the average speed of the whole run so far.
16
+
17
+ Speed is bytes completed divided by the time since the first file started,
18
+ so per-file overhead is included.
19
+ """
20
+
21
+ def __init__(self, clock=time.monotonic):
22
+ self.clock = clock
23
+ self.started_at = None
24
+
25
+ def start(self):
26
+ if self.started_at is None:
27
+ self.started_at = self.clock()
28
+
29
+ def estimate(self, done_bytes, total_bytes):
30
+ """Seconds remaining, or None while there is not enough data yet."""
31
+ if self.started_at is None or done_bytes <= 0:
32
+ return None
33
+
34
+ elapsed = self.clock() - self.started_at
35
+ if elapsed < MIN_SECONDS:
36
+ return None
37
+
38
+ remaining = max(0, total_bytes - done_bytes)
39
+ return remaining / (done_bytes / elapsed)