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/lockfile.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Read and write dblock.lock (TOML)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tomllib
|
|
7
|
+
from dataclasses import asdict, dataclass, field, fields
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from dblock.recipes import Recipe, Release
|
|
11
|
+
|
|
12
|
+
LOCK_VERSION = 1
|
|
13
|
+
HEADER = (
|
|
14
|
+
"# dblock lockfile: exact reference database releases used by this analysis.\n"
|
|
15
|
+
"# Generated by dblock. Commit it with your code; edit with `dblock add/remove/fetch`.\n"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class LockError(Exception):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Entry:
|
|
25
|
+
name: str
|
|
26
|
+
release: str
|
|
27
|
+
recipe_digest: str
|
|
28
|
+
filename: str
|
|
29
|
+
format: str
|
|
30
|
+
urls: list[str] = field(default_factory=list)
|
|
31
|
+
upstream_checksum: str = ""
|
|
32
|
+
tool: str = ""
|
|
33
|
+
tool_versions: str = ""
|
|
34
|
+
# Filled by `dblock fetch`:
|
|
35
|
+
archive_sha256: str = ""
|
|
36
|
+
checksum_origin: str = "" # "upstream-verified" or "first-download"
|
|
37
|
+
tree_digest: str = ""
|
|
38
|
+
files: int = 0
|
|
39
|
+
bytes: int = 0
|
|
40
|
+
fetched_at: str = ""
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def fetched(self) -> bool:
|
|
44
|
+
return bool(self.tree_digest)
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def from_recipe(cls, recipe: Recipe, release: Release) -> "Entry":
|
|
48
|
+
return cls(
|
|
49
|
+
name=recipe.name,
|
|
50
|
+
release=release.version,
|
|
51
|
+
recipe_digest=recipe.digest,
|
|
52
|
+
filename=release.filename,
|
|
53
|
+
format=release.format,
|
|
54
|
+
urls=list(release.urls),
|
|
55
|
+
upstream_checksum=release.checksum,
|
|
56
|
+
tool=recipe.tool,
|
|
57
|
+
tool_versions=release.tool_versions,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class Lockfile:
|
|
63
|
+
path: Path
|
|
64
|
+
entries: dict[str, Entry] = field(default_factory=dict)
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def load(cls, path: str | Path, must_exist: bool = True) -> "Lockfile":
|
|
68
|
+
path = Path(path)
|
|
69
|
+
if not path.exists():
|
|
70
|
+
if must_exist:
|
|
71
|
+
raise LockError(f"lockfile not found: {path} (create one with `dblock add NAME@RELEASE`)")
|
|
72
|
+
return cls(path)
|
|
73
|
+
try:
|
|
74
|
+
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
75
|
+
except tomllib.TOMLDecodeError as exc:
|
|
76
|
+
raise LockError(f"{path}: invalid TOML: {exc}") from exc
|
|
77
|
+
if data.get("lock_version") != LOCK_VERSION:
|
|
78
|
+
raise LockError(f"{path}: unsupported lock_version {data.get('lock_version')!r}")
|
|
79
|
+
known = {f.name for f in fields(Entry)}
|
|
80
|
+
entries = {}
|
|
81
|
+
for name, raw in data.get("databases", {}).items():
|
|
82
|
+
unknown = set(raw) - known
|
|
83
|
+
if unknown:
|
|
84
|
+
raise LockError(f"{path}: unknown keys for '{name}': {sorted(unknown)}")
|
|
85
|
+
entries[name] = Entry(name=name, **{k: v for k, v in raw.items() if k != "name"})
|
|
86
|
+
return cls(path, entries)
|
|
87
|
+
|
|
88
|
+
def save(self) -> None:
|
|
89
|
+
lines = [HEADER, f"lock_version = {LOCK_VERSION}\n"]
|
|
90
|
+
for name in sorted(self.entries):
|
|
91
|
+
entry = asdict(self.entries[name])
|
|
92
|
+
entry.pop("name")
|
|
93
|
+
lines.append(f"\n[databases.{_key(name)}]\n")
|
|
94
|
+
for key, value in entry.items():
|
|
95
|
+
lines.append(f"{key} = {_value(value)}\n")
|
|
96
|
+
tmp = self.path.with_name(self.path.name + ".tmp")
|
|
97
|
+
tmp.write_text("".join(lines), encoding="utf-8")
|
|
98
|
+
os.replace(tmp, self.path)
|
|
99
|
+
|
|
100
|
+
def get(self, name: str) -> Entry:
|
|
101
|
+
try:
|
|
102
|
+
return self.entries[name]
|
|
103
|
+
except KeyError:
|
|
104
|
+
raise LockError(f"'{name}' is not in {self.path}") from None
|
|
105
|
+
|
|
106
|
+
def select(self, names: list[str] | None) -> list[Entry]:
|
|
107
|
+
if not names:
|
|
108
|
+
return [self.entries[n] for n in sorted(self.entries)]
|
|
109
|
+
return [self.get(n) for n in names]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _key(name: str) -> str:
|
|
113
|
+
if name.replace("-", "").replace("_", "").isalnum():
|
|
114
|
+
return name
|
|
115
|
+
return _string(name)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _string(text: str) -> str:
|
|
119
|
+
escaped = text.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
|
|
120
|
+
return f'"{escaped}"'
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _value(value: object) -> str:
|
|
124
|
+
if isinstance(value, bool):
|
|
125
|
+
return "true" if value else "false"
|
|
126
|
+
if isinstance(value, int):
|
|
127
|
+
return str(value)
|
|
128
|
+
if isinstance(value, str):
|
|
129
|
+
return _string(value)
|
|
130
|
+
if isinstance(value, list):
|
|
131
|
+
return "[" + ", ".join(_value(v) for v in value) + "]"
|
|
132
|
+
raise TypeError(f"cannot write {type(value).__name__} to TOML")
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
name = "bakta-galaxy-test"
|
|
2
|
+
description = "Tiny Bakta test database used by the Galaxy wrapper (390 KB); handy for trying dblock"
|
|
3
|
+
homepage = "https://zenodo.org/records/22298301"
|
|
4
|
+
notes = [
|
|
5
|
+
"Test data only, not for annotation.",
|
|
6
|
+
"The Zenodo record is labelled 6.0, but the archive contains a folder named V5.1_light_2024-01-19.",
|
|
7
|
+
]
|
|
8
|
+
|
|
9
|
+
[[releases]]
|
|
10
|
+
version = "6.0"
|
|
11
|
+
date = "2026-09-04"
|
|
12
|
+
filename = "bakta-galaxy-test-db-light.tar.gz"
|
|
13
|
+
format = "tar.gz"
|
|
14
|
+
size = 390214
|
|
15
|
+
checksum = "md5:29ac0addf985d7255c0928326a67178c"
|
|
16
|
+
layout = ["*/version.json", "*/bakta.db"]
|
|
17
|
+
urls = ["https://zenodo.org/api/records/22298301/files/db-light.tar.gz/content"]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
name = "bakta"
|
|
2
|
+
description = "Bakta annotation database (full or light)"
|
|
3
|
+
homepage = "https://doi.org/10.5281/zenodo.4247253"
|
|
4
|
+
tool = "bakta"
|
|
5
|
+
version_command = ["bakta", "--version"]
|
|
6
|
+
citation = "Schwengers O et al. (2021) Bakta: rapid and standardized annotation of bacterial genomes via alignment-free sequence identification. Microbial Genomics 7(11):000685"
|
|
7
|
+
notes = [
|
|
8
|
+
"Minimum Bakta versions are from the database's db-versions.json on Zenodo.",
|
|
9
|
+
"Point Bakta at the unpacked folder: bakta --db $(dblock path bakta)/db (or /db-light)",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[[releases]]
|
|
13
|
+
version = "6.0-light"
|
|
14
|
+
date = "2025-02-24"
|
|
15
|
+
filename = "db-light.tar.xz"
|
|
16
|
+
format = "tar.xz"
|
|
17
|
+
size = 1344199496
|
|
18
|
+
checksum = "md5:4a6e059ded39e9c5537ef4137d2f5648"
|
|
19
|
+
tool_versions = ">=1.11"
|
|
20
|
+
layout = ["db-light/version.json"]
|
|
21
|
+
urls = ["https://zenodo.org/api/records/14916843/files/db-light.tar.xz/content"]
|
|
22
|
+
note = "Zenodo record 14916843"
|
|
23
|
+
|
|
24
|
+
[[releases]]
|
|
25
|
+
version = "6.0"
|
|
26
|
+
date = "2025-02-24"
|
|
27
|
+
filename = "db.tar.xz"
|
|
28
|
+
format = "tar.xz"
|
|
29
|
+
size = 31921769288
|
|
30
|
+
checksum = "md5:4c1115e40abfa2b464ae5dd988bdd88e"
|
|
31
|
+
tool_versions = ">=1.11"
|
|
32
|
+
layout = ["db/version.json"]
|
|
33
|
+
urls = ["https://zenodo.org/api/records/14916843/files/db.tar.xz/content"]
|
|
34
|
+
note = "Zenodo record 14916843"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
name = "checkm2"
|
|
2
|
+
description = "CheckM2 reference database (UniRef100/KO DIAMOND database)"
|
|
3
|
+
homepage = "https://github.com/chklovski/CheckM2"
|
|
4
|
+
tool = "checkm2"
|
|
5
|
+
version_command = ["checkm2", "--version"]
|
|
6
|
+
citation = "Chklovski A et al. (2023) CheckM2: a rapid, scalable and accurate tool for assessing microbial genome quality using machine learning. Nature Methods 20:1203-1212"
|
|
7
|
+
notes = ["Point CheckM2 at the .dmnd file: checkm2 predict --database_path $(dblock path checkm2)/CheckM2_database/uniref100.KO.1.dmnd"]
|
|
8
|
+
|
|
9
|
+
[[releases]]
|
|
10
|
+
version = "3"
|
|
11
|
+
date = "2025-02-20"
|
|
12
|
+
filename = "checkm2_database.tar.gz"
|
|
13
|
+
format = "tar.gz"
|
|
14
|
+
size = 1735095710
|
|
15
|
+
checksum = "md5:07c10655620843b517d0df0c160d911f"
|
|
16
|
+
layout = ["CheckM2_database/uniref100.KO.1.dmnd"]
|
|
17
|
+
urls = ["https://zenodo.org/api/records/14897628/files/checkm2_database.tar.gz/content"]
|
|
18
|
+
note = "Zenodo record 14897628"
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
name = "gtdbtk"
|
|
2
|
+
description = "GTDB-Tk reference data package (GTDB release)"
|
|
3
|
+
homepage = "https://ecogenomics.github.io/GTDBTk/installing/index.html"
|
|
4
|
+
tool = "gtdbtk"
|
|
5
|
+
version_command = ["gtdbtk", "--version"]
|
|
6
|
+
citation = "Chaumeil P-A et al. (2022) GTDB-Tk v2. Bioinformatics 38(23):5315-5316"
|
|
7
|
+
notes = [
|
|
8
|
+
"GTDB does not publish checksums for these packages; dblock records the SHA-256 at first download.",
|
|
9
|
+
"Compatibility ranges are from the GTDB-Tk installation docs.",
|
|
10
|
+
"Point GTDB-Tk at the unpacked folder: export GTDBTK_DATA_PATH=$(dblock path gtdbtk)/releaseNNN",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[[releases]]
|
|
14
|
+
version = "r232"
|
|
15
|
+
date = "2026-04-13"
|
|
16
|
+
filename = "gtdbtk_r232_data.tar.gz"
|
|
17
|
+
format = "tar.gz"
|
|
18
|
+
size = 60806405195
|
|
19
|
+
tool_versions = ">=2.7.0"
|
|
20
|
+
layout = ["release232/", "release232/skani/database/sketches.db"]
|
|
21
|
+
urls = [
|
|
22
|
+
"https://data.gtdb.ecogenomic.org/releases/release232/232.0/auxillary_files/gtdbtk_package/full_package/gtdbtk_r232_data.tar.gz",
|
|
23
|
+
"https://data.ace.uq.edu.au/public/gtdb/data/releases/release232/232.0/auxillary_files/gtdbtk_package/full_package/gtdbtk_r232_data.tar.gz",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[[releases]]
|
|
27
|
+
version = "r226"
|
|
28
|
+
date = "2025-04-11"
|
|
29
|
+
filename = "gtdbtk_r226_data.tar.gz"
|
|
30
|
+
format = "tar.gz"
|
|
31
|
+
size = 141442235198
|
|
32
|
+
tool_versions = ">=2.4.1,<=2.6.1"
|
|
33
|
+
layout = ["release226/", "release226/masks/"]
|
|
34
|
+
urls = [
|
|
35
|
+
"https://data.gtdb.ecogenomic.org/releases/release226/226.0/auxillary_files/gtdbtk_package/full_package/gtdbtk_r226_data.tar.gz",
|
|
36
|
+
"https://data.ace.uq.edu.au/public/gtdb/data/releases/release226/226.0/auxillary_files/gtdbtk_package/full_package/gtdbtk_r226_data.tar.gz",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[[releases]]
|
|
40
|
+
version = "r220"
|
|
41
|
+
date = "2024-04-16"
|
|
42
|
+
filename = "gtdbtk_r220_data.tar.gz"
|
|
43
|
+
format = "tar.gz"
|
|
44
|
+
size = 108491169765
|
|
45
|
+
tool_versions = ">=2.4.0,<=2.6.1"
|
|
46
|
+
layout = ["release220/", "release220/skani/"]
|
|
47
|
+
urls = [
|
|
48
|
+
"https://data.gtdb.ecogenomic.org/releases/release220/220.0/auxillary_files/gtdbtk_package/full_package/gtdbtk_r220_data.tar.gz",
|
|
49
|
+
"https://data.ace.uq.edu.au/public/gtdb/data/releases/release220/220.0/auxillary_files/gtdbtk_package/full_package/gtdbtk_r220_data.tar.gz",
|
|
50
|
+
]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
name = "kraken2"
|
|
2
|
+
description = "Prebuilt Kraken 2 / Bracken indexes (Langmead lab index collection)"
|
|
3
|
+
homepage = "https://benlangmead.github.io/aws-indexes/k2"
|
|
4
|
+
tool = "kraken2"
|
|
5
|
+
version_command = ["kraken2", "--version"]
|
|
6
|
+
citation = "Wood DE, Lu J, Langmead B (2019) Improved metagenomic analysis with Kraken 2. Genome Biology 20:257"
|
|
7
|
+
notes = [
|
|
8
|
+
"Archive MD5s come from the collection's published .md5 files (https://genome-idx.s3.amazonaws.com/kraken/<collection>_<date>/<collection>.md5), checked 2026-11-10.",
|
|
9
|
+
"standard-8gb-20240904 has no checksum: its .md5 file names k2_standard08gb_20240904.tar.gz, not the served k2_standard_08gb_20240904.tar.gz, so it is not used. dblock records the SHA-256 at first download.",
|
|
10
|
+
"From 2025-07 upstream names the 8 GB indexes k2_standard_08_GB_<date> instead of k2_standard_08gb_<date>.",
|
|
11
|
+
"No tool-version rule is stated upstream for these indexes.",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[[releases]]
|
|
15
|
+
version = "standard-8gb-20261022"
|
|
16
|
+
date = "2026-10-22"
|
|
17
|
+
filename = "k2_standard_08_GB_20261022.tar.gz"
|
|
18
|
+
format = "tar.gz"
|
|
19
|
+
size = 5936595768
|
|
20
|
+
checksum = "md5:7447d230ea402d0b523c14d217b5c20b"
|
|
21
|
+
layout = ["hash.k2d", "opts.k2d", "taxo.k2d"]
|
|
22
|
+
urls = ["https://genome-idx.s3.amazonaws.com/kraken/k2_standard_08_GB_20261022.tar.gz"]
|
|
23
|
+
note = "Layout taken from the published .md5 file list; archive not downloaded for this check."
|
|
24
|
+
|
|
25
|
+
[[releases]]
|
|
26
|
+
version = "pluspf-20261022"
|
|
27
|
+
date = "2026-10-22"
|
|
28
|
+
filename = "k2_pluspf_20261022.tar.gz"
|
|
29
|
+
format = "tar.gz"
|
|
30
|
+
size = 96358495912
|
|
31
|
+
checksum = "md5:7d28dbee49fe7db4809d6110d7c23689"
|
|
32
|
+
layout = ["hash.k2d", "opts.k2d", "taxo.k2d"]
|
|
33
|
+
urls = ["https://genome-idx.s3.amazonaws.com/kraken/k2_pluspf_20261022.tar.gz"]
|
|
34
|
+
note = "Layout taken from the published .md5 file list; archive not downloaded for this check."
|
|
35
|
+
|
|
36
|
+
[[releases]]
|
|
37
|
+
version = "viral-20261022"
|
|
38
|
+
date = "2026-10-22"
|
|
39
|
+
filename = "k2_viral_20261022.tar.gz"
|
|
40
|
+
format = "tar.gz"
|
|
41
|
+
size = 589941425
|
|
42
|
+
checksum = "md5:2c8c97686c45474d1d82cf940b4d9893"
|
|
43
|
+
layout = ["hash.k2d", "opts.k2d", "taxo.k2d"]
|
|
44
|
+
urls = ["https://genome-idx.s3.amazonaws.com/kraken/k2_viral_20261022.tar.gz"]
|
|
45
|
+
|
|
46
|
+
[[releases]]
|
|
47
|
+
version = "standard-8gb-20240904"
|
|
48
|
+
date = "2024-09-04"
|
|
49
|
+
filename = "k2_standard_08gb_20240904.tar.gz"
|
|
50
|
+
format = "tar.gz"
|
|
51
|
+
size = 5948839817
|
|
52
|
+
layout = ["hash.k2d", "opts.k2d", "taxo.k2d"]
|
|
53
|
+
urls = ["https://genome-idx.s3.amazonaws.com/kraken/k2_standard_08gb_20240904.tar.gz"]
|
|
54
|
+
|
|
55
|
+
[[releases]]
|
|
56
|
+
version = "pluspf-20240904"
|
|
57
|
+
date = "2024-09-04"
|
|
58
|
+
filename = "k2_pluspf_20240904.tar.gz"
|
|
59
|
+
format = "tar.gz"
|
|
60
|
+
size = 71110483952
|
|
61
|
+
checksum = "md5:81012c99f5f1c32db3704dbba1b93428"
|
|
62
|
+
layout = ["hash.k2d", "opts.k2d", "taxo.k2d"]
|
|
63
|
+
urls = ["https://genome-idx.s3.amazonaws.com/kraken/k2_pluspf_20240904.tar.gz"]
|
|
64
|
+
|
|
65
|
+
[[releases]]
|
|
66
|
+
version = "viral-20240904"
|
|
67
|
+
date = "2024-09-04"
|
|
68
|
+
filename = "k2_viral_20240904.tar.gz"
|
|
69
|
+
format = "tar.gz"
|
|
70
|
+
size = 564123047
|
|
71
|
+
checksum = "md5:37fc72e5ef54018641b9900421eaa678"
|
|
72
|
+
layout = ["hash.k2d", "opts.k2d", "taxo.k2d"]
|
|
73
|
+
urls = ["https://genome-idx.s3.amazonaws.com/kraken/k2_viral_20240904.tar.gz"]
|
dblock/recipes.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Load database recipes: one TOML file per database, listing its releases."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import tomllib
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from importlib import resources
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
FORMATS = {"tar.gz", "tar.xz", "tar.bz2", "tar", "zip", "gz", "file"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RecipeError(Exception):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Release:
|
|
20
|
+
version: str
|
|
21
|
+
urls: tuple[str, ...]
|
|
22
|
+
filename: str
|
|
23
|
+
format: str
|
|
24
|
+
checksum: str = "" # upstream checksum as "md5:<hex>" or "sha256:<hex>"; empty if none published
|
|
25
|
+
size: int = 0
|
|
26
|
+
date: str = ""
|
|
27
|
+
tool_versions: str = "" # e.g. ">=2.4.1,<=2.6.1"
|
|
28
|
+
layout: tuple[str, ...] = () # paths (or glob patterns) that must exist after unpacking
|
|
29
|
+
note: str = ""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Recipe:
|
|
34
|
+
name: str
|
|
35
|
+
description: str
|
|
36
|
+
homepage: str
|
|
37
|
+
releases: tuple[Release, ...]
|
|
38
|
+
digest: str # sha256 of the recipe file, recorded in the lockfile
|
|
39
|
+
source: str
|
|
40
|
+
tool: str = ""
|
|
41
|
+
version_command: tuple[str, ...] = ()
|
|
42
|
+
version_regex: str = r"(\d+(?:\.\d+)+)"
|
|
43
|
+
citation: str = ""
|
|
44
|
+
notes: tuple[str, ...] = field(default_factory=tuple)
|
|
45
|
+
|
|
46
|
+
def release(self, version: str | None) -> Release:
|
|
47
|
+
if version is None:
|
|
48
|
+
return self.releases[0]
|
|
49
|
+
for rel in self.releases:
|
|
50
|
+
if rel.version == version:
|
|
51
|
+
return rel
|
|
52
|
+
known = ", ".join(r.version for r in self.releases)
|
|
53
|
+
raise RecipeError(f"{self.name} has no release '{version}'. Known releases: {known}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _parse(text: str, source: str) -> Recipe:
|
|
57
|
+
try:
|
|
58
|
+
data = tomllib.loads(text)
|
|
59
|
+
except tomllib.TOMLDecodeError as exc:
|
|
60
|
+
raise RecipeError(f"{source}: invalid TOML: {exc}") from exc
|
|
61
|
+
for key in ("name", "description", "homepage", "releases"):
|
|
62
|
+
if key not in data:
|
|
63
|
+
raise RecipeError(f"{source}: missing '{key}'")
|
|
64
|
+
releases = []
|
|
65
|
+
for raw in data["releases"]:
|
|
66
|
+
for key in ("version", "urls", "filename", "format"):
|
|
67
|
+
if key not in raw:
|
|
68
|
+
raise RecipeError(f"{source}: release missing '{key}'")
|
|
69
|
+
if raw["format"] not in FORMATS:
|
|
70
|
+
raise RecipeError(f"{source}: unknown format '{raw['format']}' (use one of {sorted(FORMATS)})")
|
|
71
|
+
checksum = raw.get("checksum", "")
|
|
72
|
+
if checksum and checksum.split(":", 1)[0] not in ("md5", "sha256"):
|
|
73
|
+
raise RecipeError(f"{source}: checksum must start with 'md5:' or 'sha256:'")
|
|
74
|
+
releases.append(Release(
|
|
75
|
+
version=str(raw["version"]),
|
|
76
|
+
urls=tuple(raw["urls"]),
|
|
77
|
+
filename=raw["filename"],
|
|
78
|
+
format=raw["format"],
|
|
79
|
+
checksum=checksum,
|
|
80
|
+
size=int(raw.get("size", 0)),
|
|
81
|
+
date=str(raw.get("date", "")),
|
|
82
|
+
tool_versions=raw.get("tool_versions", ""),
|
|
83
|
+
layout=tuple(raw.get("layout", ())),
|
|
84
|
+
note=raw.get("note", ""),
|
|
85
|
+
))
|
|
86
|
+
if not releases:
|
|
87
|
+
raise RecipeError(f"{source}: no releases")
|
|
88
|
+
return Recipe(
|
|
89
|
+
name=data["name"],
|
|
90
|
+
description=data["description"],
|
|
91
|
+
homepage=data["homepage"],
|
|
92
|
+
releases=tuple(releases),
|
|
93
|
+
digest="sha256:" + hashlib.sha256(text.encode("utf-8")).hexdigest(),
|
|
94
|
+
source=source,
|
|
95
|
+
tool=data.get("tool", ""),
|
|
96
|
+
version_command=tuple(data.get("version_command", ())),
|
|
97
|
+
version_regex=data.get("version_regex", r"(\d+(?:\.\d+)+)"),
|
|
98
|
+
citation=data.get("citation", ""),
|
|
99
|
+
notes=tuple(data.get("notes", ())),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def load_recipes(extra_dir: str | Path | None = None) -> dict[str, Recipe]:
|
|
104
|
+
"""Bundled recipes, overridden by recipes in extra_dir with the same name."""
|
|
105
|
+
recipes: dict[str, Recipe] = {}
|
|
106
|
+
bundled = resources.files("dblock") / "recipes"
|
|
107
|
+
for entry in sorted(bundled.iterdir(), key=lambda e: e.name):
|
|
108
|
+
if entry.name.endswith(".toml"):
|
|
109
|
+
recipe = _parse(entry.read_text(encoding="utf-8"), f"bundled:{entry.name}")
|
|
110
|
+
recipes[recipe.name] = recipe
|
|
111
|
+
if extra_dir:
|
|
112
|
+
directory = Path(extra_dir)
|
|
113
|
+
if not directory.is_dir():
|
|
114
|
+
raise RecipeError(f"recipe directory not found: {directory}")
|
|
115
|
+
for path in sorted(directory.glob("*.toml")):
|
|
116
|
+
recipe = _parse(path.read_text(encoding="utf-8"), str(path))
|
|
117
|
+
recipes[recipe.name] = recipe
|
|
118
|
+
return recipes
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def parse_spec(spec: str) -> tuple[str, str | None]:
|
|
122
|
+
"""'gtdbtk@r232' -> ('gtdbtk', 'r232'); 'gtdbtk' -> ('gtdbtk', None)."""
|
|
123
|
+
name, _, version = spec.partition("@")
|
|
124
|
+
if not name:
|
|
125
|
+
raise RecipeError(f"invalid database spec '{spec}' (expected NAME or NAME@RELEASE)")
|
|
126
|
+
return name, (version or None)
|
dblock/store.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""The local store: unpacked databases plus a per-file manifest.
|
|
2
|
+
|
|
3
|
+
Layout: <store>/<name>/<release>/data/... unpacked database
|
|
4
|
+
<store>/<name>/<release>/manifest.json per-file size and sha256
|
|
5
|
+
<store>/.downloads/<filename>[.part] archives (removed after unpacking)
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import fnmatch
|
|
11
|
+
import gzip
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import shutil
|
|
16
|
+
import tarfile
|
|
17
|
+
import zipfile
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from pathlib import Path, PurePosixPath
|
|
20
|
+
|
|
21
|
+
from dblock.download import hash_file
|
|
22
|
+
|
|
23
|
+
MANIFEST_VERSION = 1
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class StoreError(Exception):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def default_store() -> Path:
|
|
31
|
+
env = os.environ.get("DBLOCK_STORE")
|
|
32
|
+
return Path(env) if env else Path.home() / ".dblock" / "store"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def install_dir(store: Path, name: str, release: str) -> Path:
|
|
36
|
+
return store / name / release
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def data_dir(store: Path, name: str, release: str) -> Path:
|
|
40
|
+
return install_dir(store, name, release) / "data"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------- unpacking
|
|
44
|
+
|
|
45
|
+
def unpack(archive: Path, fmt: str, target: Path) -> None:
|
|
46
|
+
"""Unpack into target (which must not exist yet), rejecting unsafe paths."""
|
|
47
|
+
tmp = target.with_name(target.name + ".unpacking")
|
|
48
|
+
if tmp.exists():
|
|
49
|
+
shutil.rmtree(tmp)
|
|
50
|
+
tmp.mkdir(parents=True)
|
|
51
|
+
try:
|
|
52
|
+
if fmt.startswith("tar"):
|
|
53
|
+
_untar(archive, tmp)
|
|
54
|
+
elif fmt == "zip":
|
|
55
|
+
_unzip(archive, tmp)
|
|
56
|
+
elif fmt == "gz":
|
|
57
|
+
name = archive.name[:-3] if archive.name.endswith(".gz") else archive.name + ".out"
|
|
58
|
+
with gzip.open(archive, "rb") as src, (tmp / name).open("wb") as dst:
|
|
59
|
+
shutil.copyfileobj(src, dst, 1024 * 1024)
|
|
60
|
+
elif fmt == "file":
|
|
61
|
+
shutil.copy2(archive, tmp / archive.name)
|
|
62
|
+
else:
|
|
63
|
+
raise StoreError(f"unsupported format '{fmt}'")
|
|
64
|
+
except BaseException:
|
|
65
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
66
|
+
raise
|
|
67
|
+
if target.exists():
|
|
68
|
+
shutil.rmtree(target)
|
|
69
|
+
tmp.rename(target)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _untar(archive: Path, dest: Path) -> None:
|
|
73
|
+
with tarfile.open(archive, "r:*") as tar:
|
|
74
|
+
if hasattr(tarfile, "data_filter"):
|
|
75
|
+
try:
|
|
76
|
+
tar.extractall(dest, filter="data")
|
|
77
|
+
except tarfile.FilterError as exc:
|
|
78
|
+
raise StoreError(f"refusing unsafe archive member: {exc}") from exc
|
|
79
|
+
return
|
|
80
|
+
for member in tar.getmembers(): # Python < 3.12 fallback
|
|
81
|
+
_check_member(member.name)
|
|
82
|
+
if member.issym() or member.islnk():
|
|
83
|
+
_check_member(str(PurePosixPath(member.name).parent / member.linkname))
|
|
84
|
+
if member.isdev():
|
|
85
|
+
raise StoreError(f"refusing device file in archive: {member.name}")
|
|
86
|
+
tar.extractall(dest)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _unzip(archive: Path, dest: Path) -> None:
|
|
90
|
+
with zipfile.ZipFile(archive) as zf:
|
|
91
|
+
for name in zf.namelist():
|
|
92
|
+
_check_member(name)
|
|
93
|
+
zf.extractall(dest)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _check_member(name: str) -> None:
|
|
97
|
+
path = PurePosixPath(name)
|
|
98
|
+
if path.is_absolute() or ".." in path.parts:
|
|
99
|
+
raise StoreError(f"refusing unsafe path in archive: {name}")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------------- manifests
|
|
103
|
+
|
|
104
|
+
@dataclass
|
|
105
|
+
class Manifest:
|
|
106
|
+
name: str
|
|
107
|
+
release: str
|
|
108
|
+
archive_sha256: str
|
|
109
|
+
files: dict[str, tuple[int, str]] = field(default_factory=dict) # relpath -> (size, sha256)
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def digest(self) -> str:
|
|
113
|
+
return tree_digest(self.files)
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def total_bytes(self) -> int:
|
|
117
|
+
return sum(size for size, _ in self.files.values())
|
|
118
|
+
|
|
119
|
+
def save(self, path: Path) -> None:
|
|
120
|
+
payload = {
|
|
121
|
+
"manifest_version": MANIFEST_VERSION,
|
|
122
|
+
"name": self.name,
|
|
123
|
+
"release": self.release,
|
|
124
|
+
"archive_sha256": self.archive_sha256,
|
|
125
|
+
"tree_digest": self.digest,
|
|
126
|
+
"files": {k: {"size": s, "sha256": h} for k, (s, h) in sorted(self.files.items())},
|
|
127
|
+
}
|
|
128
|
+
path.write_text(json.dumps(payload, indent=1) + "\n", encoding="utf-8")
|
|
129
|
+
|
|
130
|
+
@classmethod
|
|
131
|
+
def load(cls, path: Path) -> "Manifest":
|
|
132
|
+
if not path.exists():
|
|
133
|
+
raise StoreError(f"no manifest at {path}; run `dblock fetch`")
|
|
134
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
135
|
+
files = {k: (v["size"], v["sha256"]) for k, v in raw["files"].items()}
|
|
136
|
+
return cls(raw["name"], raw["release"], raw["archive_sha256"], files)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def tree_digest(files: dict[str, tuple[int, str]]) -> str:
|
|
140
|
+
h = hashlib.sha256()
|
|
141
|
+
for rel in sorted(files):
|
|
142
|
+
size, digest = files[rel]
|
|
143
|
+
h.update(f"{rel}\t{size}\t{digest}\n".encode("utf-8"))
|
|
144
|
+
return "sha256:" + h.hexdigest()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def walk(root: Path) -> dict[str, int]:
|
|
148
|
+
"""Regular files under root as posix relpath -> size."""
|
|
149
|
+
out = {}
|
|
150
|
+
for dirpath, _, filenames in os.walk(root):
|
|
151
|
+
for fn in filenames:
|
|
152
|
+
p = Path(dirpath) / fn
|
|
153
|
+
out[p.relative_to(root).as_posix()] = p.stat().st_size
|
|
154
|
+
return out
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def build_manifest(name: str, release: str, archive_sha256: str, root: Path, quiet: bool = False) -> Manifest:
|
|
158
|
+
files = {}
|
|
159
|
+
for rel, size in sorted(walk(root).items()):
|
|
160
|
+
label = "" if quiet or size < 256 * 1024 * 1024 else f" hashing {rel}"
|
|
161
|
+
files[rel] = (size, hash_file(root / rel, progress=label)["sha256"])
|
|
162
|
+
return Manifest(name, release, archive_sha256, files)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ---------------------------------------------------------------- checks
|
|
166
|
+
|
|
167
|
+
@dataclass
|
|
168
|
+
class VerifyResult:
|
|
169
|
+
missing: list[str] = field(default_factory=list)
|
|
170
|
+
extra: list[str] = field(default_factory=list)
|
|
171
|
+
size_changed: list[str] = field(default_factory=list)
|
|
172
|
+
content_changed: list[str] = field(default_factory=list)
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def ok(self) -> bool:
|
|
176
|
+
return not (self.missing or self.extra or self.size_changed or self.content_changed)
|
|
177
|
+
|
|
178
|
+
def summary(self, limit: int = 5) -> list[str]:
|
|
179
|
+
lines = []
|
|
180
|
+
for label, items in (("missing", self.missing), ("unexpected", self.extra),
|
|
181
|
+
("size changed", self.size_changed), ("content changed", self.content_changed)):
|
|
182
|
+
if items:
|
|
183
|
+
shown = ", ".join(items[:limit]) + (f" (+{len(items) - limit} more)" if len(items) > limit else "")
|
|
184
|
+
lines.append(f"{len(items)} {label}: {shown}")
|
|
185
|
+
return lines
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def verify(manifest: Manifest, root: Path, full: bool = False, quiet: bool = False) -> VerifyResult:
|
|
189
|
+
result = VerifyResult()
|
|
190
|
+
if not root.is_dir():
|
|
191
|
+
result.missing = sorted(manifest.files)
|
|
192
|
+
return result
|
|
193
|
+
present = walk(root)
|
|
194
|
+
result.missing = sorted(set(manifest.files) - set(present))
|
|
195
|
+
result.extra = sorted(set(present) - set(manifest.files))
|
|
196
|
+
for rel in sorted(set(manifest.files) & set(present)):
|
|
197
|
+
size, digest = manifest.files[rel]
|
|
198
|
+
if present[rel] != size:
|
|
199
|
+
result.size_changed.append(rel)
|
|
200
|
+
elif full:
|
|
201
|
+
label = "" if quiet or size < 256 * 1024 * 1024 else f" hashing {rel}"
|
|
202
|
+
if hash_file(root / rel, progress=label)["sha256"] != digest:
|
|
203
|
+
result.content_changed.append(rel)
|
|
204
|
+
return result
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def check_layout(root: Path, patterns: tuple[str, ...]) -> list[str]:
|
|
208
|
+
"""Return the layout patterns that match nothing under root."""
|
|
209
|
+
if not patterns:
|
|
210
|
+
return []
|
|
211
|
+
paths = list(walk(root))
|
|
212
|
+
dirs = {str(PurePosixPath(p).parent) for p in paths}
|
|
213
|
+
dirs |= {str(parent) for p in paths for parent in PurePosixPath(p).parents}
|
|
214
|
+
candidates = paths + [d + "/" for d in dirs if d != "."]
|
|
215
|
+
return [pat for pat in patterns if not any(fnmatch.fnmatchcase(c, pat) for c in candidates)]
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@dataclass
|
|
219
|
+
class TreeDiff:
|
|
220
|
+
added: list[str]
|
|
221
|
+
removed: list[str]
|
|
222
|
+
changed: list[str]
|
|
223
|
+
unchanged: int
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def diff(a: Manifest, b: Manifest) -> TreeDiff:
|
|
227
|
+
added = sorted(set(b.files) - set(a.files))
|
|
228
|
+
removed = sorted(set(a.files) - set(b.files))
|
|
229
|
+
common = set(a.files) & set(b.files)
|
|
230
|
+
changed = sorted(p for p in common if a.files[p] != b.files[p])
|
|
231
|
+
return TreeDiff(added, removed, changed, len(common) - len(changed))
|