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