From b7c7508121b9fa26ed99114898a14573954e8e24 Mon Sep 17 00:00:00 2001 From: clintval Date: Tue, 27 Oct 2026 10:26:19 -1000 Subject: [PATCH 1/4] feat: find fgumi and fgbio variant reviews with bedspec.ext.captainslog --- bedspec/ext/_captainslog.py | 15 ++++++++--- tests/ext/test_captainslog.py | 50 +++++++++++++++++++++++++++++++++++ tests/test_ext_imports.py | 8 +++++- 3 files changed, 69 insertions(+), 4 deletions(-) diff --git a/bedspec/ext/_captainslog.py b/bedspec/ext/_captainslog.py index 6841000..4ae7ae1 100644 --- a/bedspec/ext/_captainslog.py +++ b/bedspec/ext/_captainslog.py @@ -1,6 +1,8 @@ from typing import overload try: + from captainslog import fgbio + from captainslog import fgumi from captainslog.picard import FingerprintingDetailMetrics from captainslog.picard import PerBaseCoverage from captainslog.picard import PerTargetCoverage @@ -28,17 +30,22 @@ def as_interval(record: HybcapPerBase) -> list[Bed4]: ... @overload def as_interval(record: FingerprintingDetailMetrics) -> list[Bed4]: ... @overload +def as_interval(record: fgbio.ConsensusVariantReviewInfo) -> list[Bed3]: ... +@overload +def as_interval(record: fgumi.ConsensusVariantReviewInfo) -> list[Bed3]: ... +@overload def as_interval(record: RrbsCpgDetailMetrics) -> list[Bed3]: ... # fmt: on def as_interval(record: object) -> list[Bed4] | list[Bed3]: - """Return the interval of a target, a base, a SNP, or a CpG, from the coordinates it holds. + """Return the interval of a target, a base, a SNP, a variant, or a CpG, from its coordinates. A target runs from its 1-based start to its end and a base or a SNP covers its 1-based position, each named for its target or SNP, while a CpG covers its C on the forward strand, at the 0-based - position Picard writes. + position Picard writes. A variant fgumi or fgbio reviewed covers its 1-based position, unnamed, + as its row names a consensus read and not the variant. Raises: - TypeError: If the record is not one of captainslog's targets, bases, SNPs, or CpGs. + TypeError: If the record is none of captainslog's targets, bases, SNPs, variants, or CpGs. ValueError: If the record is a CpG without a position. """ if isinstance(record, PerTargetCoverage | HybcapPerTarget): @@ -50,6 +57,8 @@ def as_interval(record: object) -> list[Bed4] | list[Bed3]: if isinstance(record, FingerprintingDetailMetrics): name = record.snp or None return [Bed4(record.chrom, start=record.position - 1, end=record.position, name=name)] + if isinstance(record, fgbio.ConsensusVariantReviewInfo | fgumi.ConsensusVariantReviewInfo): + return [Bed3(record.chrom, start=record.pos - 1, end=record.pos)] if isinstance(record, RrbsCpgDetailMetrics): if record.position is None: raise ValueError("A CpG needs a position to find its interval, not None!") diff --git a/tests/ext/test_captainslog.py b/tests/ext/test_captainslog.py index d07ad18..ef269d5 100644 --- a/tests/ext/test_captainslog.py +++ b/tests/ext/test_captainslog.py @@ -4,6 +4,11 @@ from typing import Any import pytest +from captainslog import fgbio +from captainslog import fgumi +from captainslog.fgbio import ErrorRateByReadPositionMetric +from captainslog.fgbio import FgbioReader +from captainslog.fgumi import FgumiReader from captainslog.picard import FingerprintingDetailMetrics from captainslog.picard import PerBaseCoverage from captainslog.picard import PerTargetCoverage @@ -73,6 +78,14 @@ CHR1 = "TTCATGCTGAAGCCCTCTTACGATCGTACAGATGCAAATATTAACAAACC" """The first bases of chr1 of the reference the RRBS rows were collected against.""" +REVIEW = """chrom\tpos\tref\tgenotype\tfilters\tA\tC\tG\tT\tN\tconsensus_read\tconsensus_insert\tconsensus_call\tconsensus_qual\ta\tc\tg\tt\tn +chr1\t501\tA\tA/G\tPASS\t2\t0\t1\t0\t0\tS1-lib|S2-lib:4/1\tchr1:452-770 | F1R2\tG\t72\t0\t0\t1\t0\t0 +""" # noqa: E501 +"""A consensus read carrying a variant, as fgumi and fgbio both write it.""" + +CHR1_FROM_490 = "TCATAATCTCAGTGCTGGTG" +"""The bases of chr1 from 0-based 490 of the reference the reviewed reads were aligned to.""" + def written(tmp_path: Path, name: str, text: str) -> Path: """Return the path of a file in a temporary directory holding text.""" @@ -147,6 +160,22 @@ def test_as_interval_of_a_cpg_covers_its_c_at_the_0_based_position_picard_writes assert CHR1[spans[0].start : spans[0].end + 1] == "CG" +def test_as_interval_of_a_reviewed_variant_covers_its_1_based_position(tmp_path: Path) -> None: + """Test that a variant fgumi or fgbio reviewed covers its position, 0-based, and is unnamed.""" + path = written(tmp_path, "review.txt", REVIEW) + with ( + FgbioReader.from_path[fgbio.ConsensusVariantReviewInfo](path) as by_fgbio, + FgumiReader.from_path[fgumi.ConsensusVariantReviewInfo](path) as by_fgumi, + ): + variants: list[fgbio.ConsensusVariantReviewInfo | fgumi.ConsensusVariantReviewInfo] = [ + *by_fgbio, + *by_fgumi, + ] + spans = [span for variant in variants for span in as_interval(variant)] + assert spans == 2 * [Bed3("chr1", start=500, end=501)] + assert CHR1_FROM_490[spans[0].start - 490 : spans[0].end - 490] == variants[0].ref + + def test_as_interval_of_a_target_or_base_without_a_name_is_unnamed() -> None: """Test that a target or a base whose name is empty has an interval without a name.""" assert as_interval(PerTargetCoverage(chrom="1", start=201, end=300)) == [ @@ -196,6 +225,20 @@ def test_registered_targets_are_found_by_their_first_and_last_bases_only(tmp_pat assert [target.name for target in everywhere] == ["rs17272796"] +def test_registered_reviewed_variants_are_found_by_their_base_only(tmp_path: Path) -> None: + """Test that registered reviewed variants are found by their base, not the bases beside it.""" + path = written(tmp_path, "review.txt", REVIEW) + with ( + FgumiReader.from_path[fgumi.ConsensusVariantReviewInfo](path) as reader, + register_locatable(fgumi.ConsensusVariantReviewInfo, as_interval), + ): + variants = TreeDetector(reader) + found = variants.overlapping(Bed3("chr1", start=500, end=501)) + assert [variant.consensus_read for variant in found] == ["S1-lib|S2-lib:4/1"] + assert not variants.overlaps(Bed3("chr1", start=499, end=500)) + assert not variants.overlaps(Bed3("chr1", start=501, end=502)) + + def test_as_interval_of_a_record_of_another_class_raises_naming_it() -> None: """Test that a record of a class without an interval, like Riker's hybcap summary, raises.""" record: Any = HybcapMetrics() @@ -203,6 +246,13 @@ def test_as_interval_of_a_record_of_another_class_raises_naming_it() -> None: as_interval(record) +def test_as_interval_of_an_error_rate_by_read_position_raises() -> None: + """Test that fgbio's error rate at a position within a read, not on a reference, raises.""" + record: Any = ErrorRateByReadPositionMetric(position=501) + with pytest.raises(TypeError, match=r"captainslog\.fgbio\..*ErrorRateByReadPositionMetric!"): + as_interval(record) + + def test_as_interval_of_a_cpg_without_a_position_raises() -> None: """Test that a CpG made without a position has no interval, and raises saying so.""" with pytest.raises(ValueError, match="needs a position"): diff --git a/tests/test_ext_imports.py b/tests/test_ext_imports.py index dee9d26..8ce66f6 100644 --- a/tests/test_ext_imports.py +++ b/tests/test_ext_imports.py @@ -13,7 +13,13 @@ "pybedtools": ("pybedtools",), "pybwa": ("pybwa",), "pysam": ("pysam", "pysam.libctabixproxies"), - "captainslog": ("captainslog", "captainslog.picard", "captainslog.riker"), + "captainslog": ( + "captainslog", + "captainslog.fgbio", + "captainslog.fgumi", + "captainslog.picard", + "captainslog.riker", + ), } """The modules each extra installs, blocked so importing them fails as if they were missing.""" From dc093e5927c881dffe51f46adc748a1d5f24a8ae Mon Sep 17 00:00:00 2001 From: clintval Date: Tue, 27 Oct 2026 10:26:20 -1000 Subject: [PATCH 2/4] docs: list fgumi and fgbio variant reviews in the extensions table --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index aaf278f..6f57a17 100644 --- a/README.md +++ b/README.md @@ -442,7 +442,7 @@ Each module names them the same way: `as_interval` finds a record by its interva | --- | --- | --- | | `bedspec.ext.biopython` | `as_interval` | a `Bio.Align.Alignment`, like a BED12 line, by its first to last aligned base, on its strand | | | `as_blocks` | a `Bio.Align.Alignment` by its blocks, on its strand | -| `bedspec.ext.captainslog` | `as_interval` | a Picard or Riker per-target or per-base coverage row, or a `FingerprintingDetailMetrics` SNP, converted from the 1-based coordinates it holds; a `RrbsCpgDetailMetrics` by the C of its CpG on the forward strand | +| `bedspec.ext.captainslog` | `as_interval` | a Riker or Picard per-target or per-base coverage row, a `FingerprintingDetailMetrics` SNP, or an fgumi or fgbio `ConsensusVariantReviewInfo` variant, converted from the 1-based coordinates it holds; a `RrbsCpgDetailMetrics` by the C of its CpG on the forward strand | | `bedspec.ext.cyvcf2` | `as_interval` | a `Variant` from its start to its end, so a symbolic allele to its `END` | | `bedspec.ext.fgpyo` | `as_interval` | a `SupplementaryAlignment` from an `SA` tag, on its strand | | | `as_template_intervals` | a `Template` by the intervals of its primary and supplementary records, each on its strand | From 7b8aa9dc75aa3e929ed00d27c1ea457d63e6dbda Mon Sep 17 00:00:00 2001 From: clintval Date: Tue, 27 Oct 2026 10:26:20 -1000 Subject: [PATCH 3/4] chore(release): bump to 4.4.0 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index edd5288..06a1093 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "bedspec" -version = "4.3.0" +version = "4.4.0" description = "An HTS-specs compliant BED toolkit." authors = [{ name = "Clint Valentine", email = "valentine.clint@gmail.com" }] license = "MIT" diff --git a/uv.lock b/uv.lock index bfe30ef..c10aead 100644 --- a/uv.lock +++ b/uv.lock @@ -114,7 +114,7 @@ wheels = [ [[package]] name = "bedspec" -version = "4.3.0" +version = "4.4.0" source = { editable = "." } dependencies = [ { name = "pybgzf" }, From 213f3e24fe989d72a91a92a93788c41fecb72fe7 Mon Sep 17 00:00:00 2001 From: clintval Date: Tue, 27 Oct 2026 10:31:51 -1000 Subject: [PATCH 4/4] build(deps): require captainslog-ngs 0.2.0 for its fgumi and fgbio modules --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 06a1093..3d1f769 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ classifiers = [ [project.optional-dependencies] biopython = ["biopython >=1.88"] -captainslog = ["captainslog-ngs >=0.1.0"] +captainslog = ["captainslog-ngs >=0.2.0"] cyvcf2 = ["cyvcf2 >=0.34"] fgpyo = ["fgpyo >=1.5.1"] mappy = ["mappy >=2.31"] diff --git a/uv.lock b/uv.lock index c10aead..32318f7 100644 --- a/uv.lock +++ b/uv.lock @@ -184,7 +184,7 @@ typing = [ [package.metadata] requires-dist = [ { name = "biopython", marker = "extra == 'biopython'", specifier = ">=1.88" }, - { name = "captainslog-ngs", marker = "extra == 'captainslog'", specifier = ">=0.1.0" }, + { name = "captainslog-ngs", marker = "extra == 'captainslog'", specifier = ">=0.2.0" }, { name = "cyvcf2", marker = "extra == 'cyvcf2'", specifier = ">=0.34" }, { name = "fgpyo", marker = "extra == 'fgpyo'", specifier = ">=1.5.1" }, { name = "mappy", marker = "extra == 'mappy'", specifier = ">=2.31" }, @@ -277,15 +277,15 @@ wheels = [ [[package]] name = "captainslog-ngs" -version = "0.1.0" +version = "0.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typeline" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5c/7b/fab4b26893570c8d639350f4ac24368b93b0b8bb25ee10cd0d7763321fd7/captainslog_ngs-0.1.0.tar.gz", hash = "sha256:573002b4b61d4a9f9f86b0bb26663ee491c220b6ca8765d0e6e39cf18a63169e", size = 201569, upload-time = "2026-10-27T01:19:19.362Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/c2/8e6e732ca525d2312e400db76f788a9a07f5a301527bdb9539f31bcc06b5/captainslog_ngs-0.2.0.tar.gz", hash = "sha256:f1267fd36d980f7006f3beac7b5571fad0c0afac3b0a91c85b89e599c7faf2f0", size = 256631, upload-time = "2026-10-27T20:29:13.783Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/35/c4/a35120ba1c81a3e721a91c5946d5345c0c5f2681039ade88358c1852bd39/captainslog_ngs-0.1.0-py3-none-any.whl", hash = "sha256:733d97df94c49f014f26a89664c504acfdf8c6d38792e9b2d79d916c7ae3863c", size = 76327, upload-time = "2026-10-27T01:19:17.918Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c0/4d53e2be58959805c179e6baf572d7d13cf3022f6d3bd238f8de6b79f639/captainslog_ngs-0.2.0-py3-none-any.whl", hash = "sha256:a186506e7b09244950cb7e3c08596e11881e3316fb9bf0f84f0cb52e520ab71e", size = 98726, upload-time = "2026-10-27T20:29:12.564Z" }, ] [[package]]