Source code for finaletoolkit.utils._filter_file

"""
Filter a BAM/CRAM or tabix BED fragment file by quality, length, and region.

BAM/CRAM filtering shells out to bedtools/samtools; tabix BED filtering streams
lines and shells out to bedtools/bgzip/tabix.
"""
from __future__ import annotations

import gzip
import logging
import subprocess
import tempfile as tf
import warnings

import pysam

__all__ = ["filter_file"]

_VALID_SUFFIXES = (".gz", ".bam", ".cram", ".bgz")


def validate_deprecated_args(old_arg, new_arg, old_name, new_name):
    """Resolve a deprecated alias against its replacement argument."""
    if old_arg is not None:
        warnings.warn(
            f"{old_name} is deprecated. Use {new_name} instead.",
            category=DeprecationWarning,
            stacklevel=2,
        )
        if new_arg is None:
            return old_arg
        raise ValueError(
            f"{old_name} and {new_name} cannot both be specified."
        )
    return new_arg


def validate_input_file(input_file: str) -> str:
    """Return the recognized suffix of ``input_file`` or raise ``ValueError``."""
    if not any(input_file.endswith(suffix) for suffix in _VALID_SUFFIXES):
        raise ValueError(
            "Input file should have one of the following suffixes: "
            f"{', '.join(_VALID_SUFFIXES)}"
        )
    return next(suffix for suffix in _VALID_SUFFIXES if input_file.endswith(suffix))


def run_subprocess(
    cmd: str,
    error_msg: str = "Command failed",
    verbose: bool = False,
    logger=None,
) -> None:
    """Run a shell command, logging and re-raising on failure."""
    try:
        if verbose:
            logger.info(f"Running: {cmd}")
        subprocess.run(cmd, shell=True, check=True)
    except subprocess.CalledProcessError as e:
        logger.error(f"{error_msg}: {str(e)}")
        raise


def filter_bed_entries(
    infile, min_length=None, max_length=None, quality_threshold=30
):
    """Yield BED lines passing the length and MAPQ filters."""

    def get_mapq_col(length):
        if length < 5:
            raise ValueError(
                "There are not enough columns in the BED file to determine "
                "the MAPQ column"
            )
        return 3 if length == 5 else 4

    for line in infile:
        if line.startswith("#"):
            continue

        parts = line.strip().split("\t")
        mapq_column = get_mapq_col(len(parts))

        try:
            start = int(parts[1])
            end = int(parts[2])
            length = end - start
            score = float(parts[mapq_column])

            if (
                (min_length is None or length >= min_length)
                and (max_length is None or length <= max_length)
                and score >= quality_threshold
            ):
                yield line
        except (ValueError, IndexError):
            continue


