Skill Tracer
Skills › Scientific Agent Skills › pysam › common_workflows.md

common_workflows.md

472 lines · skills/pysam/references/common_workflows.md at 5f2bc03

Open on GitHub

Most marks come from matching words in the file, so treat them as pointers.

1# Correct Pysam Workflow Patterns
2
3These patterns target pysam 0.24.1 and make filtering and coordinate semantics
4explicit. Adapt thresholds to the assay rather than treating them as universal
5defaults.
6

Correct Pysam Workflow Patterns

These patterns target pysam 0.24.1 and make filtering and coordinate semantics explicit. Adapt thresholds to the assay rather than treating them as universal defaults.

Raw lines 1 to 6
7## Preflight an Analysis
8
9Before combining files, verify:
111. Reference assembly and contig naming agree (`chr1` versus `1`).
122. Numeric coordinates use 0-based, half-open intervals.
133. Alignment and variant inputs are sorted as expected.
144. Random-access inputs have valid indexes.
155. CRAM has the exact reference FASTA available.
166. Read-group/sample metadata identifies the intended samples.
177. Duplicate, secondary, supplementary, QC-fail, and quality policies are
18 stated.
20Use the bundled inspectors:
22```bash
23python scripts/inspect_hts.py sample.bam
24python scripts/inspect_hts.py cohort.vcf.gz
25python scripts/inspect_hts.py reference.fa
26```

Preflight an Analysis

Before combining files, verify:

  1. Reference assembly and contig naming agree (chr1 versus 1).
  2. Numeric coordinates use 0-based, half-open intervals.
  3. Alignment and variant inputs are sorted as expected.
  4. Random-access inputs have valid indexes.
  5. CRAM has the exact reference FASTA available.
  6. Read-group/sample metadata identifies the intended samples.
  7. Duplicate, secondary, supplementary, QC-fail, and quality policies are stated.

Use the bundled inspectors:

python scripts/inspect_hts.py sample.bam
python scripts/inspect_hts.py cohort.vcf.gz
python scripts/inspect_hts.py reference.fa
Raw lines 7 to 27
Runs code python scripts/inspect_hts.py :23
28## Streaming Alignment QC
30The bundled script scans in file order and does not require an index:
32```bash
33python scripts/alignment_qc.py sample.bam --output sample.qc.json
34python scripts/alignment_qc.py sample.cram \
35 --reference reference.fa \
36 --max-records 100000
37```
39The report counts alignment records, not unique templates or fragments.
40Secondary and supplementary records are reported separately. Use
41`--max-records` for a sampling pass; omit it for a complete scan.
43For index-level counts without reading every record:
45```python
46import pysam
48with pysam.AlignmentFile("sample.bam", "rb", require_index=True) as bam:
49 for stats in bam.get_index_statistics():
50 print(stats.contig, stats.mapped, stats.unmapped, stats.total)
51```
53Index statistics are fast but cannot replace custom record-level QC. CRAI has
54no record counts: the CRAM index-statistic zeros are unavailable data.
55The QC script counts mapped non-soft-clipped query bases, including insertions,
56only when CIGAR and SEQ exist. Missing SEQ does not contribute to mean length.

Streaming Alignment QC

The bundled script scans in file order and does not require an index:

python scripts/alignment_qc.py sample.bam --output sample.qc.json
python scripts/alignment_qc.py sample.cram \
  --reference reference.fa \
  --max-records 100000

The report counts alignment records, not unique templates or fragments. Secondary and supplementary records are reported separately. Use --max-records for a sampling pass; omit it for a complete scan.

For index-level counts without reading every record:

import pysam

with pysam.AlignmentFile("sample.bam", "rb", require_index=True) as bam:
    for stats in bam.get_index_statistics():
        print(stats.contig, stats.mapped, stats.unmapped, stats.total)

Index statistics are fast but cannot replace custom record-level QC. CRAI has no record counts: the CRAM index-statistic zeros are unavailable data. The QC script counts mapped non-soft-clipped query bases, including insertions, only when CIGAR and SEQ exist. Missing SEQ does not contribute to mean length.

