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 +3 -0
- dblock/__main__.py +5 -0
- dblock/cli.py +386 -0
- dblock/compat.py +70 -0
- dblock/download.py +125 -0
- dblock/lockfile.py +132 -0
- dblock/recipes/bakta-galaxy-test.toml +17 -0
- dblock/recipes/bakta.toml +34 -0
- dblock/recipes/checkm2.toml +18 -0
- dblock/recipes/gtdbtk.toml +50 -0
- dblock/recipes/kraken2.toml +73 -0
- dblock/recipes.py +126 -0
- dblock/store.py +231 -0
- dblock-0.1.0.dist-info/METADATA +133 -0
- dblock-0.1.0.dist-info/RECORD +19 -0
- dblock-0.1.0.dist-info/WHEEL +5 -0
- dblock-0.1.0.dist-info/entry_points.txt +2 -0
- dblock-0.1.0.dist-info/licenses/LICENSE +21 -0
- dblock-0.1.0.dist-info/top_level.txt +1 -0
dblock/__init__.py
ADDED
dblock/__main__.py
ADDED
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"
|