dblock 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.
dblock/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """dblock: lockfiles for bioinformatics reference databases."""
2
+
3
+ __version__ = "0.1.0"
dblock/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from dblock.cli import main
4
+
5
+ sys.exit(main())
dblock/cli.py ADDED
@@ -0,0 +1,386 @@
1
+ """Command-line interface. Exit codes: 0 success, 1 problems found, 2 usage or runtime error."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import datetime as dt
7
+ import json
8
+ import os
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ from dblock import __version__, compat, store
13
+ from dblock.download import DownloadError, download, hash_file, human_bytes
14
+ from dblock.lockfile import Entry, LockError, Lockfile
15
+ from dblock.recipes import Recipe, RecipeError, load_recipes, parse_spec
16
+
17
+ OK, PROBLEMS, ERROR = 0, 1, 2
18
+
19
+
20
+ def main(argv: list[str] | None = None) -> int:
21
+ parser = build_parser()
22
+ args = parser.parse_args(argv)
23
+ if not getattr(args, "func", None):
24
+ parser.print_help()
25
+ return ERROR
26
+ try:
27
+ return args.func(args)
28
+ except (LockError, RecipeError, store.StoreError, DownloadError, compat.SpecError) as exc:
29
+ print(f"error: {exc}", file=sys.stderr)
30
+ return ERROR
31
+ except KeyboardInterrupt:
32
+ print("\ninterrupted; partial downloads are kept and resume on the next fetch", file=sys.stderr)
33
+ return ERROR
34
+
35
+
36
+ def build_parser() -> argparse.ArgumentParser:
37
+ common = argparse.ArgumentParser(add_help=False)
38
+ common.add_argument("--lock", default=os.environ.get("DBLOCK_LOCK", "dblock.lock"),
39
+ help="lockfile path (default: ./dblock.lock or $DBLOCK_LOCK)")
40
+ common.add_argument("--store", type=Path, default=None,
41
+ help="database store directory (default: $DBLOCK_STORE or ~/.dblock/store)")
42
+ common.add_argument("--recipes", default=os.environ.get("DBLOCK_RECIPES"),
43
+ help="extra recipe directory; overrides bundled recipes with the same name")
44
+ common.add_argument("-q", "--quiet", action="store_true", help="only print results and errors")
45
+
46
+ parser = argparse.ArgumentParser(
47
+ prog="dblock",
48
+ description="Lockfiles for bioinformatics reference databases: pin, fetch, verify and cite exact releases.",
49
+ )
50
+ parser.add_argument("--version", action="version", version=f"dblock {__version__}")
51
+ sub = parser.add_subparsers(title="commands", metavar="COMMAND")
52
+
53
+ def cmd(name: str, func, help_text: str) -> argparse.ArgumentParser:
54
+ p = sub.add_parser(name, parents=[common], help=help_text, description=help_text)
55
+ p.set_defaults(func=func)
56
+ return p
57
+
58
+ p = cmd("recipes", cmd_recipes, "list known databases and their releases")
59
+ p.add_argument("name", nargs="?", help="show details for one database")
60
+
61
+ p = cmd("add", cmd_add, "pin database releases in the lockfile (NAME or NAME@RELEASE)")
62
+ p.add_argument("specs", nargs="+", metavar="NAME[@RELEASE]")
63
+
64
+ p = cmd("remove", cmd_remove, "remove databases from the lockfile (store files are kept)")
65
+ p.add_argument("names", nargs="+")
66
+
67
+ p = cmd("fetch", cmd_fetch, "download, check and unpack locked databases into the store")
68
+ p.add_argument("names", nargs="*", help="default: all locked databases")
69
+ p.add_argument("--keep-archive", action="store_true", help="keep the downloaded archive after unpacking")
70
+ p.add_argument("--force", action="store_true", help="re-download even if already installed")
71
+
72
+ p = cmd("verify", cmd_verify, "check installed files against the lockfile (fast: names and sizes)")
73
+ p.add_argument("names", nargs="*")
74
+ p.add_argument("--full", action="store_true", help="also re-hash every file (slow for large databases)")
75
+
76
+ p = cmd("status", cmd_status, "show locked databases, install state and checksum origin")
77
+
78
+ p = cmd("path", cmd_path, "print the installed data directory of a database (for scripts and pipelines)")
79
+ p.add_argument("name")
80
+
81
+ p = cmd("check-compat", cmd_check_compat, "check installed tool versions against each database release")
82
+ p.add_argument("names", nargs="*")
83
+
84
+ p = cmd("provenance", cmd_provenance, "print exact database provenance for a methods section or report")
85
+ p.add_argument("--format", choices=("text", "json"), default="text")
86
+
87
+ p = cmd("diff", cmd_diff, "compare the files of two installed releases of one database")
88
+ p.add_argument("name")
89
+ p.add_argument("release_a")
90
+ p.add_argument("release_b")
91
+ p.add_argument("--limit", type=int, default=20, help="paths to list per category (default 20)")
92
+ return parser
93
+
94
+
95
+ # ------------------------------------------------------------------ helpers
96
+
97
+ def _store(args) -> Path:
98
+ return args.store if args.store else store.default_store()
99
+
100
+
101
+ def _log(args, message: str) -> None:
102
+ if not args.quiet:
103
+ print(message, file=sys.stderr)
104
+
105
+
106
+ def _recipe_for(entry: Entry, recipes: dict[str, Recipe], args) -> Recipe | None:
107
+ recipe = recipes.get(entry.name)
108
+ if recipe and recipe.digest != entry.recipe_digest:
109
+ _log(args, f" note: the {entry.name} recipe changed since it was locked; the lockfile values are used")
110
+ return recipe
111
+
112
+
113
+ # ------------------------------------------------------------------ commands
114
+
115
+ def cmd_recipes(args) -> int:
116
+ recipes = load_recipes(args.recipes)
117
+ if args.name:
118
+ r = recipes.get(args.name)
119
+ if not r:
120
+ raise RecipeError(f"unknown database '{args.name}'. Known: {', '.join(sorted(recipes))}")
121
+ print(f"{r.name}: {r.description}\n homepage: {r.homepage}\n recipe: {r.source}")
122
+ if r.citation:
123
+ print(f" cite: {r.citation}")
124
+ for note in r.notes:
125
+ print(f" note: {note}")
126
+ print(" releases (newest first):")
127
+ for rel in r.releases:
128
+ checksum = rel.checksum.split(":")[0] if rel.checksum else "none upstream"
129
+ tools = f" {r.tool} {rel.tool_versions}" if rel.tool_versions else ""
130
+ size = human_bytes(rel.size) if rel.size else "size unknown"
131
+ print(f" {rel.version:<22} {rel.date or '-':<11} {size:>9} checksum: {checksum:<13}{tools}")
132
+ if rel.note:
133
+ print(f" {rel.note}")
134
+ return OK
135
+ width = max(len(n) for n in recipes)
136
+ for name in sorted(recipes):
137
+ r = recipes[name]
138
+ print(f"{name:<{width}} {', '.join(rel.version for rel in r.releases)}")
139
+ return OK
140
+
141
+
142
+ def cmd_add(args) -> int:
143
+ recipes = load_recipes(args.recipes)
144
+ lock = Lockfile.load(args.lock, must_exist=False)
145
+ for spec in args.specs:
146
+ name, version = parse_spec(spec)
147
+ recipe = recipes.get(name)
148
+ if not recipe:
149
+ raise RecipeError(f"unknown database '{name}'. Known: {', '.join(sorted(recipes))}")
150
+ release = recipe.release(version)
151
+ old = lock.entries.get(name)
152
+ if old and old.release == release.version:
153
+ _log(args, f"{name}@{release.version} is already locked")
154
+ continue
155
+ lock.entries[name] = Entry.from_recipe(recipe, release)
156
+ what = f"replaced {old.release} with" if old else "locked"
157
+ latest = " (newest known release)" if version is None else ""
158
+ _log(args, f"{name}: {what} {release.version}{latest}")
159
+ lock.save()
160
+ return OK
161
+
162
+
163
+ def cmd_remove(args) -> int:
164
+ lock = Lockfile.load(args.lock)
165
+ for name in args.names:
166
+ lock.get(name)
167
+ del lock.entries[name]
168
+ _log(args, f"{name}: removed from {lock.path}")
169
+ lock.save()
170
+ return OK
171
+
172
+
173
+ def cmd_fetch(args) -> int:
174
+ lock = Lockfile.load(args.lock)
175
+ recipes = load_recipes(args.recipes)
176
+ root = _store(args)
177
+ problems = 0
178
+ for entry in lock.select(args.names):
179
+ _log(args, f"{entry.name}@{entry.release}")
180
+ target = store.data_dir(root, entry.name, entry.release)
181
+ manifest_path = store.install_dir(root, entry.name, entry.release) / "manifest.json"
182
+ if manifest_path.exists() and not args.force:
183
+ manifest = store.Manifest.load(manifest_path)
184
+ if entry.tree_digest and manifest.digest != entry.tree_digest:
185
+ print(f" PROBLEM: installed copy differs from the lockfile (digest {manifest.digest}); "
186
+ f"use --force to re-fetch", file=sys.stderr)
187
+ problems += 1
188
+ continue
189
+ if not entry.fetched:
190
+ _record(entry, manifest)
191
+ _log(args, f" already installed at {target}")
192
+ continue
193
+ recipe = _recipe_for(entry, recipes, args)
194
+ size = 0
195
+ layout: tuple[str, ...] = ()
196
+ if recipe:
197
+ try:
198
+ rel = recipe.release(entry.release)
199
+ size, layout = rel.size, rel.layout
200
+ except RecipeError:
201
+ pass
202
+ archive = root / ".downloads" / entry.filename
203
+ used = download(entry.urls, archive, expected_size=size, quiet=args.quiet)
204
+ _log(args, f" downloaded from {used}")
205
+
206
+ algorithms = ["sha256"]
207
+ upstream_algo, _, upstream_hex = entry.upstream_checksum.partition(":")
208
+ if upstream_algo == "md5":
209
+ algorithms.append("md5")
210
+ digests = hash_file(archive, tuple(algorithms), progress="" if args.quiet else " hashing archive")
211
+ if upstream_hex and digests[upstream_algo] != upstream_hex.lower():
212
+ archive.rename(archive.with_name(archive.name + ".bad"))
213
+ print(f" PROBLEM: {upstream_algo} mismatch: upstream says {upstream_hex}, got {digests[upstream_algo]}. "
214
+ f"Kept as {archive.name}.bad", file=sys.stderr)
215
+ problems += 1
216
+ continue
217
+ if entry.archive_sha256 and digests["sha256"] != entry.archive_sha256:
218
+ archive.rename(archive.with_name(archive.name + ".bad"))
219
+ print(f" PROBLEM: the file served now differs from the locked archive "
220
+ f"(locked sha256 {entry.archive_sha256}, got {digests['sha256']}). "
221
+ f"Upstream may have replaced it. Kept as {archive.name}.bad", file=sys.stderr)
222
+ problems += 1
223
+ continue
224
+ origin = "upstream-verified" if upstream_hex else "first-download"
225
+ _log(args, " checksum: " + ("matches upstream " + upstream_algo if upstream_hex
226
+ else "no upstream checksum; recording sha256 from this download"))
227
+
228
+ _log(args, f" unpacking ({entry.format})")
229
+ store.unpack(archive, entry.format, target)
230
+ missing = store.check_layout(target, layout)
231
+ if missing:
232
+ print(f" PROBLEM: unpacked files do not match the expected layout; missing: {', '.join(missing)}",
233
+ file=sys.stderr)
234
+ problems += 1
235
+ manifest = store.build_manifest(entry.name, entry.release, digests["sha256"], target, args.quiet)
236
+ manifest.save(manifest_path)
237
+ if entry.tree_digest and manifest.digest != entry.tree_digest:
238
+ print(f" PROBLEM: unpacked contents differ from the lockfile digest", file=sys.stderr)
239
+ problems += 1
240
+ entry.archive_sha256 = digests["sha256"]
241
+ if not entry.checksum_origin:
242
+ entry.checksum_origin = origin
243
+ _record(entry, manifest)
244
+ if not args.keep_archive:
245
+ archive.unlink()
246
+ _log(args, f" installed {human_bytes(manifest.total_bytes)} in {len(manifest.files)} files at {target}")
247
+ lock.save()
248
+ lock.save()
249
+ return PROBLEMS if problems else OK
250
+
251
+
252
+ def _record(entry: Entry, manifest: store.Manifest) -> None:
253
+ entry.tree_digest = manifest.digest
254
+ entry.files = len(manifest.files)
255
+ entry.bytes = manifest.total_bytes
256
+ if not entry.archive_sha256:
257
+ entry.archive_sha256 = manifest.archive_sha256
258
+ entry.fetched_at = entry.fetched_at or dt.date.today().isoformat()
259
+
260
+
261
+ def cmd_verify(args) -> int:
262
+ lock = Lockfile.load(args.lock)
263
+ root = _store(args)
264
+ problems = 0
265
+ for entry in lock.select(args.names):
266
+ label = f"{entry.name}@{entry.release}"
267
+ if not entry.fetched:
268
+ print(f"{label}: NOT FETCHED (run `dblock fetch {entry.name}`)")
269
+ problems += 1
270
+ continue
271
+ manifest_path = store.install_dir(root, entry.name, entry.release) / "manifest.json"
272
+ if not manifest_path.exists():
273
+ print(f"{label}: NOT INSTALLED in {root}")
274
+ problems += 1
275
+ continue
276
+ manifest = store.Manifest.load(manifest_path)
277
+ if manifest.digest != entry.tree_digest:
278
+ print(f"{label}: MISMATCH installed manifest {manifest.digest} != locked {entry.tree_digest}")
279
+ problems += 1
280
+ continue
281
+ result = store.verify(manifest, store.data_dir(root, entry.name, entry.release), args.full, args.quiet)
282
+ mode = "full" if args.full else "fast"
283
+ if result.ok:
284
+ print(f"{label}: OK ({mode}; {entry.files} files, {human_bytes(entry.bytes)})")
285
+ else:
286
+ problems += 1
287
+ print(f"{label}: FAILED ({mode})")
288
+ for line in result.summary():
289
+ print(f" {line}")
290
+ return PROBLEMS if problems else OK
291
+
292
+
293
+ def cmd_status(args) -> int:
294
+ lock = Lockfile.load(args.lock)
295
+ root = _store(args)
296
+ if not lock.entries:
297
+ print("lockfile is empty")
298
+ return OK
299
+ print(f"lockfile: {lock.path}\nstore: {root}\n")
300
+ for entry in lock.select(None):
301
+ installed = (store.install_dir(root, entry.name, entry.release) / "manifest.json").exists()
302
+ state = "installed" if installed else ("locked, not on this machine" if entry.fetched else "not fetched")
303
+ origin = entry.checksum_origin or "-"
304
+ print(f"{entry.name:<18} {entry.release:<24} {state:<28} checksum: {origin}")
305
+ return OK
306
+
307
+
308
+ def cmd_path(args) -> int:
309
+ lock = Lockfile.load(args.lock)
310
+ entry = lock.get(args.name)
311
+ target = store.data_dir(_store(args), entry.name, entry.release)
312
+ if not target.is_dir():
313
+ print(f"error: {entry.name}@{entry.release} is not installed; run `dblock fetch {entry.name}`", file=sys.stderr)
314
+ return PROBLEMS
315
+ print(target)
316
+ return OK
317
+
318
+
319
+ def cmd_check_compat(args) -> int:
320
+ lock = Lockfile.load(args.lock)
321
+ recipes = load_recipes(args.recipes)
322
+ bad = 0
323
+ for entry in lock.select(args.names):
324
+ recipe = recipes.get(entry.name)
325
+ command = recipe.version_command if recipe else ()
326
+ regex = recipe.version_regex if recipe else r"(\d+(?:\.\d+)+)"
327
+ result = compat.check(command, regex, entry.tool_versions)
328
+ tool = entry.tool or "-"
329
+ found = f" {result.tool_version}" if result.tool_version else ""
330
+ print(f"{entry.name}@{entry.release}: {result.status.upper()} ({tool}{found}; {result.detail})")
331
+ bad += result.status == "incompatible"
332
+ return PROBLEMS if bad else OK
333
+
334
+
335
+ def cmd_provenance(args) -> int:
336
+ lock = Lockfile.load(args.lock)
337
+ recipes = load_recipes(args.recipes)
338
+ rows = []
339
+ for entry in lock.select(None):
340
+ recipe = recipes.get(entry.name)
341
+ rows.append({
342
+ "name": entry.name,
343
+ "release": entry.release,
344
+ "source": entry.urls[0] if entry.urls else "",
345
+ "archive_sha256": entry.archive_sha256,
346
+ "checksum_origin": entry.checksum_origin,
347
+ "tree_digest": entry.tree_digest,
348
+ "fetched_at": entry.fetched_at,
349
+ "citation": recipe.citation if recipe else "",
350
+ })
351
+ if args.format == "json":
352
+ print(json.dumps({"dblock_version": __version__, "databases": rows}, indent=2))
353
+ return OK
354
+ print("Reference databases (pinned with dblock {}):".format(__version__))
355
+ for row in rows:
356
+ if not row["archive_sha256"]:
357
+ print(f"- {row['name']} {row['release']} (not fetched yet; no checksum recorded)")
358
+ continue
359
+ origin = "verified against upstream checksum" if row["checksum_origin"] == "upstream-verified" \
360
+ else "no upstream checksum published; hash recorded at first download"
361
+ print(f"- {row['name']} {row['release']}, downloaded {row['fetched_at']} from {row['source']} "
362
+ f"(archive SHA-256 {row['archive_sha256'][:16]}..., {origin}).")
363
+ if row["citation"]:
364
+ print(f" Cite: {row['citation']}")
365
+ return OK
366
+
367
+
368
+ def cmd_diff(args) -> int:
369
+ root = _store(args)
370
+ manifests = []
371
+ for release in (args.release_a, args.release_b):
372
+ path = store.install_dir(root, args.name, release) / "manifest.json"
373
+ manifests.append(store.Manifest.load(path))
374
+ d = store.diff(*manifests)
375
+ print(f"{args.name}: {args.release_a} -> {args.release_b}")
376
+ print(f" {len(d.added)} added, {len(d.removed)} removed, {len(d.changed)} changed, {d.unchanged} unchanged files")
377
+ for label, items in (("added", d.added), ("removed", d.removed), ("changed", d.changed)):
378
+ for path in items[: args.limit]:
379
+ print(f" {label:<8} {path}")
380
+ if len(items) > args.limit:
381
+ print(f" {label:<8} ... {len(items) - args.limit} more")
382
+ return OK
383
+
384
+
385
+ if __name__ == "__main__":
386
+ sys.exit(main())
dblock/compat.py ADDED
@@ -0,0 +1,70 @@
1
+ """Tool-version compatibility: does the installed tool accept this database release?"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import shutil
7
+ import subprocess
8
+ from dataclasses import dataclass
9
+
10
+ _OPS = {
11
+ ">=": lambda a, b: a >= b,
12
+ "<=": lambda a, b: a <= b,
13
+ "==": lambda a, b: a == b,
14
+ "!=": lambda a, b: a != b,
15
+ ">": lambda a, b: a > b,
16
+ "<": lambda a, b: a < b,
17
+ }
18
+
19
+
20
+ class SpecError(Exception):
21
+ pass
22
+
23
+
24
+ def parse_version(text: str) -> tuple[int, ...]:
25
+ match = re.fullmatch(r"\s*v?(\d+(?:\.\d+)*)\s*", text)
26
+ if not match:
27
+ raise SpecError(f"not a numeric version: '{text}'")
28
+ parts = [int(p) for p in match.group(1).split(".")]
29
+ while len(parts) > 1 and parts[-1] == 0: # 2.7 == 2.7.0
30
+ parts.pop()
31
+ return tuple(parts)
32
+
33
+
34
+ def satisfies(version: str, spec: str) -> bool:
35
+ """satisfies('2.5.0', '>=2.4.1,<=2.6.1') -> True. An empty spec accepts anything."""
36
+ v = parse_version(version)
37
+ for clause in filter(None, (c.strip() for c in spec.split(","))):
38
+ match = re.fullmatch(r"(>=|<=|==|!=|>|<)\s*(.+)", clause)
39
+ if not match:
40
+ raise SpecError(f"bad version clause '{clause}' in '{spec}'")
41
+ op, bound = match.groups()
42
+ if not _OPS[op](v, parse_version(bound)):
43
+ return False
44
+ return True
45
+
46
+
47
+ @dataclass
48
+ class CompatResult:
49
+ status: str # "ok", "incompatible", "tool-not-found", "unknown-version", "no-rule"
50
+ tool_version: str = ""
51
+ detail: str = ""
52
+
53
+
54
+ def check(command: tuple[str, ...], regex: str, spec: str) -> CompatResult:
55
+ if not spec:
56
+ return CompatResult("no-rule", detail="recipe states no tool-version rule")
57
+ if not command:
58
+ return CompatResult("no-rule", detail="recipe has no version command")
59
+ if shutil.which(command[0]) is None:
60
+ return CompatResult("tool-not-found", detail=f"'{command[0]}' is not on PATH")
61
+ try:
62
+ proc = subprocess.run(command, capture_output=True, text=True, timeout=120)
63
+ except (OSError, subprocess.TimeoutExpired) as exc:
64
+ return CompatResult("unknown-version", detail=str(exc))
65
+ match = re.search(regex, proc.stdout + "\n" + proc.stderr)
66
+ if not match:
67
+ return CompatResult("unknown-version", detail=f"no version found in output of {' '.join(command)}")
68
+ version = match.group(1)
69
+ ok = satisfies(version, spec)
70
+ return CompatResult("ok" if ok else "incompatible", version, f"needs {spec}")
dblock/download.py ADDED
@@ -0,0 +1,125 @@
1
+ """Resumable downloads with mirror fallback, and file hashing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import sys
7
+ import time
8
+ import urllib.error
9
+ import urllib.request
10
+ from pathlib import Path
11
+
12
+ from dblock import __version__
13
+
14
+ CHUNK = 1024 * 1024
15
+ USER_AGENT = f"dblock/{__version__}"
16
+
17
+
18
+ class DownloadError(Exception):
19
+ pass
20
+
21
+
22
+ def hash_file(path: Path, algorithms: tuple[str, ...] = ("sha256",), progress: str = "") -> dict[str, str]:
23
+ hashers = {name: hashlib.new(name) for name in algorithms}
24
+ total = path.stat().st_size
25
+ done = 0
26
+ reporter = _Progress(progress, total) if progress else None
27
+ with path.open("rb") as handle:
28
+ while chunk := handle.read(CHUNK):
29
+ for h in hashers.values():
30
+ h.update(chunk)
31
+ done += len(chunk)
32
+ if reporter:
33
+ reporter.update(done)
34
+ if reporter:
35
+ reporter.finish()
36
+ return {name: h.hexdigest() for name, h in hashers.items()}
37
+
38
+
39
+ def download(urls: list[str], dest: Path, expected_size: int = 0, retries: int = 3, quiet: bool = False) -> str:
40
+ """Download to dest, resuming a partial `dest.part`. Returns the URL that worked."""
41
+ dest.parent.mkdir(parents=True, exist_ok=True)
42
+ part = dest.with_name(dest.name + ".part")
43
+ errors = []
44
+ for url in urls:
45
+ for attempt in range(1, retries + 1):
46
+ try:
47
+ _fetch(url, part, expected_size, quiet)
48
+ part.replace(dest)
49
+ return url
50
+ except (urllib.error.URLError, OSError, DownloadError) as exc:
51
+ errors.append(f"{url} (attempt {attempt}): {exc}")
52
+ if not quiet:
53
+ print(f" download problem: {exc}; retrying" if attempt < retries else f" giving up on {url}", file=sys.stderr)
54
+ time.sleep(min(2 ** attempt, 10) if attempt < retries else 0)
55
+ raise DownloadError("all download sources failed:\n " + "\n ".join(errors))
56
+
57
+
58
+ def _fetch(url: str, part: Path, expected_size: int, quiet: bool) -> None:
59
+ have = part.stat().st_size if part.exists() else 0
60
+ if expected_size and have == expected_size:
61
+ return
62
+ if expected_size and have > expected_size:
63
+ part.unlink()
64
+ have = 0
65
+ request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
66
+ if have:
67
+ request.add_header("Range", f"bytes={have}-")
68
+ with urllib.request.urlopen(request, timeout=60) as response:
69
+ status = getattr(response, "status", 200)
70
+ if have and status != 206:
71
+ have = 0 # server ignored the Range header: start again
72
+ length = response.headers.get("Content-Length")
73
+ total = (have + int(length)) if length else expected_size
74
+ if expected_size and total and total != expected_size:
75
+ raise DownloadError(f"server reports {total} bytes, recipe expects {expected_size}")
76
+ reporter = None if quiet else _Progress(f" downloading {part.name[:-5]}", total, start=have)
77
+ with part.open("ab" if have else "wb") as out:
78
+ done = have
79
+ while chunk := response.read(CHUNK):
80
+ out.write(chunk)
81
+ done += len(chunk)
82
+ if reporter:
83
+ reporter.update(done)
84
+ if reporter:
85
+ reporter.finish()
86
+ size = part.stat().st_size
87
+ if expected_size and size != expected_size:
88
+ raise DownloadError(f"incomplete download: {size} of {expected_size} bytes")
89
+
90
+
91
+ class _Progress:
92
+ """Progress on one updating line; small, fast jobs print nothing."""
93
+
94
+ def __init__(self, label: str, total: int, start: int = 0):
95
+ self.label, self.total, self.done = label, total, start
96
+ self.started = self.last = time.monotonic()
97
+ self.shown = False
98
+
99
+ def update(self, done: int) -> None:
100
+ self.done = done
101
+ now = time.monotonic()
102
+ if now - self.last >= 2:
103
+ self.last = now
104
+ self._show()
105
+
106
+ def _show(self) -> None:
107
+ self.shown = True
108
+ if self.total:
109
+ text = f"{self.done / self.total:6.1%} of {human_bytes(self.total)}"
110
+ else:
111
+ text = human_bytes(self.done)
112
+ print(f"\r{self.label}: {text}", end="", file=sys.stderr)
113
+
114
+ def finish(self) -> None:
115
+ if self.shown:
116
+ self._show()
117
+ print(file=sys.stderr)
118
+
119
+
120
+ def human_bytes(n: float) -> str:
121
+ for unit in ("B", "KB", "MB", "GB", "TB"):
122
+ if n < 1024 or unit == "TB":
123
+ return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
124
+ n /= 1024
125
+ return f"{n} B"