Raw lines 28 to 57
Runs code python scripts/alignment_qc.py :33
58## Zero-Aware Coverage
60`pileup()` omits positions with no columns. Use `count_coverage()` when zeros
61must be represented:
63```python
64import pysam
67def base_depth(
68 bam: pysam.AlignmentFile,
69 contig: str,
70 start: int,
71 stop: int,
72 *,
73 min_base_quality: int = 20,
74) -> list[int]:
75 if not 0 <= start < stop <= bam.get_reference_length(contig):
76 raise ValueError("coverage interval must be nonempty and within the contig")
77 a, c, g, t = bam.count_coverage(
78 contig,
79 start,
80 stop,
81 quality_threshold=min_base_quality,
82 read_callback="all",
83 )
84 return [sum(counts) for counts in zip(a, c, g, t)]
87with pysam.AlignmentFile("sample.bam", "rb") as bam:
88 depth = base_depth(bam, "chr1", 1_000, 2_000)
89```
91`read_callback="all"` excludes unmapped, secondary, QC-fail, and duplicate
92records, but not supplementary records. Use a custom callback when
93supplementary or low-MAPQ reads must also be excluded:
95```python
96def usable_read(read: pysam.AlignedSegment) -> bool:
97 return (
98 not read.is_unmapped
99 and not read.is_secondary
100 and not read.is_supplementary
101 and not read.is_qcfail
102 and not read.is_duplicate
103 and read.mapping_quality >= 20
104 )
107a, c, g, t = bam.count_coverage(
108 "chr1",
109 1_000,
110 2_000,
111 quality_threshold=20,
112 read_callback=usable_read,
114```
116Only A/C/G/T query bases contribute. Overlapping mates count separately.
117`count_coverage` does not use pileup BAQ/overlap correction; pass numeric span
118arguments because 0.24.1 does not size arrays from `region=`/`end=` aliases.

Zero-Aware Coverage

pileup() omits positions with no columns. Use count_coverage() when zeros must be represented:

import pysam


def base_depth(
    bam: pysam.AlignmentFile,
    contig: str,
    start: int,
    stop: int,
    *,
    min_base_quality: int = 20,
) -> list[int]:
    if not 0 <= start < stop <= bam.get_reference_length(contig):
        raise ValueError("coverage interval must be nonempty and within the contig")
    a, c, g, t = bam.count_coverage(
        contig,
        start,
        stop,
        quality_threshold=min_base_quality,
        read_callback="all",
    )
    return [sum(counts) for counts in zip(a, c, g, t)]


with pysam.AlignmentFile("sample.bam", "rb") as bam:
    depth = base_depth(bam, "chr1", 1_000, 2_000)

read_callback="all" excludes unmapped, secondary, QC-fail, and duplicate records, but not supplementary records. Use a custom callback when supplementary or low-MAPQ reads must also be excluded:

def usable_read(read: pysam.AlignedSegment) -> bool:
    return (
        not read.is_unmapped
        and not read.is_secondary
        and not read.is_supplementary
        and not read.is_qcfail
        and not read.is_duplicate
        and read.mapping_quality >= 20
    )


a, c, g, t = bam.count_coverage(
    "chr1",
    1_000,
    2_000,
    quality_threshold=20,
    read_callback=usable_read,
)

Only A/C/G/T query bases contribute. Overlapping mates count separately. count_coverage does not use pileup BAQ/overlap correction; pass numeric span arguments because 0.24.1 does not size arrays from region=/end= aliases.

Raw lines 58 to 119
120### Convert low-depth bases into intervals
122```python
123def below_threshold_intervals(
124 depths: list[int],
125 start: int,
126 threshold: int,
127):
128 interval_start = None
130 for offset, value in enumerate(depths):
131 position = start + offset
132 if value < threshold and interval_start is None:
133 interval_start = position
134 elif value >= threshold and interval_start is not None:
135 yield interval_start, position
136 interval_start = None
138 if interval_start is not None:
139 yield interval_start, start + len(depths)
140```
142The yielded intervals remain 0-based, half-open and correctly include regions
143with no aligned columns.

