Files
2026-08-03 18:42:47 +08:00

83 lines
3.2 KiB
Python

"""Strict parsing of confirmed paired-end WGS sample manifests."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import re
import yaml
_SAMPLE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$")
_FASTQ_SUFFIXES = (".fastq", ".fastq.gz", ".fq", ".fq.gz")
_FORBIDDEN_RECORD_KEYS = {"include", "exclude", "group_role", "read1", "read2"}
class _DuplicateKeySafeLoader(yaml.SafeLoader):
pass
def _construct_mapping(loader: _DuplicateKeySafeLoader, node: yaml.MappingNode, deep: bool = False) -> dict[object, object]:
mapping: dict[object, object] = {}
for key_node, value_node in node.value:
key = loader.construct_object(key_node, deep=deep)
if key in mapping:
raise ValueError(f"duplicate key: {key}")
mapping[key] = loader.construct_object(value_node, deep=deep)
return mapping
_DuplicateKeySafeLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _construct_mapping)
@dataclass(frozen=True)
class WgsSample:
sample_id: str
r1: Path
r2: Path
def _read_path(value: object, *, field: str, yaml_parent: Path) -> Path:
if not isinstance(value, str) or not value.strip():
raise ValueError(f"{field} must be a non-empty path")
path = Path(value)
if not path.is_absolute():
path = yaml_parent / path
if not path.name.endswith(_FASTQ_SUFFIXES):
raise ValueError(f"{field} has an unsupported FASTQ suffix")
if not path.is_file():
raise ValueError(f"{field} must be an existing file")
if path.stat().st_size == 0:
raise ValueError(f"{field} must be non-empty")
return path
def load_wgs_samples(path: str | Path) -> tuple[WgsSample, ...]:
"""Load explicitly listed, valid paired-end samples from a YAML document."""
config_path = Path(path)
with config_path.open(encoding="utf-8") as handle:
data = yaml.load(handle, Loader=_DuplicateKeySafeLoader)
if not isinstance(data, dict) or not isinstance(data.get("samples"), dict) or not data["samples"]:
raise ValueError("wgs_samples.yaml must contain a non-empty samples mapping")
if set(data) != {"samples"}:
raise ValueError("wgs_samples.yaml accepts only the samples mapping")
samples: list[WgsSample] = []
for raw_id, record in data["samples"].items():
if not isinstance(raw_id, str) or not _SAMPLE_ID.fullmatch(raw_id):
raise ValueError("sample ID is unsafe")
if not isinstance(record, dict):
raise ValueError(f"sample {raw_id} must be a dictionary")
forbidden = _FORBIDDEN_RECORD_KEYS.intersection(record)
if forbidden:
raise ValueError(f"sample {raw_id} contains prohibited field {sorted(forbidden)[0]}")
if set(record) != {"r1", "r2"}:
raise ValueError(f"sample {raw_id} must contain only r1 and r2")
r1 = _read_path(record["r1"], field=f"sample {raw_id} r1", yaml_parent=config_path.parent)
r2 = _read_path(record["r2"], field=f"sample {raw_id} r2", yaml_parent=config_path.parent)
if r1 == r2:
raise ValueError(f"sample {raw_id} r1 and r2 must be different files")
samples.append(WgsSample(raw_id, r1, r2))
return tuple(sorted(samples, key=lambda sample: sample.sample_id))