From 891f6745da2f61e121c341770f239dfb91095080 Mon Sep 17 00:00:00 2001 From: pieetie <129987442+pieetie@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:29:04 +0200 Subject: [PATCH 01/10] fix: sort VCF records for tabix (#6) --- src/svforge/cli.py | 2 +- src/svforge/io/vcf_writer.py | 14 +++++- src/svforge/writers/base.py | 83 +++++++++++++++++++++++++++++++++--- src/svforge/writers/delly.py | 9 ++-- src/svforge/writers/manta.py | 20 +++++---- 5 files changed, 106 insertions(+), 22 deletions(-) diff --git a/src/svforge/cli.py b/src/svforge/cli.py index 62d15e0..6c01d4c 100644 --- a/src/svforge/cli.py +++ b/src/svforge/cli.py @@ -363,7 +363,7 @@ def _write_sample_vcf( provenance_tags=provenance_tags, template_override=header_template_override, ) - records = writer.format_records(svs, sample_name) + records = writer.format_records_sorted(svs, sample_name, header) write_vcf(out_path, header, records) diff --git a/src/svforge/io/vcf_writer.py b/src/svforge/io/vcf_writer.py index 6ea07a5..4665ea3 100644 --- a/src/svforge/io/vcf_writer.py +++ b/src/svforge/io/vcf_writer.py @@ -18,6 +18,8 @@ import pysam +from svforge.writers.base import VCFRecord + def detect_mode(path: Path) -> str: """ @@ -39,7 +41,7 @@ def detect_mode(path: Path) -> str: def write_vcf( out_path: str | Path, header_lines: Iterable[str], - record_lines: Iterable[str], + record_lines: Iterable[VCFRecord], ) -> Path: """ Materialise a text VCF to a file in the format implied by ``out_path`` @@ -54,7 +56,7 @@ def write_vcf( mode = detect_mode(out) header_text = _lines_to_text(header_lines) - record_text = _lines_to_text(record_lines) + record_text = _records_to_text(record_lines) if mode == "w": out.write_text(header_text + record_text, encoding="utf-8") @@ -87,3 +89,11 @@ def _lines_to_text(lines: Iterable[str]) -> str: for line in lines: parts.append(line if line.endswith("\n") else line + "\n") return "".join(parts) + + +def _records_to_text(records: Iterable[VCFRecord]) -> str: + parts: list[str] = [] + for rec in records: + line = rec.line + parts.append(line if line.endswith("\n") else line + "\n") + return "".join(parts) diff --git a/src/svforge/writers/base.py b/src/svforge/writers/base.py index 9871155..67ad8c7 100644 --- a/src/svforge/writers/base.py +++ b/src/svforge/writers/base.py @@ -16,8 +16,11 @@ from __future__ import annotations import datetime as _dt +import logging +import re from abc import ABC, abstractmethod from collections.abc import Iterable, Sequence +from dataclasses import dataclass from functools import cache from importlib import resources from pathlib import Path @@ -28,6 +31,34 @@ SYNTHETIC_REFERENCE = "svforge_synthetic_reference.fa" +_LOG = logging.getLogger(__name__) + +_CONTIG_RE = re.compile(r"^##contig=]+)") + + +@dataclass(frozen=True, slots=True) +class VCFRecord: + """ + A rendered VCF data line, tagged with sort key for ordering before write. + + chrom and pos are duplicated from the line for sort efficiency; line itself + is the fully-formatted tab-separated VCF record without trailing newline. + """ + + chrom: str + pos: int + line: str + + +def extract_contig_order(header_lines: Sequence[str]) -> list[str]: + """Return contig IDs in the order they appear in the header.""" + contigs: list[str] = [] + for line in header_lines: + m = _CONTIG_RE.match(line) + if m: + contigs.append(m.group(1)) + return contigs + class CallerWriter(ABC): """ @@ -125,25 +156,63 @@ def _load_template( return _load_bundled_template(name) @abstractmethod - def format_record(self, sv: SV, sample_name: str) -> list[str]: + def format_record(self, sv: SV, sample_name: str) -> list[VCFRecord]: """ - Return one or more text VCF records for ``sv`` + Return one or more VCF records for ``sv`` - Manta returns two lines for a BND event (one per mate). DELLY and - simple symbolic records (DEL/DUP/INV/INS) return a single-line list + Manta returns two records for a BND event (one per mate). DELLY and + simple symbolic records (DEL/DUP/INV/INS) return a single-item list """ - def format_records(self, svs: Iterable[SV], sample_name: str) -> list[str]: + def format_records(self, svs: Iterable[SV], sample_name: str) -> list[VCFRecord]: """ - Format a whole SV collection to a list of text VCF records + Format a whole SV collection to a list of VCF records (not sorted) """ - records: list[str] = [] + records: list[VCFRecord] = [] for sv in svs: if sv.svtype not in self.supported_svtypes: raise ValueError(f"Writer {self.name!r} does not support svtype {sv.svtype!r}") records.extend(self.format_record(sv, sample_name)) return records + def format_records_sorted( + self, + svs: Iterable[SV], + sample_name: str, + header_lines: Sequence[str], + ) -> list[VCFRecord]: + """ + Like :meth:`format_records` but ordered by ``(contig_index, pos)``. + + Contig order is taken from ``##contig=`` lines in the rendered header, not + from a hardcoded genome list, so it stays aligned with + user-supplied ``--header-template`` and other template overrides. + """ + raw = self.format_records(svs, sample_name) + return self.sort_records(raw, extract_contig_order(header_lines)) + + def sort_records( + self, + records: Sequence[VCFRecord], + contig_order: Sequence[str], + ) -> list[VCFRecord]: + """ + Sort VCF records by (contig_index, pos), using header contig order. + """ + contig_index = {c: i for i, c in enumerate(contig_order)} + warned: set[str] = set() + + def key(rec: VCFRecord) -> tuple[int, int]: + idx = contig_index.get(rec.chrom) + if idx is None: + if rec.chrom not in warned: + _LOG.warning("Records on undeclared contig %s", rec.chrom) + warned.add(rec.chrom) + idx = 10**9 + return (idx, rec.pos) + + return sorted(records, key=key) + @cache def _load_bundled_template(name: str) -> tuple[str, ...]: diff --git a/src/svforge/writers/delly.py b/src/svforge/writers/delly.py index ea0db82..e3d481b 100644 --- a/src/svforge/writers/delly.py +++ b/src/svforge/writers/delly.py @@ -16,7 +16,7 @@ from svforge import __version__ from svforge.core.models import SV -from svforge.writers.base import CallerWriter +from svforge.writers.base import CallerWriter, VCFRecord class DellyWriter(CallerWriter): @@ -38,7 +38,7 @@ class DellyWriter(CallerWriter): "{NORMAL_SAMPLE}", ) - def format_record(self, sv: SV, sample_name: str) -> list[str]: + def format_record(self, sv: SV, sample_name: str) -> list[VCFRecord]: return [_delly_record(sv)] @@ -114,11 +114,11 @@ def _base_info(sv: SV) -> list[str]: return info -def _delly_record(sv: SV) -> str: +def _delly_record(sv: SV) -> VCFRecord: alt = f"<{sv.svtype}>" info = ";".join(_base_info(sv)) fmt, sample = _sample_column(sv) - return "\t".join( + line = "\t".join( [ sv.chrom, str(sv.pos), @@ -132,6 +132,7 @@ def _delly_record(sv: SV) -> str: sample, ] ) + return VCFRecord(chrom=sv.chrom, pos=sv.pos, line=line) from svforge.writers._registry import register_writer # noqa: E402 diff --git a/src/svforge/writers/manta.py b/src/svforge/writers/manta.py index 2bc7d2f..5798cc1 100644 --- a/src/svforge/writers/manta.py +++ b/src/svforge/writers/manta.py @@ -17,7 +17,7 @@ from svforge import __version__ from svforge.core.models import SV -from svforge.writers.base import CallerWriter +from svforge.writers.base import CallerWriter, VCFRecord class MantaWriter(CallerWriter): @@ -40,7 +40,7 @@ class MantaWriter(CallerWriter): "{NORMAL_SAMPLE}", ) - def format_record(self, sv: SV, sample_name: str) -> list[str]: + def format_record(self, sv: SV, sample_name: str) -> list[VCFRecord]: if sv.svtype == "BND": return _bnd_mate_records(sv) return [_symbolic_record(sv)] @@ -96,11 +96,11 @@ def _base_info(sv: SV) -> list[str]: return info -def _symbolic_record(sv: SV) -> str: +def _symbolic_record(sv: SV) -> VCFRecord: alt = f"<{sv.svtype}>" info = ";".join(_base_info(sv)) fmt, sample = _sample_column(sv) - return "\t".join( + line = "\t".join( [ sv.chrom, str(sv.pos), @@ -114,6 +114,7 @@ def _symbolic_record(sv: SV) -> str: sample, ] ) + return VCFRecord(chrom=sv.chrom, pos=sv.pos, line=line) def _bnd_alt(ref_base: str, mate_chrom: str, mate_pos: int, strands: str) -> str: @@ -146,7 +147,7 @@ def _mate_strands(strands: str) -> str: return f"{s2}{s1}" -def _bnd_mate_records(sv: SV) -> list[str]: +def _bnd_mate_records(sv: SV) -> list[VCFRecord]: if sv.mate_chrom is None or sv.mate_pos is None: raise ValueError(f"BND {sv.id!r} missing mate coordinates") id1 = f"{sv.id}_1" @@ -168,7 +169,7 @@ def _bnd_mate_records(sv: SV) -> list[str]: info2.append(f"SVFORGE_SOURCE={sv.source}") fmt, sample = _sample_column(sv) - rec1 = "\t".join( + line1 = "\t".join( [ sv.chrom, str(sv.pos), @@ -182,7 +183,7 @@ def _bnd_mate_records(sv: SV) -> list[str]: sample, ] ) - rec2 = "\t".join( + line2 = "\t".join( [ sv.mate_chrom, str(sv.mate_pos), @@ -196,7 +197,10 @@ def _bnd_mate_records(sv: SV) -> list[str]: sample, ] ) - return [rec1, rec2] + return [ + VCFRecord(chrom=sv.chrom, pos=sv.pos, line=line1), + VCFRecord(chrom=sv.mate_chrom, pos=sv.mate_pos, line=line2), + ] from svforge.writers._registry import register_writer # noqa: E402 From ea6e8e24e6fe741c52897e0b9295f279f0c700d8 Mon Sep 17 00:00:00 2001 From: pieetie <129987442+pieetie@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:29:50 +0200 Subject: [PATCH 02/10] test: indexability, BND inter chrom and sort invariants (#6) --- tests/fixtures/bnd_only_hg38.yaml | 9 ++ tests/test_delly_writer.py | 8 +- tests/test_indexability.py | 222 ++++++++++++++++++++++++++++++ tests/test_manta_writer.py | 9 +- tests/test_pair_mode.py | 10 +- 5 files changed, 247 insertions(+), 11 deletions(-) create mode 100644 tests/fixtures/bnd_only_hg38.yaml create mode 100644 tests/test_indexability.py diff --git a/tests/fixtures/bnd_only_hg38.yaml b/tests/fixtures/bnd_only_hg38.yaml new file mode 100644 index 0000000..3dc37a2 --- /dev/null +++ b/tests/fixtures/bnd_only_hg38.yaml @@ -0,0 +1,9 @@ +# Bank used by tests that must always emit BNDs (incl. inter-chrom) from the sampler +name: bnd_only_hg38 +genome: hg38 + +templates: + - svtype: BND + svlen: [1, 1] + homlen: [0, 5] + weight: 1.0 diff --git a/tests/test_delly_writer.py b/tests/test_delly_writer.py index a75e81c..7ccc2d0 100644 --- a/tests/test_delly_writer.py +++ b/tests/test_delly_writer.py @@ -44,7 +44,7 @@ def test_delly_one_record_per_event(tmp_path: Path) -> None: writer = get_writer("delly") svs = _svs() header = writer.header_lines("SAMP01") - records = writer.format_records(svs, "SAMP01") + records = writer.format_records_sorted(svs, "SAMP01", header) assert len(records) == len(svs) out = tmp_path / "out.vcf" @@ -67,7 +67,8 @@ def test_delly_bnd_has_chr2_pos2(tmp_path: Path) -> None: strands="+-", ) out = tmp_path / "d.vcf" - write_vcf(out, writer.header_lines("S"), writer.format_records([sv], "S")) + hdr = writer.header_lines("S") + write_vcf(out, hdr, writer.format_records_sorted([sv], "S", hdr)) with pysam.VariantFile(str(out)) as vf: rec = next(iter(vf)) assert rec.info["CHR2"] == "chr7" @@ -78,6 +79,7 @@ def test_delly_roundtrip_bcf(tmp_path: Path) -> None: writer = get_writer("delly") svs = _svs() out = tmp_path / "out.bcf" - write_vcf(out, writer.header_lines("S"), writer.format_records(svs, "S")) + hdr = writer.header_lines("S") + write_vcf(out, hdr, writer.format_records_sorted(svs, "S", hdr)) with pysam.VariantFile(str(out)) as vf: assert sum(1 for _ in vf) == len(svs) diff --git a/tests/test_indexability.py b/tests/test_indexability.py new file mode 100644 index 0000000..7a0dbf8 --- /dev/null +++ b/tests/test_indexability.py @@ -0,0 +1,222 @@ +""" +Tests for VCF indexability and sort order. + +These tests guard against regressions of issue #6 (BND inter-chrom sort). +""" + +from __future__ import annotations + +import re +from collections import defaultdict +from pathlib import Path + +import pysam +import pytest + +from svforge.cli import main as cli_main + +FIXTURES_DIR = Path(__file__).parent / "fixtures" +BND_ONLY_BANK = FIXTURES_DIR / "bnd_only_hg38.yaml" + + +def _run_svforge(args: list[str]) -> None: + rc = cli_main(args) + if rc != 0: + raise RuntimeError(f"svforge exited with {rc}") + + +def _extract_contig_order(vcf_path: Path) -> list[str]: + contigs: list[str] = [] + with pysam.VariantFile(str(vcf_path)) as vf: + for line in str(vf.header).splitlines(): + m = re.match(r"^##contig=]+)", line) + if m: + contigs.append(m.group(1)) + return contigs + + +def _records_in_order(vcf_path: Path) -> bool: + contig_order = _extract_contig_order(vcf_path) + contig_idx = {c: i for i, c in enumerate(contig_order)} + last_key = (-1, -1) + with pysam.VariantFile(str(vcf_path)) as vf: + for rec in vf: + key = (contig_idx.get(rec.chrom, 10**9), rec.pos) + if key < last_key: + return False + last_key = key + return True + + +def _info_first(rec: pysam.VariantRecord, key: str) -> str | int | None: + v = rec.info.get(key) + if v is None: + return None + return v[0] if isinstance(v, (tuple, list)) else v + + +def _has_inter_chrom_bnd(caller: str, vcf_path: Path) -> bool: + """True if at least one BND with primary and mate on different contigs.""" + with pysam.VariantFile(str(vcf_path)) as vf: + if caller == "delly": + for rec in vf: + if _info_first(rec, "SVTYPE") != "BND": + continue + chr2 = _info_first(rec, "CHR2") + if chr2 is not None and rec.chrom != str(chr2): + return True + return False + # manta: two lines per EVENT; inter-chrom iff the two breakends differ in CHROM + by_event: dict[str, set[str]] = defaultdict(set) + for rec in vf: + if _info_first(rec, "SVTYPE") != "BND": + continue + event = _info_first(rec, "EVENT") + if event is None: + continue + by_event[str(event)].add(rec.chrom) + return any(len(chroms) > 1 for chroms in by_event.values()) + + +@pytest.mark.parametrize("caller", ["manta", "delly"]) +def test_tabix_indexable_simple(tmp_path: Path, caller: str) -> None: + """Generated VCFs without BND should be tabix-indexable.""" + out = tmp_path / "test.vcf.gz" + _run_svforge( + [ + "gen", + "--caller", + caller, + "--n", + "20", + "--sample-name", + "TEST", + "--seed", + "42", + "--svtypes", + "DEL,DUP,INV,INS", + "--out", + str(out), + ] + ) + pysam.tabix_index(str(out), preset="vcf", force=True) + assert (tmp_path / "test.vcf.gz.tbi").exists() + + +@pytest.mark.parametrize("caller", ["manta", "delly"]) +def test_tabix_indexable_with_bnd(tmp_path: Path, caller: str) -> None: + """Generated VCFs with BND inter-chrom must remain tabix-indexable (issue #6).""" + out = tmp_path / "test.vcf.gz" + _run_svforge( + [ + "gen", + "--caller", + caller, + "--n", + "30", + "--sample-name", + "TEST", + "--seed", + "42", + "--bank", + str(BND_ONLY_BANK), + "--svtypes", + "BND", + "--out", + str(out), + ] + ) + with pysam.VariantFile(str(out)) as vf: + bnd_count = sum(1 for r in vf if _info_first(r, "SVTYPE") == "BND") + assert bnd_count > 0, ( + "Test setup error: no BND records generated; issue #6 regression is not exercised." + ) + assert _has_inter_chrom_bnd(caller, out), ( + "Test setup error: no inter-chromosomal BND; issue #6 regression is not exercised." + ) + pysam.tabix_index(str(out), preset="vcf", force=True) + assert (tmp_path / "test.vcf.gz.tbi").exists() + + +@pytest.mark.parametrize("caller", ["manta", "delly"]) +def test_records_sorted_by_header_contig_order(tmp_path: Path, caller: str) -> None: + """All records must be sorted by (contig_index, pos), header order.""" + out = tmp_path / "test.vcf" + _run_svforge( + [ + "gen", + "--caller", + caller, + "--n", + "50", + "--sample-name", + "TEST", + "--seed", + "42", + "--gnomad-fraction", + "0.3", + "--out", + str(out), + ] + ) + assert _records_in_order(out), ( + f"Records in {out} are not sorted by (contig_index, pos)" + ) + + +def test_region_query_chr1_after_index(tmp_path: Path) -> None: + """After indexing, pysam fetch for chr1 returns only chr1 records.""" + out = tmp_path / "test.vcf.gz" + _run_svforge( + [ + "gen", + "--caller", + "manta", + "--n", + "100", + "--sample-name", + "TEST", + "--seed", + "42", + "--out", + str(out), + ] + ) + pysam.tabix_index(str(out), preset="vcf", force=True) + with pysam.VariantFile(str(out)) as vf: + chr1_records = list(vf.fetch("chr1")) + assert all(r.chrom == "chr1" for r in chr1_records) + + +def test_sort_preserves_record_content(tmp_path: Path) -> None: + """Re-sorting data lines by header (contig index, pos) is a no-op — order matches sort key.""" + out = tmp_path / "test.vcf" + _run_svforge( + [ + "gen", + "--caller", + "manta", + "--n", + "30", + "--sample-name", + "TEST", + "--seed", + "42", + "--gnomad-fraction", + "0.3", + "--out", + str(out), + ] + ) + with open(out, encoding="utf-8") as fh: + records = [line.rstrip("\n") for line in fh if not line.startswith("#")] + contig_order = _extract_contig_order(out) + contig_idx = {c: i for i, c in enumerate(contig_order)} + + def _line_sort_key(line: str) -> tuple[int, int]: + parts = line.split("\t", 2) + chrom, pos_s = parts[0], parts[1] + return (contig_idx.get(chrom, 10**9), int(pos_s)) + + sorted_records = sorted(records, key=_line_sort_key) + assert records == sorted_records diff --git a/tests/test_manta_writer.py b/tests/test_manta_writer.py index ac065eb..0c653ba 100644 --- a/tests/test_manta_writer.py +++ b/tests/test_manta_writer.py @@ -53,7 +53,7 @@ def test_manta_header_and_records_parse(tmp_path: Path) -> None: writer = get_writer("manta") svs = _svs() header = writer.header_lines("TUMOR01") - records = writer.format_records(svs, "TUMOR01") + records = writer.format_records_sorted(svs, "TUMOR01", header) out = tmp_path / "out.vcf" write_vcf(out, header, records) @@ -83,14 +83,15 @@ def test_manta_bnd_mates_have_mateid() -> None: ] records = writer.format_records(svs, "S") assert len(records) == 2 - assert "MATEID=bnd1_2" in records[0] - assert "MATEID=bnd1_1" in records[1] + lines = [r.line for r in records] + assert any("MATEID=bnd1_2" in ln for ln in lines) + assert any("MATEID=bnd1_1" in ln for ln in lines) def test_manta_writes_vcf_gz_and_bcf(tmp_path: Path) -> None: writer = get_writer("manta") svs = _svs() header = writer.header_lines("S") - records = writer.format_records(svs, "S") + records = writer.format_records_sorted(svs, "S", header) for suffix in (".vcf", ".vcf.gz", ".bcf"): out = tmp_path / f"out{suffix}" diff --git a/tests/test_pair_mode.py b/tests/test_pair_mode.py index 33d9ed5..4da7b54 100644 --- a/tests/test_pair_mode.py +++ b/tests/test_pair_mode.py @@ -31,15 +31,17 @@ def test_sample_pair_writes_two_consistent_vcfs(tmp_path: Path, mini_bank: Bank) tumor_out = tmp_path / "tumor.vcf.gz" normal_out = tmp_path / "normal.vcf.gz" + tumor_hdr = writer.header_lines("TUMOR01") + normal_hdr = writer.header_lines("NORMAL01") write_vcf( tumor_out, - writer.header_lines("TUMOR01"), - writer.format_records(pair.tumor, "TUMOR01"), + tumor_hdr, + writer.format_records_sorted(pair.tumor, "TUMOR01", tumor_hdr), ) write_vcf( normal_out, - writer.header_lines("NORMAL01"), - writer.format_records(pair.normal, "NORMAL01"), + normal_hdr, + writer.format_records_sorted(pair.normal, "NORMAL01", normal_hdr), ) with pysam.VariantFile(str(tumor_out)) as vf: From bbaf90079f11d08140c8f10a081ffbb39b196344 Mon Sep 17 00:00:00 2001 From: pieetie <129987442+pieetie@users.noreply.github.com> Date: Mon, 27 Apr 2026 22:27:12 +0200 Subject: [PATCH 03/10] fix: zenodo badge (#8) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 08a79e4..fe1b00f 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ [![PyPI version](https://img.shields.io/pypi/v/svforge.svg)](https://pypi.org/project/svforge/) [![License](https://img.shields.io/pypi/l/svforge)](https://pypi.org/project/svforge/) -[![DOI](https://zenodo.org/badge/1218168425.svg)](https://doi.org/10.5281/zenodo.19762333) +[![DOI](https://img.shields.io/badge/DOI-10.5281%2Fzenodo.19762333-blue)](https://doi.org/10.5281/zenodo.19762333) --- From 9ccedb1a30e9d9a34f81963ec7ef458c904a140b Mon Sep 17 00:00:00 2001 From: pieetie <129987442+pieetie@users.noreply.github.com> Date: Mon, 27 Apr 2026 22:42:59 +0200 Subject: [PATCH 04/10] fix: MIT badge link (#9) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index fe1b00f..edb0a3e 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ ### Generate synthetic SV VCFs to stress-test your pipelines with confidence [![PyPI version](https://img.shields.io/pypi/v/svforge.svg)](https://pypi.org/project/svforge/) -[![License](https://img.shields.io/pypi/l/svforge)](https://pypi.org/project/svforge/) +[![License](https://img.shields.io/pypi/l/svforge)](https://github.com/pieetie/svforge/blob/main/LICENSE) [![DOI](https://img.shields.io/badge/DOI-10.5281%2Fzenodo.19762333-blue)](https://doi.org/10.5281/zenodo.19762333) --- From 4c226eed606997a8a5b4931ffabb92e227710ad9 Mon Sep 17 00:00:00 2001 From: pieetie <129987442+pieetie@users.noreply.github.com> Date: Tue, 28 Apr 2026 00:24:11 +0200 Subject: [PATCH 05/10] fix(writers,cli): paired somatic VCF output for gen-pair (#7) --- src/svforge/cli.py | 62 +++++++++++++------- src/svforge/writers/base.py | 47 +++++++++++++++ src/svforge/writers/delly.py | 63 ++++++++++++++++++++ src/svforge/writers/manta.py | 109 +++++++++++++++++++++++++++++++++++ 4 files changed, 259 insertions(+), 22 deletions(-) diff --git a/src/svforge/cli.py b/src/svforge/cli.py index 6c01d4c..385b2d5 100644 --- a/src/svforge/cli.py +++ b/src/svforge/cli.py @@ -4,7 +4,7 @@ Subcommands: - ``gen`` single-sample synthetic VCF -- ``gen-pair`` tumor + normal paired VCFs +- ``gen-pair`` single paired somatic VCF (two sample columns) - ``validate`` self-consistency check against bundled injection catalogs - ``bank`` list / show built-in banks - ``callers`` list every registered writer @@ -158,9 +158,11 @@ def _add_gen_parser(sub: argparse._SubParsersAction[argparse.ArgumentParser]) -> def _add_gen_pair_parser(sub: argparse._SubParsersAction[argparse.ArgumentParser]) -> None: - p = sub.add_parser("gen-pair", help="Generate paired tumor/normal VCFs") - p.add_argument("--out-tumor", type=Path, required=True) - p.add_argument("--out-normal", type=Path, required=True) + p = sub.add_parser( + "gen-pair", + help="Generate a paired tumor/normal somatic SV VCF (2 sample columns)", + ) + p.add_argument("--out", type=Path, required=True, help="Output .vcf / .vcf.gz / .bcf") p.add_argument("--n-somatic", type=int, required=True) p.add_argument("--n-germline", type=int, required=True) p.add_argument("--tumor-sample-name", required=True) @@ -224,32 +226,24 @@ def _cmd_gen_pair(args: argparse.Namespace) -> int: pair = sample_pair(bank, args.n_somatic, args.n_germline, cfg) writer = get_writer(args.caller) provenance = build_svforge_tags(caller=args.caller, seed=effective_seed, argv=sys.argv) - _write_sample_vcf( + + _write_paired_vcf( writer, pair.tumor, - args.tumor_sample_name, - args.out_tumor, - args.genome, - provenance, - header_template_override=args.header_template, - ) - _write_sample_vcf( - writer, - pair.normal, - args.normal_sample_name, - args.out_normal, - args.genome, - provenance, + tumor_sample=args.tumor_sample_name, + normal_sample=args.normal_sample_name, + out_path=args.out, + genome=args.genome, + provenance_tags=provenance, header_template_override=args.header_template, ) + log.info( - "Tumor: %d SVs (%d somatic + %d germline), Normal: %d SVs -> %s, %s (seed=%d)", + "Wrote %d SVs (%d somatic + %d germline) to %s (seed=%d)", len(pair.tumor), len(pair.somatic_ids), len(pair.germline_ids), - len(pair.normal), - args.out_tumor, - args.out_normal, + args.out, effective_seed, ) return 0 @@ -367,6 +361,30 @@ def _write_sample_vcf( write_vcf(out_path, header, records) +def _write_paired_vcf( + writer: CallerWriter, + svs: list[SV], + *, + tumor_sample: str, + normal_sample: str, + out_path: Path, + genome: GenomeBuild, + provenance_tags: Sequence[str], + header_template_override: Path | None = None, +) -> None: + header = writer.header_lines_paired( + tumor_sample=tumor_sample, + normal_sample=normal_sample, + genome=genome, + provenance_tags=provenance_tags, + template_override=header_template_override, + ) + records = writer.format_records_paired_sorted( + svs, tumor_sample, normal_sample, header + ) + write_vcf(out_path, header, records) + + def _bank_to_dict(bank: Bank) -> dict[str, object]: return { "name": bank.name, diff --git a/src/svforge/writers/base.py b/src/svforge/writers/base.py index 67ad8c7..33055bc 100644 --- a/src/svforge/writers/base.py +++ b/src/svforge/writers/base.py @@ -191,6 +191,53 @@ def format_records_sorted( raw = self.format_records(svs, sample_name) return self.sort_records(raw, extract_contig_order(header_lines)) + def format_record_paired( + self, + sv: SV, + tumor_sample: str, + normal_sample: str, + ) -> list[VCFRecord]: + """ + Return one or more VCF records for ``sv`` with two sample columns. + + Column order follows :attr:`SAMPLE_COLUMN_ORDER`. Sample columns are + formatted differently based on ``sv.origin``: + + - ``somatic``: the NORMAL column has ref-only support + - ``germline``: both columns have alt support consistent with VAF + + Manta returns two records for a BND event (one per mate). DELLY and + simple symbolic records (DEL/DUP/INV/INS) return a single-item list. + """ + raise NotImplementedError( + f"{type(self).__name__} does not support paired (somatic) output" + ) + + def format_records_paired( + self, + svs: Iterable[SV], + tumor_sample: str, + normal_sample: str, + ) -> list[VCFRecord]: + """Format a whole SV collection as paired tumor/normal records (not sorted).""" + records: list[VCFRecord] = [] + for sv in svs: + if sv.svtype not in self.supported_svtypes: + raise ValueError(f"Writer {self.name!r} does not support svtype {sv.svtype!r}") + records.extend(self.format_record_paired(sv, tumor_sample, normal_sample)) + return records + + def format_records_paired_sorted( + self, + svs: Iterable[SV], + tumor_sample: str, + normal_sample: str, + header_lines: Sequence[str], + ) -> list[VCFRecord]: + """Like :meth:`format_records_paired` but sorted by (contig_index, pos).""" + raw = self.format_records_paired(svs, tumor_sample, normal_sample) + return self.sort_records(raw, extract_contig_order(header_lines)) + def sort_records( self, records: Sequence[VCFRecord], diff --git a/src/svforge/writers/delly.py b/src/svforge/writers/delly.py index e3d481b..683a280 100644 --- a/src/svforge/writers/delly.py +++ b/src/svforge/writers/delly.py @@ -41,6 +41,47 @@ class DellyWriter(CallerWriter): def format_record(self, sv: SV, sample_name: str) -> list[VCFRecord]: return [_delly_record(sv)] + def format_record_paired( + self, + sv: SV, + tumor_sample: str, + normal_sample: str, + ) -> list[VCFRecord]: + del tumor_sample, normal_sample + return [_delly_record_paired(sv)] + + +def _paired_sample_columns(sv: SV) -> tuple[str, str, str]: + """ + Return (FORMAT, tumor_column, normal_column) for a paired DELLY record. + + Note: DELLY column order is (TUMOR, NORMAL), not (NORMAL, TUMOR) like Manta. + """ + total = 30 + alt = max(1, round(total * sv.vaf)) + ref = max(0, total - alt) + junction_alt = max(1, alt // 2) + junction_ref = max(0, ref // 2) + gq = 60 + gl_alt = ( + "-50,-5,0" + if sv.genotype == "1/1" + else ("0,-5,-50" if sv.genotype == "0/0" else "-10,-1,-10") + ) + fmt = "GT:GL:GQ:FT:RC:RCL:RCR:RDCN:DR:DV:RR:RV" + + tumor_col = ( + f"{sv.genotype}:{gl_alt}:{gq}:PASS:40:20:20:2:" + f"{ref}:{alt}:{junction_ref}:{junction_alt}" + ) + + if sv.origin == "germline": + normal_col = tumor_col + else: + normal_col = f"0/0:0,-5,-50:{gq}:PASS:40:20:20:2:30:0:15:0" + + return fmt, tumor_col, normal_col + def _ct_for(sv: SV) -> str: """ @@ -135,6 +176,28 @@ def _delly_record(sv: SV) -> VCFRecord: return VCFRecord(chrom=sv.chrom, pos=sv.pos, line=line) +def _delly_record_paired(sv: SV) -> VCFRecord: + alt = f"<{sv.svtype}>" + info = ";".join(_base_info(sv)) + fmt, tumor_col, normal_col = _paired_sample_columns(sv) + line = "\t".join( + [ + sv.chrom, + str(sv.pos), + sv.id, + sv.ref_base, + alt, + ".", + sv.filter, + info, + fmt, + tumor_col, + normal_col, + ] + ) + return VCFRecord(chrom=sv.chrom, pos=sv.pos, line=line) + + from svforge.writers._registry import register_writer # noqa: E402 register_writer("delly")(DellyWriter) diff --git a/src/svforge/writers/manta.py b/src/svforge/writers/manta.py index 5798cc1..bc888e9 100644 --- a/src/svforge/writers/manta.py +++ b/src/svforge/writers/manta.py @@ -45,6 +45,34 @@ def format_record(self, sv: SV, sample_name: str) -> list[VCFRecord]: return _bnd_mate_records(sv) return [_symbolic_record(sv)] + def format_record_paired( + self, + sv: SV, + tumor_sample: str, + normal_sample: str, + ) -> list[VCFRecord]: + del tumor_sample, normal_sample # column order fixed by SAMPLE_COLUMN_ORDER + paired helpers + if sv.svtype == "BND": + return _bnd_mate_records_paired(sv) + return [_symbolic_record_paired(sv)] + + +def _paired_sample_columns(sv: SV) -> tuple[str, str, str]: + """ + Return (FORMAT, normal_column, tumor_column) for a paired Manta record. + + For germline variants, both columns reflect the variant's VAF. + For somatic variants, the NORMAL column is ref-only. + """ + fmt = "PR:SR" + ref, alt = _sample_depth(sv.vaf) + split_alt = max(1, alt // 2) + split_ref = max(0, ref // 2) + tumor_col = f"{ref},{alt}:{split_ref},{split_alt}" + normal_col = tumor_col if sv.origin == "germline" else "30,0:15,0" + + return fmt, normal_col, tumor_col + def _sample_depth(vaf: float) -> tuple[int, int]: """ @@ -117,6 +145,28 @@ def _symbolic_record(sv: SV) -> VCFRecord: return VCFRecord(chrom=sv.chrom, pos=sv.pos, line=line) +def _symbolic_record_paired(sv: SV) -> VCFRecord: + alt = f"<{sv.svtype}>" + info = ";".join(_base_info(sv)) + fmt, normal_col, tumor_col = _paired_sample_columns(sv) + line = "\t".join( + [ + sv.chrom, + str(sv.pos), + sv.id, + sv.ref_base, + alt, + ".", + sv.filter, + info, + fmt, + normal_col, + tumor_col, + ] + ) + return VCFRecord(chrom=sv.chrom, pos=sv.pos, line=line) + + def _bnd_alt(ref_base: str, mate_chrom: str, mate_pos: int, strands: str) -> str: """ Return the Manta-style BND ALT string for a breakend @@ -203,6 +253,65 @@ def _bnd_mate_records(sv: SV) -> list[VCFRecord]: ] +def _bnd_mate_records_paired(sv: SV) -> list[VCFRecord]: + if sv.mate_chrom is None or sv.mate_pos is None: + raise ValueError(f"BND {sv.id!r} missing mate coordinates") + id1 = f"{sv.id}_1" + id2 = f"{sv.id}_2" + + alt1 = _bnd_alt(sv.ref_base, sv.mate_chrom, sv.mate_pos, sv.strands) + alt2 = _bnd_alt(sv.ref_base, sv.chrom, sv.pos, _mate_strands(sv.strands)) + + info1 = ["SVTYPE=BND", f"MATEID={id2}", f"EVENT={sv.id}"] + info2 = ["SVTYPE=BND", f"MATEID={id1}", f"EVENT={sv.id}"] + if sv.homlen: + info1.append(f"HOMLEN={sv.homlen}") + info2.append(f"HOMLEN={sv.homlen}") + if sv.origin == "somatic": + for info in (info1, info2): + info.append("SOMATIC") + info.append("SOMATICSCORE=60") + info1.append(f"SVFORGE_SOURCE={sv.source}") + info2.append(f"SVFORGE_SOURCE={sv.source}") + + fmt, normal_col, tumor_col = _paired_sample_columns(sv) + + line1 = "\t".join( + [ + sv.chrom, + str(sv.pos), + id1, + sv.ref_base, + alt1, + ".", + sv.filter, + ";".join(info1), + fmt, + normal_col, + tumor_col, + ] + ) + line2 = "\t".join( + [ + sv.mate_chrom, + str(sv.mate_pos), + id2, + sv.ref_base, + alt2, + ".", + sv.filter, + ";".join(info2), + fmt, + normal_col, + tumor_col, + ] + ) + return [ + VCFRecord(chrom=sv.chrom, pos=sv.pos, line=line1), + VCFRecord(chrom=sv.mate_chrom, pos=sv.mate_pos, line=line2), + ] + + from svforge.writers._registry import register_writer # noqa: E402 register_writer("manta")(MantaWriter) From c3ba8f537bcd7fa17b38f23cc693c1802b4ab8b9 Mon Sep 17 00:00:00 2001 From: pieetie <129987442+pieetie@users.noreply.github.com> Date: Tue, 28 Apr 2026 00:24:49 +0200 Subject: [PATCH 06/10] test: cover gen pair 2 sample VCF + update cli chrom tests (#7) --- tests/test_chromosome_filter.py | 14 +-- tests/test_cli.py | 26 ++--- tests/test_pair_mode.py | 3 +- tests/test_paired_output.py | 180 ++++++++++++++++++++++++++++++++ 4 files changed, 201 insertions(+), 22 deletions(-) create mode 100644 tests/test_paired_output.py diff --git a/tests/test_chromosome_filter.py b/tests/test_chromosome_filter.py index 546b971..498d42a 100644 --- a/tests/test_chromosome_filter.py +++ b/tests/test_chromosome_filter.py @@ -184,8 +184,7 @@ def test_empty_pool_raises_explicit_error(tmp_path: Path) -> None: assert rc != 0 def test_gen_pair_accepts_chromosome_filter(tmp_path: Path) -> None: - tumor = tmp_path / "t.vcf" - normal = tmp_path / "n.vcf" + paired = tmp_path / "paired.vcf" rc = main( [ "gen-pair", @@ -193,10 +192,8 @@ def test_gen_pair_accepts_chromosome_filter(tmp_path: Path) -> None: "manta", "--bank", str(BANK), - "--out-tumor", - str(tumor), - "--out-normal", - str(normal), + "--out", + str(paired), "--n-somatic", "5", "--n-germline", @@ -214,6 +211,5 @@ def test_gen_pair_accepts_chromosome_filter(tmp_path: Path) -> None: ] ) assert rc == 0 - for path in (tumor, normal): - for rec in _records(path): - assert rec.chrom in {"chr1", "chr2"} + for rec in _records(paired): + assert rec.chrom in {"chr1", "chr2"} diff --git a/tests/test_cli.py b/tests/test_cli.py index 4ec21b3..8d89bf8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -55,17 +55,14 @@ def test_cli_gen_manta_vcf_gz(tmp_path: Path, mini_bank_path: Path) -> None: assert sum(1 for _ in vf) > 0 def test_cli_gen_pair(tmp_path: Path, mini_bank_path: Path) -> None: - tumor = tmp_path / "tumor.vcf.gz" - normal = tmp_path / "normal.vcf.gz" + out = tmp_path / "paired.vcf.gz" rc = main( [ "gen-pair", "--caller", "delly", - "--out-tumor", - str(tumor), - "--out-normal", - str(normal), + "--out", + str(out), "--n-somatic", "5", "--n-germline", @@ -83,12 +80,17 @@ def test_cli_gen_pair(tmp_path: Path, mini_bank_path: Path) -> None: ] ) assert rc == 0 - with pysam.VariantFile(str(tumor)) as vf: - tumor_ids = {r.id for r in vf if r.id} - with pysam.VariantFile(str(normal)) as vf: - normal_ids = {r.id for r in vf if r.id} - assert normal_ids.issubset(tumor_ids) - assert len(tumor_ids - normal_ids) >= 5 + somatic_n = 0 + germline_n = 0 + with pysam.VariantFile(str(out)) as vf: + assert len(vf.header.samples) == 2 + for rec in vf: + if rec.info.get("SOMATIC"): + somatic_n += 1 + else: + germline_n += 1 + assert somatic_n >= 5 + assert germline_n >= 6 def test_cli_validate_pass( tmp_path: Path, diff --git a/tests/test_pair_mode.py b/tests/test_pair_mode.py index 4da7b54..7fd1f4b 100644 --- a/tests/test_pair_mode.py +++ b/tests/test_pair_mode.py @@ -1,5 +1,6 @@ """ -End-to-end tests for the pair (tumor + normal) mode +Integration tests for :func:`~svforge.core.sampler.sample_pair`: writing tumor vs. +normal-only SV lists as separate single-sample VCFs (distinct from CLI ``gen-pair``). """ from __future__ import annotations diff --git a/tests/test_paired_output.py b/tests/test_paired_output.py new file mode 100644 index 0000000..2afa198 --- /dev/null +++ b/tests/test_paired_output.py @@ -0,0 +1,180 @@ +""" +Tests for paired (somatic) VCF output structure. + +Guard rail for issue #7 regression. +""" + +from __future__ import annotations + +from pathlib import Path + +import pysam +import pytest + +from svforge.cli import main as cli_main + + +def _run_svforge(args: list[str]) -> None: + rc = cli_main(args) + if rc != 0: + raise RuntimeError(f"svforge exited with {rc}") + + +@pytest.fixture +def paired_vcf(tmp_path: Path) -> Path: + out = tmp_path / "somaticSV.vcf.gz" + _run_svforge( + [ + "gen-pair", + "--caller", + "manta", + "--n-somatic", + "10", + "--n-germline", + "5", + "--tumor-sample-name", + "MY_TUMOR", + "--normal-sample-name", + "MY_NORMAL", + "--seed", + "42", + "--out", + str(out), + ] + ) + return out + + +def test_paired_vcf_has_two_sample_columns(paired_vcf: Path) -> None: + """Paired VCF must have NORMAL and TUMOR columns in Manta order.""" + with pysam.VariantFile(str(paired_vcf)) as vf: + samples = list(vf.header.samples) + assert samples == ["MY_NORMAL", "MY_TUMOR"], ( + f"Expected [NORMAL, TUMOR] columns, got {samples}" + ) + + +def test_paired_vcf_contains_both_somatic_and_germline(paired_vcf: Path) -> None: + """Paired VCF must contain both somatic and germline records.""" + somatic_count = 0 + germline_count = 0 + with pysam.VariantFile(str(paired_vcf)) as vf: + for rec in vf: + if rec.info.get("SOMATIC"): + somatic_count += 1 + else: + germline_count += 1 + assert somatic_count > 0, "No somatic records found" + assert germline_count > 0, "No germline records found" + + +def test_somatic_records_have_ref_only_normal_column(paired_vcf: Path) -> None: + """For SOMATIC records, NORMAL column should have only ref support.""" + with pysam.VariantFile(str(paired_vcf)) as vf: + for rec in vf: + if rec.info.get("SOMATIC"): + pr = rec.samples["MY_NORMAL"]["PR"] + assert pr[1] == 0, ( + f"Record {rec.id}: NORMAL PR alt should be 0 for " + f"somatic variant, got {pr}" + ) + + +def test_germline_records_have_alt_support_in_both_columns(paired_vcf: Path) -> None: + """Germline records must have alt support in both NORMAL and TUMOR.""" + with pysam.VariantFile(str(paired_vcf)) as vf: + for rec in vf: + if not rec.info.get("SOMATIC"): + normal_pr = rec.samples["MY_NORMAL"]["PR"] + tumor_pr = rec.samples["MY_TUMOR"]["PR"] + assert normal_pr[1] > 0, ( + f"Record {rec.id}: germline should have alt support " + f"in NORMAL, got {normal_pr}" + ) + assert tumor_pr[1] > 0, ( + f"Record {rec.id}: germline should have alt support " + f"in TUMOR, got {tumor_pr}" + ) + + +def test_paired_vcf_is_tabix_indexable(paired_vcf: Path) -> None: + """Paired VCF must be tabix-indexable (no #6 regression).""" + pysam.tabix_index(str(paired_vcf), preset="vcf", force=True) + assert (paired_vcf.parent / (paired_vcf.name + ".tbi")).exists() + + +@pytest.mark.parametrize("caller", ["manta", "delly"]) +def test_paired_works_for_both_callers(tmp_path: Path, caller: str) -> None: + """Both Manta and DELLY support paired output with 2 sample columns.""" + out = tmp_path / f"{caller}_somatic.vcf" + _run_svforge( + [ + "gen-pair", + "--caller", + caller, + "--n-somatic", + "5", + "--n-germline", + "3", + "--tumor-sample-name", + "T", + "--normal-sample-name", + "N", + "--seed", + "42", + "--out", + str(out), + ] + ) + with pysam.VariantFile(str(out)) as vf: + samples = list(vf.header.samples) + assert len(samples) == 2 + + +def test_caller_specific_column_order(tmp_path: Path) -> None: + """Manta uses NORMAL,TUMOR order; DELLY uses TUMOR,NORMAL.""" + manta_out = tmp_path / "manta.vcf" + _run_svforge( + [ + "gen-pair", + "--caller", + "manta", + "--n-somatic", + "3", + "--n-germline", + "2", + "--tumor-sample-name", + "T", + "--normal-sample-name", + "N", + "--seed", + "42", + "--out", + str(manta_out), + ] + ) + with pysam.VariantFile(str(manta_out)) as vf: + assert list(vf.header.samples) == ["N", "T"] + + delly_out = tmp_path / "delly.vcf" + _run_svforge( + [ + "gen-pair", + "--caller", + "delly", + "--n-somatic", + "3", + "--n-germline", + "2", + "--tumor-sample-name", + "T", + "--normal-sample-name", + "N", + "--seed", + "42", + "--out", + str(delly_out), + ] + ) + with pysam.VariantFile(str(delly_out)) as vf: + assert list(vf.header.samples) == ["T", "N"] From afdd9db3f09fffff2ec42890a48e190db156b326 Mon Sep 17 00:00:00 2001 From: pieetie <129987442+pieetie@users.noreply.github.com> Date: Tue, 28 Apr 2026 00:25:36 +0200 Subject: [PATCH 07/10] docs: update README (#7) --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index edb0a3e..cac291e 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ pip install -e ".[dev,test]" ## Quick start -For ready-to-run command lines (single sample, tumor/normal pair, validation, banks, and dev checks), see [`docs/ready-to-use.md`](docs/ready-to-use.md). +For ready-to-run command lines (single-sample `gen`, paired somatic `gen-pair`, validation, banks, and dev checks), see [`docs/ready-to-use.md`](docs/ready-to-use.md). ## Typical use cases @@ -50,7 +50,7 @@ For ready-to-run command lines (single sample, tumor/normal pair, validation, ba ``` svforge gen # one VCF for one sample -svforge gen-pair # tumor + normal VCFs for somatic pipelines +svforge gen-pair # one 2-sample somatic VCF (NORMAL + TUMOR) svforge validate # self-consistency check of injected SVs svforge bank list # list built-in banks svforge bank show # dump a bank as YAML From aec74dbfc8d78b8c9e9c2bf22ecbc56ae737e0fe Mon Sep 17 00:00:00 2001 From: pieetie <129987442+pieetie@users.noreply.github.com> Date: Tue, 28 Apr 2026 00:26:24 +0200 Subject: [PATCH 08/10] docs: fix gen pair command (#7) --- docs/ready-to-use.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/ready-to-use.md b/docs/ready-to-use.md index bf00e37..5ce0e02 100644 --- a/docs/ready-to-use.md +++ b/docs/ready-to-use.md @@ -129,15 +129,16 @@ svforge gen --caller manta --out data_local/gen-test/out.vcf.gz --n 100 --sample --- -## 4. Tumor + normal pair (`gen-pair`) +## 4. Paired somatic VCF (`gen-pair`) + +Produces **one** VCF with **two sample columns** (NORMAL + TUMOR for Manta, TUMOR + NORMAL for DELLY), like real `somaticSV.vcf.gz`. **Example:** ```bash svforge gen-pair \ --caller manta \ - --out-tumor data_local/gen-test/tumor.vcf.gz \ - --out-normal data_local/gen-test/normal.vcf.gz \ + --out data_local/gen-test/somaticSV.vcf.gz \ --n-somatic 30 \ --n-germline 10 \ --tumor-sample-name TUMOR_01 \ From b05e2bcd0683d1cf4787cc5edbaef3ab98e87691 Mon Sep 17 00:00:00 2001 From: pieetie <129987442+pieetie@users.noreply.github.com> Date: Tue, 28 Apr 2026 00:57:17 +0200 Subject: [PATCH 09/10] fix: ruff + mypy (#10) --- src/svforge/io/vcf_writer.py | 2 +- tests/test_indexability.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/svforge/io/vcf_writer.py b/src/svforge/io/vcf_writer.py index 4665ea3..8ea9194 100644 --- a/src/svforge/io/vcf_writer.py +++ b/src/svforge/io/vcf_writer.py @@ -76,7 +76,7 @@ def write_vcf( staging.write_text(header_text + record_text, encoding="utf-8") with ( pysam.VariantFile(str(staging)) as vin, - pysam.VariantFile(str(out), mode, header=vin.header) as vout, # type: ignore[arg-type] + pysam.VariantFile(str(out), mode, header=vin.header) as vout, ): for rec in vin: vout.write(rec) diff --git a/tests/test_indexability.py b/tests/test_indexability.py index 7a0dbf8..6014775 100644 --- a/tests/test_indexability.py +++ b/tests/test_indexability.py @@ -208,7 +208,7 @@ def test_sort_preserves_record_content(tmp_path: Path) -> None: str(out), ] ) - with open(out, encoding="utf-8") as fh: + with out.open(encoding="utf-8") as fh: records = [line.rstrip("\n") for line in fh if not line.startswith("#")] contig_order = _extract_contig_order(out) contig_idx = {c: i for i, c in enumerate(contig_order)} From eef095828d01b9d63890befea342fdd12c4512f3 Mon Sep 17 00:00:00 2001 From: pieetie <129987442+pieetie@users.noreply.github.com> Date: Tue, 28 Apr 2026 00:57:47 +0200 Subject: [PATCH 10/10] chore(release): v1.0.1 --- CHANGELOG.md | 14 +++++++++++++- CITATION.cff | 4 ++-- README.md | 2 +- pyproject.toml | 2 +- 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cc5aab..a4a4bc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [1.0.1] — 2026-04-28 + +### Fixed + +- `gen-pair` now produces a two-sample somatic VCF (#7). +- VCF output is now tabix-indexable; previously inter-chromosomal BND records broke sort order (#6). +- README DOI and license badges (#8, #9). + +### Changed + +- **BREAKING**: `gen-pair` replaces `--out-tumor` / `--out-normal` with a single `--out` flag. + ## [1.0.0] — 2026-04-25 Initial release. @@ -17,4 +29,4 @@ Initial release. - Writer plugin system: third-party callers can register via `svforge.writers` entry point - Non-configurable `##svforgeWarning=SYNTHETIC_DATA_DO_NOT_USE_FOR_CLINICAL_DIAGNOSIS` injected in every VCF header - `sanitize_command()` strips absolute paths from the logged command line so user home directories and cluster paths never leak into generated VCFs -- Python 3.10+, hg38 only \ No newline at end of file +- Python 3.10+, hg38 only diff --git a/CITATION.cff b/CITATION.cff index 3fc0a23..0a16ebe 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -4,8 +4,8 @@ title: "svForge" authors: - family-names: "Natiez" given-names: "Pierre" -version: "1.0.0" -date-released: "2026-04-25" +version: "1.0.1" +date-released: "2026-04-28" license: MIT repository-code: "https://github.com/pieetie/svforge" identifiers: diff --git a/README.md b/README.md index cac291e..69d625c 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ --- -**svForge** produces caller-specific VCFs (Manta, DELLY) in VCF / VCF.gz / BCF format with fine-grained control over variability (HOMLEN, SVLEN, VAF) and realistic artefact injection (SVs in ENCODE blacklist regions, gnomAD germline SVs). +**svForge** produces caller-shaped VCFs (Manta, DELLY) in VCF / VCF.gz / BCF format with fine-grained control over variability (HOMLEN, SVLEN, VAF) and realistic artefact injection (SVs in ENCODE blacklist regions, gnomAD germline SVs). Designed to be modular, it is easy to adapt to your own use case. You can tune generation parameters, plug in new callers, and customize the workflow without reworking the whole tool. diff --git a/pyproject.toml b/pyproject.toml index c2df056..f606f12 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "svforge" -version = "1.0.0" +version = "1.0.1" description = "Synthetic VCF generator for structural variants (Manta, DELLY) with controlled variability and realistic artefact injection" readme = "README.md" requires-python = ">=3.10"