Convert low-depth bases into intervals

def below_threshold_intervals(
    depths: list[int],
    start: int,
    threshold: int,
):
    interval_start = None

    for offset, value in enumerate(depths):
        position = start + offset
        if value < threshold and interval_start is None:
            interval_start = position
        elif value >= threshold and interval_start is not None:
            yield interval_start, position
            interval_start = None

    if interval_start is not None:
        yield interval_start, start + len(depths)

The yielded intervals remain 0-based, half-open and correctly include regions with no aligned columns.

Raw lines 120 to 144
145## Exact Pileup at a Position
147Use explicit pileup options and retain the iterator:
149```python
150def aligned_depth_at(
151 bam: pysam.AlignmentFile,
152 fasta: pysam.FastaFile,
153 contig: str,
154 position: int,
155) -> int:
156 iterator = bam.pileup(
157 contig,
158 position,
159 position + 1,
160 truncate=True,
161 stepper="samtools",
162 fastafile=fasta,
163 min_mapping_quality=20,
164 min_base_quality=20,
165 max_depth=100_000,
166 ignore_overlaps=True,
167 ignore_orphans=True,
168 )
169 for column in iterator:
170 if column.reference_pos == position:
171 return sum(
172 not item.is_del and not item.is_refskip
173 and item.query_position is not None
174 for item in column.pileups
175 )
176 return 0
177```
179`get_num_aligned()` includes D/N entries in 0.24.1, so this helper deliberately
180counts observed bases. BAQ is enabled when the reference is supplied. MAPQ255
181is retained by the numeric threshold; apply an explicit unavailable-MAPQ
182policy if required. This definition is not identical to VCF `INFO/DP` from a caller. Name custom
183annotations so their provenance and filters remain clear.

Exact Pileup at a Position

Use explicit pileup options and retain the iterator:

def aligned_depth_at(
    bam: pysam.AlignmentFile,
    fasta: pysam.FastaFile,
    contig: str,
    position: int,
) -> int:
    iterator = bam.pileup(
        contig,
        position,
        position + 1,
        truncate=True,
        stepper="samtools",
        fastafile=fasta,
        min_mapping_quality=20,
        min_base_quality=20,
        max_depth=100_000,
        ignore_overlaps=True,
        ignore_orphans=True,
    )
    for column in iterator:
        if column.reference_pos == position:
            return sum(
                not item.is_del and not item.is_refskip
                and item.query_position is not None
                for item in column.pileups
            )
    return 0

get_num_aligned() includes D/N entries in 0.24.1, so this helper deliberately counts observed bases. BAQ is enabled when the reference is supplied. MAPQ255 is retained by the numeric threshold; apply an explicit unavailable-MAPQ policy if required. This definition is not identical to VCF INFO/DP from a caller. Name custom annotations so their provenance and filters remain clear.

Raw lines 145 to 184
185## SNP Base Support
187This helper is deliberately limited to single-nucleotide REF/ALT alleles:
189```python
190from collections import Counter
193def snp_base_counts(
194 bam: pysam.AlignmentFile,
195 fasta: pysam.FastaFile,
196 record: pysam.VariantRecord,
197) -> Counter[str]:
198 if (
199 record.ref.upper() not in {"A", "C", "G", "T"}
200 or not record.alts
201 or any(alt.upper() not in {"A", "C", "G", "T"} for alt in record.alts)
202 or any(alt.upper() == record.ref.upper() for alt in record.alts)
203 ):
204 raise ValueError("snp_base_counts only supports simple SNP records")
206 # Validate REF without inferring a variant from a mismatched assembly.
207 observed_ref = fasta.fetch(record.contig, record.start, record.start + 1)
208 if observed_ref.upper() != record.ref.upper():
209 raise ValueError("VCF REF does not match the selected FASTA")
210 counts: Counter[str] = Counter()
211 iterator = bam.pileup(
212 record.contig,
213 record.start,
214 record.start + 1,
215 truncate=True,
216 stepper="samtools",
217 fastafile=fasta,
218 min_mapping_quality=20,
219 min_base_quality=20,
220 max_depth=100_000,
221 ignore_overlaps=True,
222 ignore_orphans=True,
223 )
225 for column in iterator:
226 for pileup_read in column.pileups:
227 if pileup_read.is_del or pileup_read.is_refskip:
228 continue
229 query_position = pileup_read.query_position
230 if query_position is None:
231 continue
232 base = pileup_read.alignment.query_sequence[query_position]
233 counts[base.upper()] += 1
234 return counts
235```
237For indels, inspect `PileupRead.indel`, CIGAR, and normalized alleles or use a
238dedicated variant caller. This SNP method must not be generalized to symbolic
239or breakend alleles.

