Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
- Python 3.10+, hg38 only
4 changes: 2 additions & 2 deletions CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@
### 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/)
[![DOI](https://zenodo.org/badge/1218168425.svg)](https://doi.org/10.5281/zenodo.19762333)
[![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)

---

**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.
Expand All @@ -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

Expand All @@ -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
Expand Down
7 changes: 4 additions & 3 deletions docs/ready-to-use.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
64 changes: 41 additions & 23 deletions src/svforge/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -363,7 +357,31 @@ 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)


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)


Expand Down
16 changes: 13 additions & 3 deletions src/svforge/io/vcf_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@

import pysam

from svforge.writers.base import VCFRecord


def detect_mode(path: Path) -> str:
"""
Expand All @@ -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``
Expand All @@ -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")
Expand All @@ -74,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)
Expand All @@ -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)
Loading
Loading