just-dna-format 0.1.0__tar.gz
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.
- just_dna_format-0.1.0/PKG-INFO +50 -0
- just_dna_format-0.1.0/README.md +42 -0
- just_dna_format-0.1.0/pyproject.toml +18 -0
- just_dna_format-0.1.0/src/just_dna_format/__init__.py +15 -0
- just_dna_format-0.1.0/src/just_dna_format/identity.py +100 -0
- just_dna_format-0.1.0/src/just_dna_format/integrity.py +151 -0
- just_dna_format-0.1.0/src/just_dna_format/manifest.py +173 -0
- just_dna_format-0.1.0/src/just_dna_format/spec.py +209 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: just-dna-format
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Declarative schema + integrity contract for just-dna annotation modules (DSL spec, manifest, digests)
|
|
5
|
+
Requires-Dist: pydantic>=2.12.5
|
|
6
|
+
Requires-Python: >=3.13
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
|
|
9
|
+
# just-dna-module
|
|
10
|
+
|
|
11
|
+
The declarative **manifest contract** and **integrity primitives** for just-dna annotation
|
|
12
|
+
modules. Dependency-light (Pydantic + stdlib only) so it can be shared as the single source of
|
|
13
|
+
truth by both:
|
|
14
|
+
|
|
15
|
+
- **`just-dna-pipelines`** — *emits* `manifest.json` when it compiles a module, and
|
|
16
|
+
- **`just-dna-marketplace`** — *indexes, serves, and verifies* those manifests.
|
|
17
|
+
|
|
18
|
+
Keeping the contract in one small package prevents the two sides from drifting.
|
|
19
|
+
|
|
20
|
+
## What's here
|
|
21
|
+
|
|
22
|
+
| Module | Contents |
|
|
23
|
+
|---|---|
|
|
24
|
+
| `just_dna_format.manifest` | `ModuleManifest` + sub-models (`Identity`, `Display`, `Stats`, `Compilation`, `FileEntry`, `Artifact`); `read_manifest` / `write_manifest`. Mirrors the marketplace SPEC §4. |
|
|
25
|
+
| `just_dna_format.integrity` | `sha256_file`, `artifact_digest` (canonical Merkle root), `build_artifact`, `verify_manifest` (verify-then-install), `IntegrityError`. SPEC §5. |
|
|
26
|
+
| `just_dna_format.identity` | Name/namespace rules, `canonical_id`, SemVer `Version` + `parse_version`, `version_from_legacy` (`vN → N.0.0`), `latest`. SPEC §6. |
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from just_dna_format.manifest import ModuleManifest, read_manifest
|
|
32
|
+
from just_dna_format.integrity import build_artifact, verify_manifest
|
|
33
|
+
|
|
34
|
+
# Compiler side: hash outputs and record the digest.
|
|
35
|
+
artifact = build_artifact(output_dir, ["weights.parquet", "annotations.parquet", "studies.parquet"])
|
|
36
|
+
|
|
37
|
+
# Downloader side: verify before installing (raises IntegrityError on any mismatch).
|
|
38
|
+
verify_manifest(module_dir, read_manifest(module_dir / "manifest.json"))
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
All hashes are SHA-256, lowercase hex, prefixed `sha256:`. The `artifact.digest` is a Merkle-style
|
|
42
|
+
root over the canonical file listing — verifying it verifies the whole set and is the version's
|
|
43
|
+
immutable content identity.
|
|
44
|
+
|
|
45
|
+
## Develop
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
uv sync
|
|
49
|
+
uv run pytest -vvv
|
|
50
|
+
```
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# just-dna-module
|
|
2
|
+
|
|
3
|
+
The declarative **manifest contract** and **integrity primitives** for just-dna annotation
|
|
4
|
+
modules. Dependency-light (Pydantic + stdlib only) so it can be shared as the single source of
|
|
5
|
+
truth by both:
|
|
6
|
+
|
|
7
|
+
- **`just-dna-pipelines`** — *emits* `manifest.json` when it compiles a module, and
|
|
8
|
+
- **`just-dna-marketplace`** — *indexes, serves, and verifies* those manifests.
|
|
9
|
+
|
|
10
|
+
Keeping the contract in one small package prevents the two sides from drifting.
|
|
11
|
+
|
|
12
|
+
## What's here
|
|
13
|
+
|
|
14
|
+
| Module | Contents |
|
|
15
|
+
|---|---|
|
|
16
|
+
| `just_dna_format.manifest` | `ModuleManifest` + sub-models (`Identity`, `Display`, `Stats`, `Compilation`, `FileEntry`, `Artifact`); `read_manifest` / `write_manifest`. Mirrors the marketplace SPEC §4. |
|
|
17
|
+
| `just_dna_format.integrity` | `sha256_file`, `artifact_digest` (canonical Merkle root), `build_artifact`, `verify_manifest` (verify-then-install), `IntegrityError`. SPEC §5. |
|
|
18
|
+
| `just_dna_format.identity` | Name/namespace rules, `canonical_id`, SemVer `Version` + `parse_version`, `version_from_legacy` (`vN → N.0.0`), `latest`. SPEC §6. |
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from just_dna_format.manifest import ModuleManifest, read_manifest
|
|
24
|
+
from just_dna_format.integrity import build_artifact, verify_manifest
|
|
25
|
+
|
|
26
|
+
# Compiler side: hash outputs and record the digest.
|
|
27
|
+
artifact = build_artifact(output_dir, ["weights.parquet", "annotations.parquet", "studies.parquet"])
|
|
28
|
+
|
|
29
|
+
# Downloader side: verify before installing (raises IntegrityError on any mismatch).
|
|
30
|
+
verify_manifest(module_dir, read_manifest(module_dir / "manifest.json"))
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
All hashes are SHA-256, lowercase hex, prefixed `sha256:`. The `artifact.digest` is a Merkle-style
|
|
34
|
+
root over the canonical file listing — verifying it verifies the whole set and is the version's
|
|
35
|
+
immutable content identity.
|
|
36
|
+
|
|
37
|
+
## Develop
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
uv sync
|
|
41
|
+
uv run pytest -vvv
|
|
42
|
+
```
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "just-dna-format"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Declarative schema + integrity contract for just-dna annotation modules (DSL spec, manifest, digests)"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.13"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"pydantic>=2.12.5",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
[build-system]
|
|
12
|
+
requires = ["uv-build"]
|
|
13
|
+
build-backend = "uv_build"
|
|
14
|
+
|
|
15
|
+
[dependency-groups]
|
|
16
|
+
dev = [
|
|
17
|
+
"pytest>=9.0.3",
|
|
18
|
+
]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""
|
|
2
|
+
just-dna-format — the declarative schema + integrity contract for just-dna annotation modules.
|
|
3
|
+
|
|
4
|
+
Covers both halves of the module format: the authored input DSL (`spec`) and the compiled output
|
|
5
|
+
`manifest` (+ integrity digests and identity/versioning rules). Dependency-light (Pydantic +
|
|
6
|
+
stdlib) so it is shared by `just-dna-compiler` (which emits manifests), `just-dna-pipelines`, and
|
|
7
|
+
`just-dna-marketplace` (which indexes and serves them) without pulling heavy transitive deps.
|
|
8
|
+
|
|
9
|
+
Import from the submodules directly, e.g.::
|
|
10
|
+
|
|
11
|
+
from just_dna_format.spec import ModuleSpecConfig, VariantRow, StudyRow
|
|
12
|
+
from just_dna_format.manifest import ModuleManifest
|
|
13
|
+
from just_dna_format.integrity import sha256_file, artifact_digest, verify_manifest
|
|
14
|
+
from just_dna_format.identity import parse_version, canonical_id
|
|
15
|
+
"""
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Identity and versioning (SPEC §6).
|
|
3
|
+
|
|
4
|
+
Identity is `namespace/name`. `name` keeps the DSL rule `^[a-z][a-z0-9_]*$`; `namespace` is an
|
|
5
|
+
owned account/org slug (lowercase alphanumeric with hyphens, e.g. `just-dna-seq`). Versions are
|
|
6
|
+
SemVer `MAJOR.MINOR.PATCH` for public ordering; the legacy `vN` directory convention maps
|
|
7
|
+
`v1 -> 1.0.0`, `v2 -> 2.0.0`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from functools import total_ordering
|
|
13
|
+
|
|
14
|
+
NAME_PATTERN: re.Pattern[str] = re.compile(r"^[a-z][a-z0-9_]*$")
|
|
15
|
+
NAMESPACE_PATTERN: re.Pattern[str] = re.compile(r"^[a-z0-9][a-z0-9-]*$")
|
|
16
|
+
_VERSION_PATTERN: re.Pattern[str] = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
|
|
17
|
+
_LEGACY_PATTERN: re.Pattern[str] = re.compile(r"^v?(\d+)$")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def is_valid_name(name: str) -> bool:
|
|
21
|
+
"""Whether `name` matches the module name rule `^[a-z][a-z0-9_]*$`."""
|
|
22
|
+
return bool(NAME_PATTERN.match(name))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def validate_name(name: str) -> str:
|
|
26
|
+
"""Return `name` if valid, else raise `ValueError`."""
|
|
27
|
+
if not is_valid_name(name):
|
|
28
|
+
raise ValueError(
|
|
29
|
+
f"module name must be lowercase alphanumeric with underscores, got: {name!r}"
|
|
30
|
+
)
|
|
31
|
+
return name
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def is_valid_namespace(namespace: str) -> bool:
|
|
35
|
+
"""Whether `namespace` is a valid account/org slug (lowercase alnum + hyphens)."""
|
|
36
|
+
return bool(NAMESPACE_PATTERN.match(namespace))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def validate_namespace(namespace: str) -> str:
|
|
40
|
+
"""Return `namespace` if valid, else raise `ValueError`."""
|
|
41
|
+
if not is_valid_namespace(namespace):
|
|
42
|
+
raise ValueError(
|
|
43
|
+
f"namespace must be lowercase alphanumeric with hyphens, got: {namespace!r}"
|
|
44
|
+
)
|
|
45
|
+
return namespace
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def canonical_id(namespace: str, name: str, version: str) -> str:
|
|
49
|
+
"""Build the canonical id `namespace/name@version`."""
|
|
50
|
+
return f"{namespace}/{name}@{version}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@total_ordering
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class Version:
|
|
56
|
+
"""A parsed SemVer `MAJOR.MINOR.PATCH`, comparable and stringifiable."""
|
|
57
|
+
|
|
58
|
+
major: int
|
|
59
|
+
minor: int
|
|
60
|
+
patch: int
|
|
61
|
+
|
|
62
|
+
def __str__(self) -> str:
|
|
63
|
+
return f"{self.major}.{self.minor}.{self.patch}"
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def as_tuple(self) -> tuple[int, int, int]:
|
|
67
|
+
return (self.major, self.minor, self.patch)
|
|
68
|
+
|
|
69
|
+
def __lt__(self, other: "Version") -> bool:
|
|
70
|
+
if not isinstance(other, Version):
|
|
71
|
+
return NotImplemented
|
|
72
|
+
return self.as_tuple < other.as_tuple
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def parse_version(version: str) -> Version:
|
|
76
|
+
"""Parse a strict `MAJOR.MINOR.PATCH` string into a `Version`, else raise `ValueError`."""
|
|
77
|
+
match = _VERSION_PATTERN.match(version)
|
|
78
|
+
if match is None:
|
|
79
|
+
raise ValueError(f"version must be MAJOR.MINOR.PATCH, got: {version!r}")
|
|
80
|
+
return Version(int(match.group(1)), int(match.group(2)), int(match.group(3)))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def is_valid_version(version: str) -> bool:
|
|
84
|
+
"""Whether `version` is a strict `MAJOR.MINOR.PATCH` string."""
|
|
85
|
+
return bool(_VERSION_PATTERN.match(version))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def version_from_legacy(legacy: str) -> str:
|
|
89
|
+
"""Map a legacy integer or `vN` directory name to SemVer (`v1`/`1` -> `1.0.0`)."""
|
|
90
|
+
match = _LEGACY_PATTERN.match(legacy)
|
|
91
|
+
if match is None:
|
|
92
|
+
raise ValueError(f"legacy version must be an integer or vN, got: {legacy!r}")
|
|
93
|
+
return f"{int(match.group(1))}.0.0"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def latest(versions: list[str]) -> str:
|
|
97
|
+
"""Return the highest SemVer string from a non-empty list."""
|
|
98
|
+
if not versions:
|
|
99
|
+
raise ValueError("cannot pick latest from an empty version list")
|
|
100
|
+
return str(max((parse_version(v) for v in versions)))
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Integrity primitives (SPEC §5).
|
|
3
|
+
|
|
4
|
+
All hashes are SHA-256, lowercase hex, prefixed `sha256:`. These functions are the shared
|
|
5
|
+
implementation the compiler uses to *emit* integrity fields and a downloader uses to *verify*
|
|
6
|
+
them — keeping both sides byte-for-byte agreement by construction.
|
|
7
|
+
|
|
8
|
+
Time is never read here: callers pass any timestamps into the manifest. This keeps the module
|
|
9
|
+
pure and deterministic.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from just_dna_format.manifest import (
|
|
17
|
+
MARKETPLACE_COMPILED_BY,
|
|
18
|
+
Artifact,
|
|
19
|
+
FileEntry,
|
|
20
|
+
ModuleManifest,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
SHA256_PREFIX: str = "sha256:"
|
|
24
|
+
_CHUNK: int = 1 << 20 # 1 MiB streaming reads
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class IntegrityError(Exception):
|
|
28
|
+
"""Raised when a file hash, artifact digest, or trust check fails verification."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def sha256_bytes(data: bytes) -> str:
|
|
32
|
+
"""SHA-256 of raw bytes, prefixed `sha256:`."""
|
|
33
|
+
return SHA256_PREFIX + hashlib.sha256(data).hexdigest()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def sha256_file(path: Path) -> str:
|
|
37
|
+
"""Streaming SHA-256 of a file's raw bytes, prefixed `sha256:`."""
|
|
38
|
+
digest = hashlib.sha256()
|
|
39
|
+
with Path(path).open("rb") as handle:
|
|
40
|
+
for chunk in iter(lambda: handle.read(_CHUNK), b""):
|
|
41
|
+
digest.update(chunk)
|
|
42
|
+
return SHA256_PREFIX + digest.hexdigest()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def file_entry(directory: Path, name: str) -> FileEntry:
|
|
46
|
+
"""Build a `FileEntry` (name, sha256, size) for `directory/name`."""
|
|
47
|
+
path = Path(directory) / name
|
|
48
|
+
return FileEntry(name=name, sha256=sha256_file(path), size=path.stat().st_size)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def file_entries(directory: Path, names: list[str]) -> list[FileEntry]:
|
|
52
|
+
"""Build `FileEntry` rows for each existing name under `directory` (skips missing)."""
|
|
53
|
+
directory = Path(directory)
|
|
54
|
+
return [file_entry(directory, name) for name in names if (directory / name).is_file()]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def artifact_digest(files: list[FileEntry]) -> str:
|
|
58
|
+
"""
|
|
59
|
+
Merkle-style root over the file set (SPEC §5): build the JSON array
|
|
60
|
+
`[{"name","sha256","size"}, ...]` sorted by name, serialized with sorted keys and no
|
|
61
|
+
whitespace, then hash. Verifying this one digest verifies the whole set, and it is the
|
|
62
|
+
version's immutable content identity — independent of the order files were listed in.
|
|
63
|
+
"""
|
|
64
|
+
listing = sorted(
|
|
65
|
+
({"name": f.name, "sha256": f.sha256, "size": f.size} for f in files),
|
|
66
|
+
key=lambda entry: entry["name"],
|
|
67
|
+
)
|
|
68
|
+
canonical = json.dumps(listing, sort_keys=True, separators=(",", ":"))
|
|
69
|
+
return sha256_bytes(canonical.encode("utf-8"))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def build_artifact(output_dir: Path, filenames: list[str]) -> Artifact:
|
|
73
|
+
"""Hash each output file and compute the artifact digest over the set."""
|
|
74
|
+
files = file_entries(output_dir, filenames)
|
|
75
|
+
return Artifact(digest=artifact_digest(files), files=files)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def verify_manifest(
|
|
79
|
+
module_dir: Path,
|
|
80
|
+
manifest: ModuleManifest,
|
|
81
|
+
*,
|
|
82
|
+
require_marketplace: bool = True,
|
|
83
|
+
check_inputs: bool = False,
|
|
84
|
+
check_logs: bool = False,
|
|
85
|
+
) -> None:
|
|
86
|
+
"""
|
|
87
|
+
Verify a downloaded module against its manifest (SPEC §5 verify-then-install).
|
|
88
|
+
|
|
89
|
+
Steps:
|
|
90
|
+
1. Every `artifact.files[]` present on disk hashes to its declared value.
|
|
91
|
+
2. The recomputed `artifact.digest` matches the manifest.
|
|
92
|
+
3. `compile_success` is true and `compiled_by == "marketplace-server"`
|
|
93
|
+
(when `require_marketplace`).
|
|
94
|
+
4. Optionally (`check_inputs`) every `inputs[]` file on disk matches its declared hash.
|
|
95
|
+
5. Optionally (`check_logs`) every `logs[]` file *present* on disk matches its declared hash;
|
|
96
|
+
absent logs are skipped, since logs are optional and need not be downloaded.
|
|
97
|
+
|
|
98
|
+
Raises `IntegrityError` on the first failure; returns `None` on success.
|
|
99
|
+
"""
|
|
100
|
+
module_dir = Path(module_dir)
|
|
101
|
+
|
|
102
|
+
for entry in manifest.artifact.files:
|
|
103
|
+
path = module_dir / entry.name
|
|
104
|
+
if not path.is_file():
|
|
105
|
+
raise IntegrityError(f"artifact file missing on disk: {entry.name}")
|
|
106
|
+
actual = sha256_file(path)
|
|
107
|
+
if actual != entry.sha256:
|
|
108
|
+
raise IntegrityError(
|
|
109
|
+
f"artifact hash mismatch for {entry.name}: "
|
|
110
|
+
f"declared {entry.sha256}, computed {actual}"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
recomputed = artifact_digest(manifest.artifact.files)
|
|
114
|
+
if recomputed != manifest.artifact.digest:
|
|
115
|
+
raise IntegrityError(
|
|
116
|
+
f"artifact digest mismatch: declared {manifest.artifact.digest}, "
|
|
117
|
+
f"computed {recomputed}"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
if require_marketplace:
|
|
121
|
+
if not manifest.compilation.compile_success:
|
|
122
|
+
raise IntegrityError("compilation.compile_success is not true — untrusted")
|
|
123
|
+
if manifest.compilation.compiled_by != MARKETPLACE_COMPILED_BY:
|
|
124
|
+
raise IntegrityError(
|
|
125
|
+
f"compiled_by is {manifest.compilation.compiled_by!r}, "
|
|
126
|
+
f"expected {MARKETPLACE_COMPILED_BY!r} — untrusted"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
if check_inputs:
|
|
130
|
+
for entry in manifest.inputs:
|
|
131
|
+
path = module_dir / entry.name
|
|
132
|
+
if not path.is_file():
|
|
133
|
+
raise IntegrityError(f"input file missing on disk: {entry.name}")
|
|
134
|
+
actual = sha256_file(path)
|
|
135
|
+
if actual != entry.sha256:
|
|
136
|
+
raise IntegrityError(
|
|
137
|
+
f"input hash mismatch for {entry.name}: "
|
|
138
|
+
f"declared {entry.sha256}, computed {actual}"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
if check_logs:
|
|
142
|
+
for entry in manifest.logs:
|
|
143
|
+
path = module_dir / entry.name
|
|
144
|
+
if not path.is_file():
|
|
145
|
+
continue # logs are optional — an absent one is not a failure
|
|
146
|
+
actual = sha256_file(path)
|
|
147
|
+
if actual != entry.sha256:
|
|
148
|
+
raise IntegrityError(
|
|
149
|
+
f"log hash mismatch for {entry.name}: "
|
|
150
|
+
f"declared {entry.sha256}, computed {actual}"
|
|
151
|
+
)
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The `manifest.json` contract — the single source of truth for a compiled annotation module.
|
|
3
|
+
|
|
4
|
+
Mirrors SPEC §4. Fields known at compile time (display, stats, compilation, inputs, artifact)
|
|
5
|
+
are filled by the compiler; marketplace-level fields (namespace, version, owner, license,
|
|
6
|
+
published_at, canonical_id) are `Optional` and filled by the marketplace on publish.
|
|
7
|
+
|
|
8
|
+
This module is intentionally dependency-light (Pydantic + stdlib only) so both
|
|
9
|
+
`just-dna-pipelines` (which emits the manifest) and `just-dna-marketplace` (which consumes and
|
|
10
|
+
extends it) can share one definition without pulling heavy transitive dependencies.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, Field, field_validator
|
|
18
|
+
|
|
19
|
+
from just_dna_format.identity import (
|
|
20
|
+
is_valid_version,
|
|
21
|
+
validate_name,
|
|
22
|
+
validate_namespace,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
MANIFEST_VERSION: str = "1.0"
|
|
26
|
+
SCHEMA_VERSION: str = "1.0"
|
|
27
|
+
|
|
28
|
+
# The only `compiled_by` value a downloader trusts (SPEC §5).
|
|
29
|
+
MARKETPLACE_COMPILED_BY: str = "marketplace-server"
|
|
30
|
+
|
|
31
|
+
# Mirrors just-dna-pipelines ModuleInfo.color validation (module_compiler/models.py).
|
|
32
|
+
COLOR_PATTERN: re.Pattern[str] = re.compile(r"^#[0-9a-fA-F]{6}$")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Identity(BaseModel):
|
|
36
|
+
"""Module identity. `namespace`/`version`/`canonical_id` are filled by the marketplace.
|
|
37
|
+
|
|
38
|
+
Identity rules are validated here using the shared `just_dna_format.identity` helpers, so
|
|
39
|
+
the contract enforces exactly what just-dna-pipelines enforces on `module_spec.yaml`.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
namespace: Optional[str] = Field(default=None, description="Owning account/org slug")
|
|
43
|
+
name: str = Field(description="Machine name, matches ^[a-z][a-z0-9_]*$")
|
|
44
|
+
version: Optional[str] = Field(default=None, description="SemVer MAJOR.MINOR.PATCH")
|
|
45
|
+
canonical_id: Optional[str] = Field(
|
|
46
|
+
default=None, description="namespace/name@version"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
@field_validator("name")
|
|
50
|
+
@classmethod
|
|
51
|
+
def _check_name(cls, v: str) -> str:
|
|
52
|
+
return validate_name(v)
|
|
53
|
+
|
|
54
|
+
@field_validator("namespace")
|
|
55
|
+
@classmethod
|
|
56
|
+
def _check_namespace(cls, v: Optional[str]) -> Optional[str]:
|
|
57
|
+
return None if v is None else validate_namespace(v)
|
|
58
|
+
|
|
59
|
+
@field_validator("version")
|
|
60
|
+
@classmethod
|
|
61
|
+
def _check_version(cls, v: Optional[str]) -> Optional[str]:
|
|
62
|
+
if v is not None and not is_valid_version(v):
|
|
63
|
+
raise ValueError(f"version must be MAJOR.MINOR.PATCH, got: {v!r}")
|
|
64
|
+
return v
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Display(BaseModel):
|
|
68
|
+
"""Shared display metadata for a module. The authoring DSL's `spec.ModuleInfo` extends this
|
|
69
|
+
(adding `name`), so the fields and their validation are defined here once."""
|
|
70
|
+
|
|
71
|
+
title: str
|
|
72
|
+
description: str
|
|
73
|
+
report_title: str
|
|
74
|
+
icon: str = Field(default="database", description="Fomantic UI icon name")
|
|
75
|
+
color: str = Field(default="#6435c9", description="Hex color for UI theming")
|
|
76
|
+
|
|
77
|
+
@field_validator("color")
|
|
78
|
+
@classmethod
|
|
79
|
+
def _check_color(cls, v: str) -> str:
|
|
80
|
+
if not COLOR_PATTERN.match(v):
|
|
81
|
+
raise ValueError(f"color must be a 6-digit hex code like #21ba45, got: {v!r}")
|
|
82
|
+
return v
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Stats(BaseModel):
|
|
86
|
+
"""Card/detail stats derived from the spec at compile time."""
|
|
87
|
+
|
|
88
|
+
variant_count: int = 0
|
|
89
|
+
weights_rows: int = 0
|
|
90
|
+
study_count: int = 0
|
|
91
|
+
gene_count: int = 0
|
|
92
|
+
genes: list[str] = Field(default_factory=list)
|
|
93
|
+
categories: list[str] = Field(default_factory=list)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Compilation(BaseModel):
|
|
97
|
+
"""Provenance of the compile that produced this artifact (SPEC §5 trust fields)."""
|
|
98
|
+
|
|
99
|
+
compile_success: bool = False
|
|
100
|
+
compiled_by: Optional[str] = Field(
|
|
101
|
+
default=None, description="e.g. 'marketplace-server'; foreign values are untrusted"
|
|
102
|
+
)
|
|
103
|
+
compiler_version: Optional[str] = None
|
|
104
|
+
ensembl_reference: Optional[str] = Field(
|
|
105
|
+
default=None, description="Pinned Ensembl reference, e.g. org/repo@<rev>"
|
|
106
|
+
)
|
|
107
|
+
compiled_at: Optional[str] = Field(default=None, description="ISO-8601 UTC timestamp")
|
|
108
|
+
warnings: list[str] = Field(default_factory=list)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class FileEntry(BaseModel):
|
|
112
|
+
"""One hashed file — used for both `inputs[]` and `artifact.files[]` (SPEC §5)."""
|
|
113
|
+
|
|
114
|
+
name: str
|
|
115
|
+
sha256: str = Field(description="Lowercase hex digest, prefixed 'sha256:'")
|
|
116
|
+
size: int = Field(description="Byte size of the file")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class Artifact(BaseModel):
|
|
120
|
+
"""The compiled output set plus its Merkle-root digest (the content identity)."""
|
|
121
|
+
|
|
122
|
+
digest: str = Field(description="sha256: over the canonical file listing (SPEC §5)")
|
|
123
|
+
files: list[FileEntry] = Field(default_factory=list)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class ModuleManifest(BaseModel):
|
|
127
|
+
"""Full module manifest (SPEC §4). Written next to the parquets as `manifest.json`."""
|
|
128
|
+
|
|
129
|
+
manifest_version: str = MANIFEST_VERSION
|
|
130
|
+
schema_version: str = SCHEMA_VERSION
|
|
131
|
+
|
|
132
|
+
identity: Identity
|
|
133
|
+
display: Display
|
|
134
|
+
|
|
135
|
+
genome_build: str = "GRCh38"
|
|
136
|
+
curator: Optional[str] = None
|
|
137
|
+
method: Optional[str] = None
|
|
138
|
+
license: Optional[str] = None
|
|
139
|
+
|
|
140
|
+
owner: Optional[str] = None
|
|
141
|
+
authors: list[str] = Field(default_factory=list)
|
|
142
|
+
created_at: Optional[str] = None
|
|
143
|
+
published_at: Optional[str] = None
|
|
144
|
+
|
|
145
|
+
stats: Stats = Field(default_factory=Stats)
|
|
146
|
+
compilation: Compilation = Field(default_factory=Compilation)
|
|
147
|
+
inputs: list[FileEntry] = Field(default_factory=list)
|
|
148
|
+
artifact: Artifact
|
|
149
|
+
logs: list[FileEntry] = Field(
|
|
150
|
+
default_factory=list,
|
|
151
|
+
description=(
|
|
152
|
+
"Optional per-version run/provenance log files, hashed like inputs. Each `name` is a "
|
|
153
|
+
"path relative to the module dir, so both a top-level aggregate log (e.g. `run.log`) "
|
|
154
|
+
"and per-role files under a `logs/` folder (e.g. `logs/researcher.log`, "
|
|
155
|
+
"`logs/reviewer.log`) are supported. Absent logs do NOT invalidate a module. Kept out "
|
|
156
|
+
"of `artifact.digest` so identical compiled data stays dedup-equal regardless of logs; "
|
|
157
|
+
"full cross-version provenance is the union of every version's logs."
|
|
158
|
+
),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def read_manifest(path: Path) -> ModuleManifest:
|
|
163
|
+
"""Load and validate a `manifest.json` from disk."""
|
|
164
|
+
return ModuleManifest.model_validate_json(Path(path).read_text(encoding="utf-8"))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def write_manifest(manifest: ModuleManifest, path: Path) -> Path:
|
|
168
|
+
"""Write a manifest to disk as indented JSON. Returns the path written."""
|
|
169
|
+
path = Path(path)
|
|
170
|
+
path.write_text(
|
|
171
|
+
manifest.model_dump_json(indent=2, exclude_none=False) + "\n", encoding="utf-8"
|
|
172
|
+
)
|
|
173
|
+
return path
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The authored module spec DSL (`module_spec.yaml` + `variants.csv` + `studies.csv`).
|
|
3
|
+
|
|
4
|
+
This is the *input* half of the module format; `manifest.py` is the *output* half. Both live in
|
|
5
|
+
this dependency-light package so the compiler is a pure transform between two validated schema
|
|
6
|
+
sets, and any consumer can validate a spec or a manifest without pulling the compiler's polars/
|
|
7
|
+
duckdb weight.
|
|
8
|
+
|
|
9
|
+
Identity/display rules reuse the shared helpers in `identity` and `manifest`, so the DSL and the
|
|
10
|
+
manifest enforce exactly the same constraints.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
17
|
+
|
|
18
|
+
from just_dna_format.identity import validate_name
|
|
19
|
+
from just_dna_format.manifest import SCHEMA_VERSION, Display
|
|
20
|
+
|
|
21
|
+
VALID_STATES: frozenset[str] = frozenset(
|
|
22
|
+
{"risk", "protective", "neutral", "significant", "alt", "ref"}
|
|
23
|
+
)
|
|
24
|
+
VALID_CHROMOSOMES: frozenset[str] = frozenset(
|
|
25
|
+
{str(i) for i in range(1, 23)} | {"X", "Y", "MT"}
|
|
26
|
+
)
|
|
27
|
+
RSID_PATTERN: re.Pattern[str] = re.compile(r"^rs\d+$")
|
|
28
|
+
ALLELE_PATTERN: re.Pattern[str] = re.compile(r"^[ACGT]+$", re.IGNORECASE)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ModuleInfo(Display):
|
|
32
|
+
"""The `module:` block of module_spec.yaml: a machine `name` plus the shared `Display`
|
|
33
|
+
metadata (title/description/report_title/icon/color).
|
|
34
|
+
|
|
35
|
+
Extends the manifest's `Display` rather than re-declaring those fields, so the display schema
|
|
36
|
+
and its validation (e.g. the hex-colour rule) live in exactly one place. `name` lives here on
|
|
37
|
+
the authoring side; the manifest routes it into `Identity` instead.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
name: str = Field(description="Machine name: lowercase, underscores, no spaces")
|
|
41
|
+
|
|
42
|
+
@field_validator("name")
|
|
43
|
+
@classmethod
|
|
44
|
+
def _validate_name(cls, v: str) -> str:
|
|
45
|
+
return validate_name(v)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Defaults(BaseModel):
|
|
49
|
+
"""Default values applied to variant rows when not explicitly set."""
|
|
50
|
+
|
|
51
|
+
curator: str = Field(default="ai-module-creator", description="Default curator identifier")
|
|
52
|
+
method: str = Field(default="literature-review", description="Default annotation method")
|
|
53
|
+
priority: Optional[str] = Field(default=None, description="Default priority level")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ModuleSpecConfig(BaseModel):
|
|
57
|
+
"""Top-level model for module_spec.yaml."""
|
|
58
|
+
|
|
59
|
+
schema_version: str = Field(default=SCHEMA_VERSION, description="DSL schema version")
|
|
60
|
+
module: ModuleInfo = Field(description="Module identity and display metadata")
|
|
61
|
+
defaults: Defaults = Field(default_factory=Defaults, description="Default variant-row values")
|
|
62
|
+
genome_build: str = Field(default="GRCh38", description="Reference genome build for positions")
|
|
63
|
+
|
|
64
|
+
@field_validator("schema_version")
|
|
65
|
+
@classmethod
|
|
66
|
+
def _validate_version(cls, v: str) -> str:
|
|
67
|
+
if v != SCHEMA_VERSION:
|
|
68
|
+
raise ValueError(f"Unsupported schema_version: {v!r}. Expected {SCHEMA_VERSION!r}")
|
|
69
|
+
return v
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class VariantRow(BaseModel):
|
|
73
|
+
"""One row of variants.csv. At least one identifier (rsid or chrom+start) is required."""
|
|
74
|
+
|
|
75
|
+
rsid: Optional[str] = Field(default=None, description="dbSNP identifier, e.g. rs1801133")
|
|
76
|
+
chrom: Optional[str] = Field(default=None, description="Chromosome without 'chr' prefix")
|
|
77
|
+
start: Optional[int] = Field(default=None, description="0-based genomic position (GRCh38)")
|
|
78
|
+
ref: Optional[str] = Field(default=None, description="Reference allele")
|
|
79
|
+
alts: Optional[str] = Field(default=None, description="Alt allele(s), comma-separated")
|
|
80
|
+
genotype: str = Field(description="Slash-separated sorted alleles, e.g. A/G")
|
|
81
|
+
weight: Optional[float] = Field(default=None, description="Score (positive=protective)")
|
|
82
|
+
state: str = Field(description="One of: risk, protective, neutral, significant, alt, ref")
|
|
83
|
+
conclusion: str = Field(description="Human-readable interpretation for this genotype")
|
|
84
|
+
priority: Optional[str] = Field(default=None, description="Priority level override")
|
|
85
|
+
gene: Optional[str] = Field(default=None, description="Gene symbol, e.g. MTHFR")
|
|
86
|
+
phenotype: Optional[str] = Field(default=None, description="Associated trait or phenotype")
|
|
87
|
+
category: Optional[str] = Field(default=None, description="Grouping category within the module")
|
|
88
|
+
clinvar: Optional[bool] = Field(default=None, description="Is this variant in ClinVar?")
|
|
89
|
+
pathogenic: Optional[bool] = Field(default=None, description="ClinVar pathogenic flag")
|
|
90
|
+
benign: Optional[bool] = Field(default=None, description="ClinVar benign flag")
|
|
91
|
+
curator: Optional[str] = Field(default=None, description="Curator override")
|
|
92
|
+
method: Optional[str] = Field(default=None, description="Annotation method override")
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def variant_key(self) -> str:
|
|
96
|
+
"""Stable grouping key: rsid when available, else chrom:start:ref."""
|
|
97
|
+
if self.rsid is not None:
|
|
98
|
+
return self.rsid
|
|
99
|
+
return f"{self.chrom}:{self.start}:{self.ref}"
|
|
100
|
+
|
|
101
|
+
@field_validator("rsid")
|
|
102
|
+
@classmethod
|
|
103
|
+
def _validate_rsid(cls, v: Optional[str]) -> Optional[str]:
|
|
104
|
+
if v is not None and not RSID_PATTERN.match(v):
|
|
105
|
+
raise ValueError(f"rsid must match rs<digits>, got: {v!r}")
|
|
106
|
+
return v
|
|
107
|
+
|
|
108
|
+
@field_validator("state")
|
|
109
|
+
@classmethod
|
|
110
|
+
def _validate_state(cls, v: str) -> str:
|
|
111
|
+
if v not in VALID_STATES:
|
|
112
|
+
raise ValueError(f"state must be one of {sorted(VALID_STATES)}, got: {v!r}")
|
|
113
|
+
return v
|
|
114
|
+
|
|
115
|
+
@field_validator("chrom")
|
|
116
|
+
@classmethod
|
|
117
|
+
def _validate_chrom(cls, v: Optional[str]) -> Optional[str]:
|
|
118
|
+
if v is not None:
|
|
119
|
+
normalized = v.removeprefix("chr")
|
|
120
|
+
if normalized not in VALID_CHROMOSOMES:
|
|
121
|
+
raise ValueError(
|
|
122
|
+
f"chrom must be one of 1-22, X, Y, MT (without 'chr' prefix), got: {v!r}"
|
|
123
|
+
)
|
|
124
|
+
return normalized
|
|
125
|
+
return v
|
|
126
|
+
|
|
127
|
+
@field_validator("genotype")
|
|
128
|
+
@classmethod
|
|
129
|
+
def _validate_genotype(cls, v: str) -> str:
|
|
130
|
+
parts = v.split("/")
|
|
131
|
+
if len(parts) != 2:
|
|
132
|
+
raise ValueError(f"genotype must be two alleles slash-separated (e.g. A/G), got: {v!r}")
|
|
133
|
+
for allele in parts:
|
|
134
|
+
if not ALLELE_PATTERN.match(allele):
|
|
135
|
+
raise ValueError(
|
|
136
|
+
f"genotype alleles must be uppercase nucleotides, got: {allele!r} in {v!r}"
|
|
137
|
+
)
|
|
138
|
+
if parts != sorted(parts):
|
|
139
|
+
raise ValueError(
|
|
140
|
+
f"genotype alleles must be alphabetically sorted: "
|
|
141
|
+
f"expected {'/'.join(sorted(parts))!r}, got: {v!r}"
|
|
142
|
+
)
|
|
143
|
+
return v
|
|
144
|
+
|
|
145
|
+
@model_validator(mode="after")
|
|
146
|
+
def _validate_identification(self) -> "VariantRow":
|
|
147
|
+
has_rsid = self.rsid is not None
|
|
148
|
+
positional = {"chrom": self.chrom, "start": self.start}
|
|
149
|
+
has_pos = any(v is not None for v in positional.values())
|
|
150
|
+
has_ref = any(v is not None for v in {"ref": self.ref, "alts": self.alts}.values())
|
|
151
|
+
|
|
152
|
+
if not has_rsid and not has_pos:
|
|
153
|
+
raise ValueError(
|
|
154
|
+
"At least one identifier is required: provide rsid or position (chrom + start)"
|
|
155
|
+
)
|
|
156
|
+
if has_pos:
|
|
157
|
+
missing = [k for k, v in positional.items() if v is None]
|
|
158
|
+
if missing:
|
|
159
|
+
raise ValueError(
|
|
160
|
+
f"If any positional columns are provided, chrom and start are required. "
|
|
161
|
+
f"Missing: {missing}"
|
|
162
|
+
)
|
|
163
|
+
if has_ref and not has_pos:
|
|
164
|
+
raise ValueError("ref/alts require chrom and start to also be provided")
|
|
165
|
+
return self
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class StudyRow(BaseModel):
|
|
169
|
+
"""One row of studies.csv: an (rsid, pmid) evidence link. Grounding evidence is mandatory."""
|
|
170
|
+
|
|
171
|
+
rsid: Optional[str] = Field(default=None, description="dbSNP identifier or variant key")
|
|
172
|
+
chrom: Optional[str] = Field(default=None, description="Chromosome (for position-only variants)")
|
|
173
|
+
start: Optional[int] = Field(default=None, description="0-based position (position-only variants)")
|
|
174
|
+
ref: Optional[str] = Field(default=None, description="Reference allele (position-only variants)")
|
|
175
|
+
pmid: str = Field(description="PubMed ID or reference — free-form, must be non-empty")
|
|
176
|
+
population: Optional[str] = Field(default=None, description="Study population")
|
|
177
|
+
p_value: Optional[str] = Field(default=None, description="Statistical significance")
|
|
178
|
+
conclusion: Optional[str] = Field(default=None, description="Study-specific conclusion")
|
|
179
|
+
study_design: Optional[str] = Field(default=None, description="e.g. meta-analysis, GWAS")
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def variant_key(self) -> str:
|
|
183
|
+
"""Stable key matching VariantRow.variant_key."""
|
|
184
|
+
if self.rsid is not None:
|
|
185
|
+
return self.rsid
|
|
186
|
+
return f"{self.chrom}:{self.start}:{self.ref}"
|
|
187
|
+
|
|
188
|
+
@field_validator("rsid")
|
|
189
|
+
@classmethod
|
|
190
|
+
def _validate_rsid(cls, v: Optional[str]) -> Optional[str]:
|
|
191
|
+
if v is not None and not RSID_PATTERN.match(v):
|
|
192
|
+
raise ValueError(f"rsid must match rs<digits>, got: {v!r}")
|
|
193
|
+
return v
|
|
194
|
+
|
|
195
|
+
@field_validator("pmid")
|
|
196
|
+
@classmethod
|
|
197
|
+
def _validate_pmid(cls, v: str) -> str:
|
|
198
|
+
v = str(v).strip()
|
|
199
|
+
if not v:
|
|
200
|
+
raise ValueError("pmid must not be empty")
|
|
201
|
+
return v
|
|
202
|
+
|
|
203
|
+
@model_validator(mode="after")
|
|
204
|
+
def _validate_study_identification(self) -> "StudyRow":
|
|
205
|
+
if self.rsid is None and self.chrom is None:
|
|
206
|
+
raise ValueError(
|
|
207
|
+
"At least one identifier is required: provide rsid or position (chrom + start)"
|
|
208
|
+
)
|
|
209
|
+
return self
|