SNP Base Support

This helper is deliberately limited to single-nucleotide REF/ALT alleles:

from collections import Counter


def snp_base_counts(
    bam: pysam.AlignmentFile,
    fasta: pysam.FastaFile,
    record: pysam.VariantRecord,
) -> Counter[str]:
    if (
        record.ref.upper() not in {"A", "C", "G", "T"}
        or not record.alts
        or any(alt.upper() not in {"A", "C", "G", "T"} for alt in record.alts)
        or any(alt.upper() == record.ref.upper() for alt in record.alts)
    ):
        raise ValueError("snp_base_counts only supports simple SNP records")

    # Validate REF without inferring a variant from a mismatched assembly.
    observed_ref = fasta.fetch(record.contig, record.start, record.start + 1)
    if observed_ref.upper() != record.ref.upper():
        raise ValueError("VCF REF does not match the selected FASTA")
    counts: Counter[str] = Counter()
    iterator = bam.pileup(
        record.contig,
        record.start,
        record.start + 1,
        truncate=True,
        stepper="samtools",
        fastafile=fasta,
        min_mapping_quality=20,
        min_base_quality=20,
        max_depth=100_000,
        ignore_overlaps=True,
        ignore_orphans=True,
    )

    for column in iterator:
        for pileup_read in column.pileups:
            if pileup_read.is_del or pileup_read.is_refskip:
                continue
            query_position = pileup_read.query_position
            if query_position is None:
                continue
            base = pileup_read.alignment.query_sequence[query_position]
            counts[base.upper()] += 1
    return counts

For indels, inspect PileupRead.indel, CIGAR, and normalized alleles or use a dedicated variant caller. This SNP method must not be generalized to symbolic or breakend alleles.

Raw lines 185 to 240
241## Annotate a VCF with BAM-Derived Depth
243Copy the header, declare a new field, translate copied records, and use
244`record.start` directly:
246```python
247import pysam
250def annotate_depth(
251 input_vcf: str,
252 input_bam: str,
253 reference_fasta: str,
254 output_vcf: str,
255) -> None:
256 from pathlib import Path
258 if Path(output_vcf).exists():
259 raise FileExistsError(output_vcf)
260 with pysam.FastaFile(reference_fasta) as fasta, pysam.AlignmentFile(
261 input_bam,
262 "rb",
263 reference_filename=reference_fasta,
264 ) as bam, pysam.VariantFile(input_vcf) as source:
265 header = source.header.copy()
266 if "BAM_BASE_DP" in header.info:
267 raise ValueError("BAM_BASE_DP already exists in input header")
268 header.info.add(
269 "BAM_BASE_DP",
270 number=1,
271 type="Integer",
272 description=(
273 "Aligned base depth at POS; MAPQ>=20, baseQ>=20, "
274 "samtools pileup filters, paired overlaps collapsed"
275 ),
276 )
278 with pysam.VariantFile(output_vcf, "w", header=header) as output:
279 for source_record in source:
280 record = source_record.copy()
281 record.translate(header)
282 record.info["BAM_BASE_DP"] = aligned_depth_at(
283 bam,
284 fasta,
285 record.contig,
286 record.start,
287 )
288 output.write(record)
289```
291For CRAM input, `reference_filename` is essential. For a large unindexed VCF,
292this pattern can issue many BAM seeks; process by contig or sorted windows to
293improve locality.

Annotate a VCF with BAM-Derived Depth

Copy the header, declare a new field, translate copied records, and use record.start directly:

import pysam


