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.
@@ -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