[docs] def filter_file( input_file: str, whitelist_file: str | None = None, blacklist_file: str | None = None, output_file: str | None = None, min_length: int | None = None, max_length: int | None = None, intersect_policy: str = "midpoint", quality_threshold: int = 30, workers: int = 1, verbose: bool = False, fraction_low: int | None = None, fraction_high: int | None = None, ) -> str: """Create a filtered copy of a BAM/CRAM or tabix BED fragment file. Reads/intervals are kept when they exceed ``quality_threshold``, fall within the length bounds, and (for whitelist/blacklist BEDs) intersect per ``intersect_policy``. BAM/CRAM additionally keeps only read1 of proper pairs. Parameters ---------- input_file : str Input BAM/CRAM/`.gz`/`.bgz` path. whitelist_file : str, optional BED of regions to keep. blacklist_file : str, optional BED of regions to exclude. output_file : str, optional Output path (``"-"`` for stdout). min_length, max_length : int, optional Fragment-length bounds. intersect_policy : {"midpoint", "any"}, optional Whitelist/blacklist intersection policy (default ``"midpoint"``). quality_threshold : int, optional Minimum mapping quality (default 30). workers : int, optional Threads for samtools/bgzip (default 1). verbose : bool, optional Print configuration and enable logging. fraction_low, fraction_high : int, optional Deprecated aliases for ``min_length``/``max_length``. Returns ------- str The output path. """ logger = logging.getLogger(__name__) if verbose: print( f""" input_file: {input_file} whitelist_file: {whitelist_file} blacklist_file: {blacklist_file} output_file: {output_file} min_length: {min_length} max_length: {max_length} intersect_policy: {intersect_policy} quality_threshold: {quality_threshold} workers: {workers} verbose: {verbose} """ ) logging.basicConfig(level=logging.INFO) min_length = validate_deprecated_args( fraction_low, min_length, "fraction_low", "min_length" ) max_length = validate_deprecated_args( fraction_high, max_length, "fraction_high", "max_length" ) suffix = validate_input_file(input_file) if intersect_policy == "midpoint": intersect_param = "-f 0.500" elif intersect_policy == "any": intersect_param = "" else: raise ValueError("intersect_policy must be 'midpoint' or 'any'") pysam.set_verbosity(pysam.set_verbosity(0)) with tf.TemporaryDirectory() as temp_dir: temp_1 = f"{temp_dir}/output1{suffix}" temp_2 = f"{temp_dir}/output2{suffix}" temp_3 = f"{temp_dir}/output3{suffix}" if input_file.endswith((".bam", ".cram")): _filter_alignment( input_file, whitelist_file, blacklist_file, output_file, min_length, max_length, intersect_policy, intersect_param, quality_threshold, workers, verbose, logger, temp_1, temp_2, temp_3, ) elif input_file.endswith((".gz", ".bgz")): _filter_bed( input_file, whitelist_file, blacklist_file, output_file, min_length, max_length, intersect_policy, quality_threshold, workers, verbose, logger, temp_1, temp_2, temp_3, ) return output_file
def _filter_alignment( input_file, whitelist_file, blacklist_file, output_file, min_length, max_length, intersect_policy, intersect_param, quality_threshold, workers, verbose, logger, temp_1, temp_2, temp_3, ) -> None: """Filter a BAM/CRAM via bedtools/samtools and a length pass.""" if whitelist_file: run_subprocess( f"bedtools intersect -abam {input_file} -b {whitelist_file} " f"{intersect_param} > {temp_1} && samtools index {temp_1}", error_msg="Whitelist filtering failed", verbose=verbose, logger=logger, ) else: run_subprocess(f"cp {input_file} {temp_1}", verbose=verbose, logger=logger) if blacklist_file: intersect_param = "-f 0.500" if intersect_policy == "midpoint" else "" run_subprocess( f"bedtools intersect -abam {temp_1} -b {blacklist_file} -v " f"{intersect_param} > {temp_2} && samtools index {temp_2}", error_msg="Blacklist filtering failed", verbose=verbose, logger=logger, ) else: run_subprocess(f"mv {temp_1} {temp_2}", verbose=verbose, logger=logger) run_subprocess( f"samtools view {temp_2} -F 3852 -f 3 -b -h -o {temp_3} " f"-q {quality_threshold} -@ {workers}", error_msg="Quality filtering failed", verbose=verbose, logger=logger, ) # Length filter (template length, same reference for both mates). pysam.set_verbosity(0) with pysam.AlignmentFile(temp_3, "rb", threads=workers // 3) as in_file: with pysam.AlignmentFile( output_file, "wb", template=in_file, threads=workers - workers // 3 ) as out_file: for read in in_file: if ( read.reference_name == read.next_reference_name and (max_length is None or read.template_length <= max_length) and (min_length is None or read.template_length >= min_length) ): out_file.write(read) if output_file != "-": run_subprocess( f"samtools index {output_file}", error_msg="Index creation failed", verbose=verbose, logger=logger, ) def _filter_bed( input_file, whitelist_file, blacklist_file, output_file, min_length, max_length, intersect_policy, quality_threshold, workers, verbose, logger, temp_1, temp_2, temp_3, ) -> None: """Filter a tabix BED fragment file via streaming + bedtools/bgzip/tabix.""" with gzip.open(input_file, "rt") as infile, open(temp_1, "w") as outfile: for line in filter_bed_entries( infile, min_length, max_length, quality_threshold ): outfile.write(line) outfile.flush() if whitelist_file: intersect_param = "-f 0.500" if intersect_policy == "midpoint" else "" run_subprocess( f"bedtools intersect -a {temp_1} -b {whitelist_file} " f"{intersect_param} > {temp_2}", error_msg="Whitelist filtering failed", verbose=verbose, logger=logger, ) else: run_subprocess(f"mv {temp_1} {temp_2}", verbose=verbose, logger=logger) if blacklist_file: intersect_param = "-f 0.500" if intersect_policy == "midpoint" else "" run_subprocess( f"bedtools intersect -v -a {temp_2} -b {blacklist_file} " f"{intersect_param} > {temp_3}", error_msg="Blacklist filtering failed", verbose=verbose, logger=logger, ) else: run_subprocess(f"mv {temp_2} {temp_3}", verbose=verbose, logger=logger) run_subprocess( f"bgzip -@ {workers} -c {temp_3} > {output_file}", error_msg="Compression failed", verbose=verbose, logger=logger, ) if output_file != "-": run_subprocess( f"tabix -p bed {output_file}", error_msg="Index creation failed", verbose=verbose, logger=logger, )