def annotate_depth(
    input_vcf: str,
    input_bam: str,
    reference_fasta: str,
    output_vcf: str,
) -> None:
    from pathlib import Path

    if Path(output_vcf).exists():
        raise FileExistsError(output_vcf)
    with pysam.FastaFile(reference_fasta) as fasta, pysam.AlignmentFile(
        input_bam,
        "rb",
        reference_filename=reference_fasta,
    ) as bam, pysam.VariantFile(input_vcf) as source:
        header = source.header.copy()
        if "BAM_BASE_DP" in header.info:
            raise ValueError("BAM_BASE_DP already exists in input header")
        header.info.add(
            "BAM_BASE_DP",
            number=1,
            type="Integer",
            description=(
                "Aligned base depth at POS; MAPQ>=20, baseQ>=20, "
                "samtools pileup filters, paired overlaps collapsed"
            ),
        )

        with pysam.VariantFile(output_vcf, "w", header=header) as output:
            for source_record in source:
                record = source_record.copy()
                record.translate(header)
                record.info["BAM_BASE_DP"] = aligned_depth_at(
                    bam,
                    fasta,
                    record.contig,
                    record.start,
                )
                output.write(record)

For CRAM input, reference_filename is essential. For a large unindexed VCF, this pattern can issue many BAM seeks; process by contig or sorted windows to improve locality.

Raw lines 241 to 294
295## Validate VCF REF Alleles Against FASTA
297```python
298def reference_matches(
299 record: pysam.VariantRecord,
300 fasta: pysam.FastaFile,
301) -> bool:
302 observed = fasta.fetch(
303 record.contig,
304 record.start,
305 record.start + len(record.ref),
306 )
307 return observed.upper() == record.ref.upper()
310with pysam.FastaFile("reference.fa") as fasta, pysam.VariantFile(
311 "variants.vcf.gz"
312) as variants:
313 mismatches = [
314 (record.contig, record.pos, record.ref)
315 for record in variants
316 if not reference_matches(record, fasta)
317 ]
318```
320Do not "fix" mismatches automatically. Investigate assembly version, contig
321aliases, left normalization, and strand/representation errors.

Validate VCF REF Alleles Against FASTA

def reference_matches(
    record: pysam.VariantRecord,
    fasta: pysam.FastaFile,
) -> bool:
    observed = fasta.fetch(
        record.contig,
        record.start,
        record.start + len(record.ref),
    )
    return observed.upper() == record.ref.upper()


with pysam.FastaFile("reference.fa") as fasta, pysam.VariantFile(
    "variants.vcf.gz"
) as variants:
    mismatches = [
        (record.contig, record.pos, record.ref)
        for record in variants
        if not reference_matches(record, fasta)
    ]

Do not "fix" mismatches automatically. Investigate assembly version, contig aliases, left normalization, and strand/representation errors.

Raw lines 295 to 322
323## Filter an Alignment File
325Use the bundled script for common primary-read filtering:
327```bash
328python scripts/alignment_qc.py input.bam --max-records 10000
329python scripts/filter_alignments.py input.bam filtered.bam \
330 --min-mapq 20 --exclude-unmapped --exclude-secondary \
331 --exclude-qcfail --exclude-duplicates --exclude-supplementary --index
332```
334If implementing custom filtering, preserve the source header and stream
335records:
337```python
338with pysam.AlignmentFile("input.bam", "rb") as source, pysam.AlignmentFile(
339 "filtered.bam", "wb", template=source
340) as output:
341 for read in source.fetch(until_eof=True):
342 if usable_read(read):
343 output.write(read)
344```
346Filtering preserves input order; it does not sort. Index only coordinate-sorted
347output. If the header's sort-order declaration is wrong, fix the workflow
348rather than trusting it. Use `--index --csi` for BAM beyond BAI limits.
349Per-record filtering preserves RG/reference dictionaries and mate fields, but
350can remove one mate without fetching or repairing its partner.

Filter an Alignment File

Use the bundled script for common primary-read filtering:

python scripts/alignment_qc.py input.bam --max-records 10000
python scripts/filter_alignments.py input.bam filtered.bam \
  --min-mapq 20 --exclude-unmapped --exclude-secondary \
  --exclude-qcfail --exclude-duplicates --exclude-supplementary --index

If implementing custom filtering, preserve the source header and stream records:

with pysam.AlignmentFile("input.bam", "rb") as source, pysam.AlignmentFile(
    "filtered.bam", "wb", template=source
) as output:
    for read in source.fetch(until_eof=True):
        if usable_read(read):
            output.write(read)

Filtering preserves input order; it does not sort. Index only coordinate-sorted output. If the header's sort-order declaration is wrong, fix the workflow rather than trusting it. Use --index --csi for BAM beyond BAI limits. Per-record filtering preserves RG/reference dictionaries and mate fields, but can remove one mate without fetching or repairing its partner.

Raw lines 323 to 351
Runs code python scripts/alignment_qc.py :328
Runs code python scripts/filter_alignments.py :329
352## Extract Strand-Aware BED Sequences
354```python
355with pysam.TabixFile(
356 "genes.bed.gz", parser=pysam.asBed()
357) as genes, pysam.FastaFile("reference.fa") as fasta, open(
358 "genes.fa", "x", encoding="utf-8"
359) as output:
360 for gene in genes.fetch():
361 sequence = fasta.fetch(gene.contig, gene.start, gene.end)
362 if gene.strand == "-":
363 sequence = pysam.reverse_complement(sequence)
364 output.write(f">{gene.name}\n{sequence}\n")
365```
367This requires BED6 with a strand and extracts the full span, not spliced BED12
368exons. Validate FASTA bounds and strand before extraction. BED parser
369coordinates are already 0-based. Sanitize or encode record names if
370they will be consumed by strict downstream FASTA parsers.

Extract Strand-Aware BED Sequences

with pysam.TabixFile(
    "genes.bed.gz", parser=pysam.asBed()
) as genes, pysam.FastaFile("reference.fa") as fasta, open(
    "genes.fa", "x", encoding="utf-8"
) as output:
    for gene in genes.fetch():
        sequence = fasta.fetch(gene.contig, gene.start, gene.end)
        if gene.strand == "-":
            sequence = pysam.reverse_complement(sequence)
        output.write(f">{gene.name}\n{sequence}\n")

This requires BED6 with a strand and extracts the full span, not spliced BED12 exons. Validate FASTA bounds and strand before extraction. BED parser coordinates are already 0-based. Sanitize or encode record names if they will be consumed by strict downstream FASTA parsers.

Raw lines 352 to 371
372## Count RNA Splice Junctions
374`find_introns()` counts `N` CIGAR operations:
376```python
377with pysam.AlignmentFile("rna.bam", "rb") as bam:
378 primary_reads = (
379 read
380 for read in bam.fetch("chr1")
381 if not read.is_unmapped
382 and not read.is_qcfail
383 and not read.is_secondary
384 and not read.is_supplementary
385 and not read.is_duplicate
386 and read.mapping_quality >= 20
387 )
388 junction_counts = bam.find_introns(primary_reads)
389```
391Keys are `(start, stop)` 0-based splice intervals. Filter strand and library
392orientation according to the assay.

Count RNA Splice Junctions

find_introns() counts N CIGAR operations:

with pysam.AlignmentFile("rna.bam", "rb") as bam:
    primary_reads = (
        read
        for read in bam.fetch("chr1")
        if not read.is_unmapped
        and not read.is_qcfail
        and not read.is_secondary
        and not read.is_supplementary
        and not read.is_duplicate
        and read.mapping_quality >= 20
    )
    junction_counts = bam.find_introns(primary_reads)

Keys are (start, stop) 0-based splice intervals. Filter strand and library orientation according to the assay.

Raw lines 372 to 393
394## Bulk Operations via Wrapped Tools
396For sort/index and normalization, mature command implementations are usually
397preferable to Python record loops:
399```python
400import pysam.samtools
401import pysam.bcftools
403pysam.samtools.sort(
404 "-@", "4",
405 "-o", "sorted.bam",
406 "input.bam",
407 catch_stdout=False,
409pysam.samtools.index(
410 "-@", "4",
411 "sorted.bam",
412 catch_stdout=False,
415pysam.bcftools.norm(
416 "-f", "reference.fa",
417 "-m", "-any",
418 "--multi-overlaps", ".", # other ALT alleles become missing after splitting
419 "-Oz",
420 "-o", "normalized.vcf.gz",
421 "input.vcf.gz",
422 catch_stdout=False,
424pysam.bcftools.index(
425 "--csi",
426 "normalized.vcf.gz",
427 catch_stdout=False,
429```
431Splitting changes genotype/allele indexing: the explicit missing policy avoids
432turning another ALT into a reference call. It does not preserve the original
433multiallelic haplotype representation. Keep the source and validate GT/AD/PL
434semantics before interpreting normalized calls.
436Pass each argument as its own string. Do not split or evaluate an untrusted
437shell command. Use `catch_stdout=False` when `-o` writes large or binary data.

Bulk Operations via Wrapped Tools

For sort/index and normalization, mature command implementations are usually preferable to Python record loops:

import pysam.samtools
import pysam.bcftools

pysam.samtools.sort(
    "-@", "4",
    "-o", "sorted.bam",
    "input.bam",
    catch_stdout=False,
)
pysam.samtools.index(
    "-@", "4",
    "sorted.bam",
    catch_stdout=False,
)

pysam.bcftools.norm(
    "-f", "reference.fa",
    "-m", "-any",
    "--multi-overlaps", ".",  # other ALT alleles become missing after splitting
    "-Oz",
    "-o", "normalized.vcf.gz",
    "input.vcf.gz",
    catch_stdout=False,
)
pysam.bcftools.index(
    "--csi",
    "normalized.vcf.gz",
    catch_stdout=False,
)

Splitting changes genotype/allele indexing: the explicit missing policy avoids turning another ALT into a reference call. It does not preserve the original multiallelic haplotype representation. Keep the source and validate GT/AD/PL semantics before interpreting normalized calls.

Pass each argument as its own string. Do not split or evaluate an untrusted shell command. Use catch_stdout=False when -o writes large or binary data.

Raw lines 394 to 438
439## Do Not Hand-Roll Complex VCF Merges
441Combining records by `(contig, pos, ref, alts)` is insufficient because inputs
442can differ in:
444- allele normalization and multiallelic decomposition
445- contig order and metadata
446- INFO/FORMAT Number and Type definitions
447- FILTER definitions
448- sample names and ploidy
449- duplicate positions and phasing
451Normalize and validate inputs, then use `bcftools merge` for samples or
452`bcftools concat` for disjoint genomic partitions as appropriate.

Do Not Hand-Roll Complex VCF Merges

Combining records by (contig, pos, ref, alts) is insufficient because inputs can differ in:

  • allele normalization and multiallelic decomposition
  • contig order and metadata
  • INFO/FORMAT Number and Type definitions
  • FILTER definitions
  • sample names and ploidy
  • duplicate positions and phasing

Normalize and validate inputs, then use bcftools merge for samples or bcftools concat for disjoint genomic partitions as appropriate.

Raw lines 439 to 453
454## Output Validation
456Alignment output:
458```python
459pysam.samtools.quickcheck("-v", "filtered.bam")
460```
462Variant output:
464```python
465with pysam.VariantFile("annotated.vcf.gz") as variants:
466 assert "BAM_BASE_DP" in variants.header.info
467```
469Then index final sorted output and fetch known intervals at contig starts,
470interval boundaries, and high-coordinate regions. Format validity does not
471prove biological validity; compare counts and selected records to an
472independent tool when results matter.

Output Validation

Alignment output:

pysam.samtools.quickcheck("-v", "filtered.bam")

Variant output:

with pysam.VariantFile("annotated.vcf.gz") as variants:
    assert "BAM_BASE_DP" in variants.header.info

Then index final sorted output and fetch known intervals at contig starts, interval boundaries, and high-coordinate regions. Format validity does not prove biological validity; compare counts and selected records to an independent tool when results matter.

Raw lines 454 to 472