From 15ceb4cbd319f20e91720c388cde66cc38ada443 Mon Sep 17 00:00:00 2001 From: sfilges Date: Tue, 3 Mar 2026 13:53:37 +0100 Subject: [PATCH] Fixed alrge file cleanup from BLAST and updated config files --- CHANGELOG.md | 12 + config/designer_default_config.json | 7 + config/designer_lenient_config.json | 7 + docs/CONFIG_GUIDE.md | 122 +++++++++++ docs/COORDINATE_SYSTEMS.md | 72 ++++++ docs/USER_GUIDE.md | 15 ++ src/plexus/blast/specificity.py | 39 +++- src/plexus/cli.py | 327 +--------------------------- src/plexus/cli_docker.py | 208 ++++++++++++++++++ src/plexus/cli_init_wizard.py | 148 +++++++++++++ src/plexus/config.py | 57 +++++ src/plexus/pipeline.py | 6 + src/plexus/version.py | 2 +- tests/test_blast_specificity.py | 85 ++++++++ tests/test_cli.py | 6 +- tests/test_config.py | 57 +++++ 16 files changed, 832 insertions(+), 338 deletions(-) create mode 100644 docs/CONFIG_GUIDE.md create mode 100644 docs/COORDINATE_SYSTEMS.md create mode 100644 src/plexus/cli_docker.py create mode 100644 src/plexus/cli_init_wizard.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ac85694..e9b95a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.0b4] - 03-03-2026 + +### Changed + +- **Automatic BLAST archive cleanup (`specificity.py`)**: The BLAST archive file (outfmt 11, can be 20 GB+) is now automatically deleted after the tabular output is produced. Wrapped in `try/finally` so the archive is removed even if an error occurs during the run or reformat step. +- **Extracted init wizard and Docker wrapper from `cli.py`**: Split two self-contained blocks into their own modules to improve maintainability. `cli_init_wizard.py` contains `_is_interactive()`, `_prompt_path()`, and `_run_init_wizard()`. `cli_docker.py` contains the `docker` command, which registers itself on the shared Typer app via side-effect import. `cli.py` drops from 989 to 666 lines; no public API changes. + +### Added + +- **Off-target hard filter (`specificity.py`)**: New `filter_offtarget_pairs()` removes primer pairs with BLAST off-target amplicons before multiplex optimization. Per junction, pairs with zero off-targets are kept; when all pairs have off-targets, the pair with fewest is retained as a fallback. Called automatically from `run_pipeline()` after `run_specificity_check()`. Previously the only mechanism was a soft cost penalty (`wt_off_target * count`), which was often insufficient to outweigh thermodynamic scores, allowing off-target-contaminated pairs into the final panel. 5 new tests in `test_blast_specificity.py`. +- **Configurable BLAST specificity thresholds (`config.py`, `specificity.py`)**: New `BlastParameters` config section with 5 fields that were previously hardcoded: `length_threshold` (default 15), `evalue_threshold` (default 10.0), `max_mismatches` (default 2), `max_amplicon_size` (default 2000), and `ontarget_tolerance` (default 5). These control which BLAST hits are classified as "predicted bound" and how on-target vs off-target amplicons are distinguished. Added to both `designer_default_config.json` and `designer_lenient_config.json` (identical values — physics-based, not stringency-based). `run_specificity_check()` and `_is_on_target()` now accept these as parameters, threaded from `pipeline.py` via `config.blast_parameters`. 9 new tests across `test_config.py` and `test_blast_specificity.py`. + ## [1.0.0b3] - 03-03-2026 ### Fixed diff --git a/config/designer_default_config.json b/config/designer_default_config.json index d6621d7..a0e9dda 100644 --- a/config/designer_default_config.json +++ b/config/designer_default_config.json @@ -70,6 +70,13 @@ "snp_strict": false, "snp_af_weight": 1.0 }, + "blast_parameters": { + "length_threshold": 15, + "evalue_threshold": 10.0, + "max_mismatches": 2, + "max_amplicon_size": 2000, + "ontarget_tolerance": 5 + }, "multiplex_picker_parameters": { "initial_solutions": 100, "top_solutions_to_keep": 4, diff --git a/config/designer_lenient_config.json b/config/designer_lenient_config.json index 9a34667..759a34c 100644 --- a/config/designer_lenient_config.json +++ b/config/designer_lenient_config.json @@ -70,6 +70,13 @@ "snp_strict": false, "snp_af_weight": 0.5 }, + "blast_parameters": { + "length_threshold": 15, + "evalue_threshold": 10.0, + "max_mismatches": 2, + "max_amplicon_size": 2000, + "ontarget_tolerance": 5 + }, "multiplex_picker_parameters": { "initial_solutions": 100, "top_solutions_to_keep": 4, diff --git a/docs/CONFIG_GUIDE.md b/docs/CONFIG_GUIDE.md new file mode 100644 index 0000000..3aaf93c --- /dev/null +++ b/docs/CONFIG_GUIDE.md @@ -0,0 +1,122 @@ +# Configuration Guide for Plexus + +Plexus uses a hierarchical JSON configuration to control every aspect of the multiplex PCR primer design process. This guide explains the parameters, their impact on the design, and provides sensible ranges for typical applications. + +## Configuration Structure + +The configuration is divided into six main sections: + +1. `singleplex_design_parameters`: Individual primer properties. +2. `primer_pair_parameters`: Properties of the forward/reverse pair (e.g., amplicon size). +3. `pcr_conditions`: Thermodynamic environment (salt, concentrations). +4. `snp_check_parameters`: How variants (SNPs) affect primer scoring. +5. `blast_parameters`: Specificity and off-target detection thresholds. +6. `multiplex_picker_parameters`: Weights for the final panel optimization. + +--- + +## 1. Singleplex Design Parameters + +These parameters define what makes a "good" individual primer. + +| Parameter | Default | Sensible Range | Description | +| :--- | :--- | :--- | :--- | +| `PRIMER_OPT_TM` | 60.0 | 58.0 - 62.0 | The ideal melting temperature (°C). | +| `PRIMER_MIN_TM` | 57.0 | 55.0 - 59.0 | Minimum allowed Tm. | +| `PRIMER_MAX_TM` | 63.0 | 61.0 - 68.0 | Maximum allowed Tm. | +| `PRIMER_OPT_SIZE` | 22 | 18 - 26 | The ideal primer length (bp). | +| `primer_min_length`| 18 | 15 - 20 | Minimum primer length. | +| `primer_max_length`| 28 | 25 - 35 | Maximum primer length. | +| `primer_min_gc` | 30 | 20 - 40 | Minimum GC content (%). | +| `primer_max_gc` | 70 | 60 - 80 | Maximum GC content (%). | +| `primer_gc_clamp` | 1 | 0 or 1 | If 1, requires 1-3 G/C bases in the last 5 bases of the 3' end. | +| `primer_max_poly_x`| 5 | 3 - 6 | Max allowed homopolymer length (e.g., AAAAA). | +| `PRIMER_MAX_HAIRPIN_TH` | 24.0 | 20.0 - 30.0 | Max allowed Tm (°C) for internal hairpin structures. | +| `PRIMER_MAX_SELF_ANY_TH` | 45.0 | 35.0 - 50.0 | Max allowed Tm (°C) for any self-dimer. | +| `PRIMER_MAX_SELF_END_TH` | 35.0 | 25.0 - 40.0 | Max allowed Tm (°C) for 3'-anchored self-dimers. | + +--- + +## 2. Primer Pair Parameters + +These parameters control the relationship between the forward and reverse primers. + +| Parameter | Default | Sensible Range | Description | +| :--- | :--- | :--- | :--- | +| `PRIMER_PAIR_MAX_DIFF_TM` | 3.0 | 1.0 - 5.0 | Max difference in Tm between F and R primers. | +| `PRIMER_PRODUCT_OPT_SIZE` | 60 | 50 - 150 | Ideal total amplicon length (including primers). | +| `PRIMER_PRODUCT_MAX_SIZE` | 120 | 80 - 250 | Maximum total amplicon length. | +| `PRIMER_PRODUCT_MIN_INSERT_SIZE` | 20 | 10 - 50 | Minimum bases between the two primers. | + +--- + +## 3. PCR Conditions + +These values are used for thermodynamic calculations (SantaLucia 1998). Ensure these match your actual lab protocol for accurate Tm and Dimer predictions. + +| Parameter | Default | Description | +| :--- | :--- | :--- | +| `annealing_temperature` | 60.0 | The temperature used for the annealing step (°C). | +| `mv_concentration` | 50.0 | Monovalent cation concentration (mM), usually KCl. | +| `dv_concentration` | 1.5 | Divalent cation concentration (mM), usually MgCl2. | +| `dntp_concentration` | 0.6 | Total dNTP concentration (mM). | +| `primer_concentration` | 50.0 | Concentration of each individual primer (nM). | + +--- + +## 4. SNP Check Parameters + +Plexus queries VCF files to avoid designing primers over common variants. + +| Parameter | Default | Sensible Range | Description | +| :--- | :--- | :--- | :--- | +| `af_threshold` | 0.01 | 0.001 - 0.05 | Minimum Allele Frequency (AF) to consider a SNP problematic. | +| `snp_penalty_weight` | 10.0 | 5.0 - 20.0 | Base penalty added to a primer for each overlapping SNP. | +| `snp_3prime_window` | 5 | 3 - 8 | Bases from the 3' end where a SNP is considered high-impact. | +| `snp_3prime_multiplier` | 3.0 | 2.0 - 5.0 | Multiplier for the penalty if the SNP is in the 3' window. | +| `snp_strict` | false | true/false | If true, any primer with a SNP > `af_threshold` is discarded immediately. | +| `snp_af_weight` | 0.0 | 0.0 - 1.0 | Exponent for AF scaling. `0.5` (sqrt) is recommended to penalize common SNPs more than rare ones. | + +--- + +## 5. BLAST Parameters + +Used to identify off-target products that might cause non-specific amplification. + +| Parameter | Default | Description | +| :--- | :--- | :--- | +| `length_threshold` | 15 | Min 3'-anchored alignment length (bp) to predict binding. | +| `max_mismatches` | 2 | Max mismatches allowed in a 3'-anchored alignment. | +| `max_amplicon_size` | 2000 | Max distance between two hits to be considered a potential off-target amplicon. | +| `ontarget_tolerance` | 5 | BP tolerance when verifying if a BLAST hit matches the intended target. | + +--- + +## 6. Multiplex Picker Parameters + +These weights control the final "cost" of a panel. The optimizer tries to minimize this cost. + +| Parameter | Default | Description | +| :--- | :--- | :--- | +| `wt_pair_penalty` | 1.0 | Weight for the basic primer quality penalty (Tm deviation, length, etc). | +| `wt_off_target` | 5.0 | Penalty for each predicted off-target product. Usually high to avoid mispriming. | +| `wt_cross_dimer` | 1.0 | Weight for penalties arising from primers in DIFFERENT pairs forming dimers. | +| `wt_pair_dimer` | 1.0 | Weight for the dimer score of the F/R primers within the SAME pair. | +| `wt_snp_penalty` | 3.0 | Weight for the accumulated SNP penalty from the `snpcheck` step. | +| `initial_solutions`| 100 | Number of iterations for stochastic selectors (Greedy, Simulated Annealing). | + +--- + +## Best Practices + +### For Small, High-Quality Panels (< 24 targets) + +* Use `preset: "default"`. +* Set `snp_strict: true` to ensure no variants interfere with your clinical targets. +* Increase `initial_solutions` to `1000` for a more thorough search. + +### For Large Discovery Panels (> 100 targets) + +* Use `preset: "lenient"` to allow for a wider range of Tms. +* Keep `snp_strict: false` but use `snp_af_weight: 0.5` to prioritize the best available sites. +* Use the `Greedy` or `SimulatedAnnealing` selectors; avoid `BruteForce` or `DFS`. diff --git a/docs/COORDINATE_SYSTEMS.md b/docs/COORDINATE_SYSTEMS.md new file mode 100644 index 0000000..2262849 --- /dev/null +++ b/docs/COORDINATE_SYSTEMS.md @@ -0,0 +1,72 @@ +# Coordinate Systems in Plexus + +For a tool where off-by-one errors are safety-critical, understanding the coordinate systems used for inputs, internal processing, and outputs is essential. + +## Summary + +| Context | Coordinate System | Base | Inclusive | +|---------|-------------------|------|-----------| +| **Input CSV** | Genomic | 1 | Yes | +| **Output CSV** | Genomic | 1 | Yes | +| **Internal Offsets** | Design-Region Relative | 0 | Yes | +| **Reference FASTA** | Standard (pysam handles) | 1 (text) | Yes | +| **VCF/gnomAD** | Genomic | 1 | Yes | + +--- + +## Genomic Coordinates (1-based) + +Plexus follows the standard convention used by genome browsers (IGV, UCSC), VCF files, and GFF/GTF annotations. + +### Input: `junctions.csv` +The columns `Five_Prime_Coordinate` and `Three_Prime_Coordinate` must be provided as **1-based inclusive** genomic coordinates. +- For a single-base target (e.g., an SNV), both coordinates should be the same. +- For a range (e.g., a deletion or a whole exon), provide the first and last base of the target. + +### Output: `selected_multiplex.csv` / `candidate_pairs.csv` +All genomic coordinate columns in the output files are **1-based inclusive**: +- `Forward_Genomic_Start` / `Forward_Genomic_End` +- `Reverse_Genomic_Start` / `Reverse_Genomic_End` +- `Junction_Start` / `Junction_End` + +This ensures that you can directly paste these coordinates into IGV or a genome browser to verify the primer positions. + +--- + +## Design-Region Offsets (0-based) + +Internally, Plexus extracts a padded genomic sequence around your target, known as the **Design Region**. + +### `design_start` +The attribute `junction.design_start` stores the **1-based genomic coordinate** of the first base in the extracted sequence. + +### Primer Offsets +The `start` attribute of a `Primer` object is a **0-based index** into the `design_region` string. +- A primer starting at the very beginning of the design region has a `start` of `0`. +- The genomic start of a primer is calculated as: `design_start + primer.start`. +- The genomic end of a primer is calculated as: `design_start + primer.start + primer.length - 1`. + +--- + +## External Tools and Libraries + +Plexus handles the conversion between its internal 1-based system and the 0-based systems used by some underlying libraries: + +### pysam (VCF/FASTA access) +`pysam` uses 0-based half-open coordinates (like the BAM/BED formats). +- **Extraction**: When Plexus fetches a sequence from the FASTA, it converts the 1-based `[start, end]` range to 0-based `[start-1, end)`. +- **SNP Checking**: When querying a VCF, Plexus converts the 1-based primer range `[P_start, P_end]` to the 0-based query `fetch(chrom, P_start - 1, P_end)`. + +### Primer3 +`primer3-py` uses 0-based coordinates for sequence offsets. Plexus passes the `design_region` string and receives 0-based offsets, which are stored directly in the `Primer` objects. + +### BLAST +The BLAST specificity check uses 1-based coordinates for its output, which Plexus parses and maps back to the original genomic coordinates for on-target/off-target classification. + +--- + +## Developer Note: Coordinate Math + +When performing arithmetic on coordinates, always verify the base: +- **Genomic to Design-Region**: `junction.start - junction.design_start` (both 1-based) yields a 0-based offset. +- **Design-Region to Genomic**: `junction.design_start + primer.offset` (1-based + 0-based) yields a 1-based genomic coordinate. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index b569904..9974d71 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -11,6 +11,7 @@ 7. [Output Interpretation](#output-interpretation) 8. [Troubleshooting](#troubleshooting) 9. [Technical Reference](#technical-reference) + - [Coordinate Systems](#coordinate-systems-reference) 10. [Compliance and Clinical Use](#compliance-and-clinical-use) - [Two Deployment Paths](#two-compliance-deployment-paths) - [The Compliance Manifest](#the-compliance-manifest) @@ -147,6 +148,8 @@ plexus run -i junctions.csv -g hg38 -o results/ A **Junction** represents a single genomic target for primer design: +> **Important**: Plexus uses **1-based inclusive genomic coordinates** for all target inputs and final outputs. See the [Coordinate Systems Guide](COORDINATE_SYSTEMS.md) for full details. + ```python Junction( name="EGFR_T790M", @@ -766,6 +769,18 @@ plexus status ## Technical Reference +### Coordinate Systems Reference + +For a tool where off-by-one errors are safety-critical, understanding the coordinate systems used for inputs, internal processing, and outputs is essential. + +| Context | Coordinate System | Base | Inclusive | +|---------|-------------------|------|-----------| +| **Input CSV** | Genomic | 1 | Yes | +| **Output CSV** | Genomic | 1 | Yes | +| **Internal Offsets** | Design-Region Relative | 0 | Yes | + +For more detailed coordinate system specifications and internal handling, please see the [Coordinate Systems Guide](COORDINATE_SYSTEMS.md). + ### CLI Command Reference **Main Commands:** diff --git a/src/plexus/blast/specificity.py b/src/plexus/blast/specificity.py index e44322a..bc1a8b0 100644 --- a/src/plexus/blast/specificity.py +++ b/src/plexus/blast/specificity.py @@ -9,7 +9,16 @@ def run_specificity_check( - panel: MultiplexPanel, work_dir: str, genome_fasta: str, num_threads: int = 1 + panel: MultiplexPanel, + work_dir: str, + genome_fasta: str, + num_threads: int = 1, + *, + length_threshold: int = 15, + evalue_threshold: float = 10.0, + max_mismatches: int = 2, + max_amplicon_size: int = 2000, + ontarget_tolerance: int = 5, ): """ Run BLAST on all candidate primers in the panel to check for specificity @@ -21,6 +30,12 @@ def run_specificity_check( panel: The MultiplexPanel object containing junctions and primer designs. work_dir: Directory to store temporary BLAST files. genome_fasta: Path to the reference genome FASTA file. + num_threads: Number of BLAST threads. + length_threshold: Minimum 3'-anchored alignment length (bp) to predict binding. + evalue_threshold: E-value cutoff for predicted binding. + max_mismatches: Maximum mismatches in a 3'-anchored alignment. + max_amplicon_size: Maximum amplicon size (bp) to consider. + ontarget_tolerance: Coordinate tolerance (bp) for on-target classification. """ logger.info("Starting specificity check (BLAST)...") os.makedirs(work_dir, exist_ok=True) @@ -43,8 +58,13 @@ def run_specificity_check( runner = BlastRunner(input_fasta, genome_fasta) runner.create_database() - runner.run(output_archive=blast_archive, num_threads=num_threads) - runner.reformat_output_as_table(blast_table) + try: + runner.run(output_archive=blast_archive, num_threads=num_threads) + runner.reformat_output_as_table(blast_table) + finally: + if os.path.exists(blast_archive): + os.remove(blast_archive) + logger.debug(f"Removed BLAST archive: {blast_archive}") blast_df = runner.get_dataframe() @@ -56,14 +76,16 @@ def run_specificity_check( target_map = getattr(panel, "primer_target_map", {}) annotator = BlastResultsAnnotator(blast_df, target_map=target_map) annotator.build_annotation_dict( - length_threshold=15, evalue_threshold=10, max_mismatches=2 + length_threshold=length_threshold, + evalue_threshold=evalue_threshold, + max_mismatches=max_mismatches, ) annotator.add_annotations() # 5. Find Off-Target Amplicons bound_df = annotator.get_predicted_bound() finder = AmpliconFinder(bound_df, target_map=target_map) - finder.find_amplicons(max_size_bp=2000) + finder.find_amplicons(max_size_bp=max_amplicon_size) all_amplicons_df = finder.amplicon_df @@ -106,7 +128,7 @@ def run_specificity_check( off_targets = [] on_targets = [] for prod in potential_products: - if _is_on_target(prod, junction, pair): + if _is_on_target(prod, junction, pair, tolerance=ontarget_tolerance): on_targets.append(prod) else: off_targets.append(prod) @@ -191,7 +213,7 @@ def filter_offtarget_pairs(panel: MultiplexPanel) -> tuple[int, list[str]]: return total_removed, fallback_junctions -def _is_on_target(prod: dict, junction, pair) -> bool: +def _is_on_target(prod: dict, junction, pair, tolerance: int = 5) -> bool: """Check if a BLAST amplicon overlaps the intended target region. Compares the BLAST hit genomic coordinates against the expected @@ -224,9 +246,8 @@ def _is_on_target(prod: dict, junction, pair) -> bool: expected_rev_start = design_start + pair.reverse.start + pair.reverse.length - 1 # Allow small tolerance for BLAST coordinate alignment differences. - # 5 bp chosen to absorb minor alignment shifts while still distinguishing + # Default 5 bp absorbs minor alignment shifts while still distinguishing # on-target hits from nearby off-target loci. - tolerance = 5 # bp fwd_match = abs(prod["F_start"] - expected_fwd_start) <= tolerance rev_match = abs(prod["R_start"] - expected_rev_start) <= tolerance diff --git a/src/plexus/cli.py b/src/plexus/cli.py index 4964acd..f13ea66 100644 --- a/src/plexus/cli.py +++ b/src/plexus/cli.py @@ -436,141 +436,6 @@ def status() -> None: ) -def _is_interactive() -> bool: - """Return True if stdin is an interactive TTY.""" - import sys - - return sys.stdin.isatty() - - -def _prompt_path(label: str, *, must_exist: bool = True) -> Path: - """Prompt for a file path, re-asking until a valid path is given.""" - from rich.prompt import Prompt - - while True: - raw = Prompt.ask(f" {label}") - if not raw or not raw.strip(): - console.print(" [red]Path cannot be empty.[/red]") - continue - p = Path(raw.strip()).expanduser().resolve() - if must_exist and not p.is_file(): - console.print(f" [red]File not found: {p}[/red]") - continue - return p - - -def _run_init_wizard( - genome_presets: list[str], -) -> dict: - """Interactive wizard for `plexus init`. Returns a dict of collected parameters.""" - - from rich.panel import Panel - from rich.prompt import Confirm, Prompt - - console.print() - console.print( - Panel( - "[bold]Plexus — Resource Initialization Wizard[/bold]\n" - "[dim]Answer the prompts below to register reference resources.\n" - "Press Ctrl-C at any time to abort.[/dim]", - border_style="green", - ) - ) - - # 1. Genome - default_genome = genome_presets[0] if genome_presets else "hg38" - genome = Prompt.ask( - " [bold]Genome[/bold]", - choices=genome_presets, - default=default_genome, - ) - - # 2. FASTA - console.print() - fasta = _prompt_path("[bold]Reference FASTA file[/bold]") - - # 3. SNP VCF - console.print() - register_vcf = Confirm.ask( - " [bold]Register a SNP VCF[/bold] (gnomAD)?", default=True - ) - snp_vcf: Path | None = None - skip_snp = True - if register_vcf: - snp_vcf = _prompt_path("[bold]SNP VCF file[/bold] (tabix-indexed .vcf.gz)") - skip_snp = False - - # 4. Operational mode - console.print() - mode = Prompt.ask( - " [bold]Operational mode[/bold]", - choices=["research", "compliance"], - default="research", - ) - - # 5. Checksums - console.print() - use_checksums = Confirm.ask( - " [bold]Verify files with a checksums file[/bold]?", default=False - ) - checksums: Path | None = None - if use_checksums: - checksums = _prompt_path("[bold]Checksums file[/bold] (sha256sum format)") - - # 6. BLAST index - console.print() - build_blast = Confirm.ask(" [bold]Build BLAST index[/bold]?", default=True) - - # 7. Force rebuild - force = False - if build_blast: - force = Confirm.ask( - " [bold]Force rebuild[/bold] existing indexes?", default=False - ) - - # ── Summary ────────────────────────────────────────────────────────────── - console.print() - summary_lines = [ - f" Genome: [bold]{genome}[/bold]", - f" FASTA: {fasta}", - ] - if snp_vcf: - summary_lines.append(f" SNP VCF: {snp_vcf}") - else: - summary_lines.append(" SNP VCF: [dim]skipped[/dim]") - summary_lines.append(f" Mode: {mode}") - if checksums: - summary_lines.append(f" Checksums: {checksums}") - summary_lines.append( - f" BLAST: {'build' if build_blast else '[dim]skip[/dim]'}" - ) - if force: - summary_lines.append(" Force: yes") - - console.print( - Panel( - "\n".join(summary_lines), - title="[bold]Summary[/bold]", - border_style="cyan", - ) - ) - - if not Confirm.ask(" [bold]Proceed with initialization?[/bold]", default=True): - console.print("[yellow]Aborted.[/yellow]") - raise typer.Exit(code=0) - - return { - "genome": genome, - "fasta": fasta, - "snp_vcf": snp_vcf, - "skip_snp": skip_snp, - "skip_blast": not build_blast, - "force": force, - "mode": mode, - "checksums": checksums, - } - - @app.command() def init( genome: Annotated[ @@ -633,6 +498,7 @@ def init( Provide --fasta and --snp-vcf to run non-interactively. Use --checksums to verify files against known-good hashes. """ + from plexus.cli_init_wizard import _is_interactive, _run_init_wizard from plexus.resources import ( GENOME_PRESETS, genome_status, @@ -794,196 +660,7 @@ def template( ) -@app.command( - name="docker", - context_settings={"allow_extra_args": True, "ignore_unknown_options": True}, -) -def docker_run( - ctx: typer.Context, - input_file: Annotated[ - Path, - typer.Option( - "--input", "-i", help="Path to CSV file containing junction coordinates." - ), - ], - fasta_file: Annotated[ - Path, - typer.Option( - "--fasta", - "-f", - help="Path to reference genome FASTA (and adjacent BLAST DB / .fai files).", - ), - ], - tag: Annotated[ - str, - typer.Option( - "--tag", - help="Plexus image version tag to use (e.g. 0.5.0). Defaults to the currently installed version.", - ), - ] = __version__, - registry: Annotated[ - str, - typer.Option("--registry", help="Docker registry prefix for the plexus image."), - ] = "ghcr.io/sfilges/plexus", - output_dir: Annotated[ - Path, - typer.Option("--output", "-o", help="Host directory for output files."), - ] = Path("./output"), - snp_vcf: Annotated[ - Path | None, - typer.Option( - "--snp-vcf", help="Path to tabix-indexed VCF (and adjacent .tbi index)." - ), - ] = None, - checksums: Annotated[ - Path | None, - typer.Option( - "--checksums", - help="SHA-256 checksums file for stateless data verification.", - ), - ] = None, - config_file: Annotated[ - Path | None, - typer.Option("--config", "-c", help="Path to custom JSON config file."), - ] = None, - pull: Annotated[ - bool, - typer.Option( - "--pull/--no-pull", help="Pull the image even if it exists locally." - ), - ] = False, -) -> None: - """ - Run the plexus compliance container for a specific version. - - Wraps 'docker run': mounts parent directories of all file arguments - (so adjacent BLAST DB, .fai, and .tbi files are included), translates - host paths to container paths, and streams output. - - Additional 'plexus run' options (--selector, --preset, --skip-blast, - --snp-strict, --genome, --padding, etc.) can be appended and are passed - through unchanged. - - Example: - plexus docker --tag 0.5.0 \\ - --fasta /data/hg38.fa \\ - --snp-vcf /data/gnomad.vcf.gz \\ - --checksums /data/checksums.sha256 \\ - --input /data/junctions.csv \\ - --output /data/results/ \\ - --skip-blast - """ - import shutil - import subprocess - import sys - - # 1. Check docker is available - if not shutil.which("docker"): - console.print( - "[bold red]Error: docker not found on PATH. " - "Install Docker: https://docs.docker.com/get-docker/[/bold red]" - ) - raise typer.Exit(code=1) - - image = f"{registry}:{tag}" - - console.print("[bold green]Plexus Docker Runner[/bold green]") - console.print(f" Image: {image}") - console.print(f" Input: {input_file}") - console.print(f" FASTA: {fasta_file}") - console.print(f" Output: {output_dir}") - console.print() - - # 2. Pull image if needed - needs_pull = pull - if not pull: - inspect = subprocess.run( - ["docker", "image", "inspect", image], capture_output=True - ) - if inspect.returncode != 0: - console.print(f" Image not found locally — pulling {image} ...") - needs_pull = True - if needs_pull: - result = subprocess.run(["docker", "pull", image]) - if result.returncode != 0: - console.print(f"[bold red]Error: failed to pull {image}[/bold red]") - raise typer.Exit(code=1) - console.print(" [green]✓[/green] Image ready") - - # 3. Collect file args (excluding output) - file_args: dict[str, Path | None] = { - "input": input_file.resolve(), - "fasta": fasta_file.resolve(), - "snp_vcf": snp_vcf.resolve() if snp_vcf else None, - "checksums": checksums.resolve() if checksums else None, - "config": config_file.resolve() if config_file else None, - } - - # 4. Build volume mounts: unique parent dirs → /mnt/vol0, /mnt/vol1, ... - dir_map: dict[str, str] = {} - counter = 0 - for path in file_args.values(): - if path is None: - continue - parent = str(path.parent) - if parent not in dir_map: - dir_map[parent] = f"/mnt/vol{counter}" - counter += 1 - - fasta_parent = str(file_args["fasta"].parent) - - volume_flags: list[str] = [] - for host_dir, mount_pt in dir_map.items(): - mode = "rw" if host_dir == fasta_parent else "ro" - volume_flags += ["-v", f"{host_dir}:{mount_pt}:{mode}"] - - # Output dir — writable - abs_output = output_dir.resolve() - abs_output.mkdir(parents=True, exist_ok=True) - volume_flags += ["-v", f"{abs_output}:/mnt/output"] - - # 5. Translate host paths → container paths - def to_container(path: Path) -> str: - return f"{dir_map[str(path.parent)]}/{path.name}" - - # 6. Build plexus run args - run_args = [ - "--input", - to_container(file_args["input"]), - "--fasta", - to_container(file_args["fasta"]), - "--output", - "/mnt/output", - ] - if file_args["snp_vcf"]: - run_args += ["--snp-vcf", to_container(file_args["snp_vcf"])] - if file_args["checksums"]: - run_args += ["--checksums", to_container(file_args["checksums"])] - if file_args["config"]: - run_args += ["--config", to_container(file_args["config"])] - - # 7. Extra args passed through verbatim (non-file plexus run flags) - extra_args: list[str] = ctx.args - - # 8. TTY flag for rich output - tty_flag = ["-t"] if sys.stdout.isatty() else [] - - # 9. Assemble and run - cmd = [ - "docker", - "run", - "--rm", - *tty_flag, - *volume_flags, - image, - "run", - *run_args, - *extra_args, - ] - console.print(f" Running: {' '.join(cmd)}\n") - result = subprocess.run(cmd) - raise typer.Exit(code=result.returncode) - +import plexus.cli_docker # noqa: E402, F401 — registers docker command if __name__ == "__main__": app() diff --git a/src/plexus/cli_docker.py b/src/plexus/cli_docker.py new file mode 100644 index 0000000..9e61e90 --- /dev/null +++ b/src/plexus/cli_docker.py @@ -0,0 +1,208 @@ +# ================================================================================ +# Docker wrapper command for plexus +# +# Extracted from cli.py to keep the main CLI module focused. +# Registers the 'docker' command on the shared Typer app. +# ================================================================================ + +from pathlib import Path +from typing import Annotated + +import typer +from rich.console import Console + +from plexus.cli import app +from plexus.version import __version__ + +console = Console() + + +@app.command( + name="docker", + context_settings={"allow_extra_args": True, "ignore_unknown_options": True}, +) +def docker_run( + ctx: typer.Context, + input_file: Annotated[ + Path, + typer.Option( + "--input", "-i", help="Path to CSV file containing junction coordinates." + ), + ], + fasta_file: Annotated[ + Path, + typer.Option( + "--fasta", + "-f", + help="Path to reference genome FASTA (and adjacent BLAST DB / .fai files).", + ), + ], + tag: Annotated[ + str, + typer.Option( + "--tag", + help="Plexus image version tag to use (e.g. 0.5.0). Defaults to the currently installed version.", + ), + ] = __version__, + registry: Annotated[ + str, + typer.Option("--registry", help="Docker registry prefix for the plexus image."), + ] = "ghcr.io/sfilges/plexus", + output_dir: Annotated[ + Path, + typer.Option("--output", "-o", help="Host directory for output files."), + ] = Path("./output"), + snp_vcf: Annotated[ + Path | None, + typer.Option( + "--snp-vcf", help="Path to tabix-indexed VCF (and adjacent .tbi index)." + ), + ] = None, + checksums: Annotated[ + Path | None, + typer.Option( + "--checksums", + help="SHA-256 checksums file for stateless data verification.", + ), + ] = None, + config_file: Annotated[ + Path | None, + typer.Option("--config", "-c", help="Path to custom JSON config file."), + ] = None, + pull: Annotated[ + bool, + typer.Option( + "--pull/--no-pull", help="Pull the image even if it exists locally." + ), + ] = False, +) -> None: + """ + Run the plexus compliance container for a specific version. + + Wraps 'docker run': mounts parent directories of all file arguments + (so adjacent BLAST DB, .fai, and .tbi files are included), translates + host paths to container paths, and streams output. + + Additional 'plexus run' options (--selector, --preset, --skip-blast, + --snp-strict, --genome, --padding, etc.) can be appended and are passed + through unchanged. + + Example: + plexus docker --tag 0.5.0 \\ + --fasta /data/hg38.fa \\ + --snp-vcf /data/gnomad.vcf.gz \\ + --checksums /data/checksums.sha256 \\ + --input /data/junctions.csv \\ + --output /data/results/ \\ + --skip-blast + """ + import shutil + import subprocess + import sys + + # 1. Check docker is available + if not shutil.which("docker"): + console.print( + "[bold red]Error: docker not found on PATH. " + "Install Docker: https://docs.docker.com/get-docker/[/bold red]" + ) + raise typer.Exit(code=1) + + image = f"{registry}:{tag}" + + console.print("[bold green]Plexus Docker Runner[/bold green]") + console.print(f" Image: {image}") + console.print(f" Input: {input_file}") + console.print(f" FASTA: {fasta_file}") + console.print(f" Output: {output_dir}") + console.print() + + # 2. Pull image if needed + needs_pull = pull + if not pull: + inspect = subprocess.run( + ["docker", "image", "inspect", image], capture_output=True + ) + if inspect.returncode != 0: + console.print(f" Image not found locally — pulling {image} ...") + needs_pull = True + if needs_pull: + result = subprocess.run(["docker", "pull", image]) + if result.returncode != 0: + console.print(f"[bold red]Error: failed to pull {image}[/bold red]") + raise typer.Exit(code=1) + console.print(" [green]✓[/green] Image ready") + + # 3. Collect file args (excluding output) + file_args: dict[str, Path | None] = { + "input": input_file.resolve(), + "fasta": fasta_file.resolve(), + "snp_vcf": snp_vcf.resolve() if snp_vcf else None, + "checksums": checksums.resolve() if checksums else None, + "config": config_file.resolve() if config_file else None, + } + + # 4. Build volume mounts: unique parent dirs → /mnt/vol0, /mnt/vol1, ... + dir_map: dict[str, str] = {} + counter = 0 + for path in file_args.values(): + if path is None: + continue + parent = str(path.parent) + if parent not in dir_map: + dir_map[parent] = f"/mnt/vol{counter}" + counter += 1 + + fasta_parent = str(file_args["fasta"].parent) + + volume_flags: list[str] = [] + for host_dir, mount_pt in dir_map.items(): + mode = "rw" if host_dir == fasta_parent else "ro" + volume_flags += ["-v", f"{host_dir}:{mount_pt}:{mode}"] + + # Output dir — writable + abs_output = output_dir.resolve() + abs_output.mkdir(parents=True, exist_ok=True) + volume_flags += ["-v", f"{abs_output}:/mnt/output"] + + # 5. Translate host paths → container paths + def to_container(path: Path) -> str: + return f"{dir_map[str(path.parent)]}/{path.name}" + + # 6. Build plexus run args + run_args = [ + "--input", + to_container(file_args["input"]), + "--fasta", + to_container(file_args["fasta"]), + "--output", + "/mnt/output", + ] + if file_args["snp_vcf"]: + run_args += ["--snp-vcf", to_container(file_args["snp_vcf"])] + if file_args["checksums"]: + run_args += ["--checksums", to_container(file_args["checksums"])] + if file_args["config"]: + run_args += ["--config", to_container(file_args["config"])] + + # 7. Extra args passed through verbatim (non-file plexus run flags) + extra_args: list[str] = ctx.args + + # 8. TTY flag for rich output + tty_flag = ["-t"] if sys.stdout.isatty() else [] + + # 9. Assemble and run + cmd = [ + "docker", + "run", + "--rm", + *tty_flag, + *volume_flags, + image, + "run", + *run_args, + *extra_args, + ] + console.print(f" Running: {' '.join(cmd)}\n") + result = subprocess.run(cmd) + raise typer.Exit(code=result.returncode) diff --git a/src/plexus/cli_init_wizard.py b/src/plexus/cli_init_wizard.py new file mode 100644 index 0000000..50a1f88 --- /dev/null +++ b/src/plexus/cli_init_wizard.py @@ -0,0 +1,148 @@ +# ================================================================================ +# Interactive init wizard for `plexus init` +# +# Extracted from cli.py to keep the main CLI module focused. +# These are plain helper functions — the init command itself stays in cli.py. +# ================================================================================ + +from pathlib import Path + +import typer +from rich.console import Console + +console = Console() + + +def _is_interactive() -> bool: + """Return True if stdin is an interactive TTY.""" + import sys + + return sys.stdin.isatty() + + +def _prompt_path(label: str, *, must_exist: bool = True) -> Path: + """Prompt for a file path, re-asking until a valid path is given.""" + from rich.prompt import Prompt + + while True: + raw = Prompt.ask(f" {label}") + if not raw or not raw.strip(): + console.print(" [red]Path cannot be empty.[/red]") + continue + p = Path(raw.strip()).expanduser().resolve() + if must_exist and not p.is_file(): + console.print(f" [red]File not found: {p}[/red]") + continue + return p + + +def _run_init_wizard( + genome_presets: list[str], +) -> dict: + """Interactive wizard for `plexus init`. Returns a dict of collected parameters.""" + + from rich.panel import Panel + from rich.prompt import Confirm, Prompt + + console.print() + console.print( + Panel( + "[bold]Plexus — Resource Initialization Wizard[/bold]\n" + "[dim]Answer the prompts below to register reference resources.\n" + "Press Ctrl-C at any time to abort.[/dim]", + border_style="green", + ) + ) + + # 1. Genome + default_genome = genome_presets[0] if genome_presets else "hg38" + genome = Prompt.ask( + " [bold]Genome[/bold]", + choices=genome_presets, + default=default_genome, + ) + + # 2. FASTA + console.print() + fasta = _prompt_path("[bold]Reference FASTA file[/bold]") + + # 3. SNP VCF + console.print() + register_vcf = Confirm.ask( + " [bold]Register a SNP VCF[/bold] (gnomAD)?", default=True + ) + snp_vcf: Path | None = None + skip_snp = True + if register_vcf: + snp_vcf = _prompt_path("[bold]SNP VCF file[/bold] (tabix-indexed .vcf.gz)") + skip_snp = False + + # 4. Operational mode + console.print() + mode = Prompt.ask( + " [bold]Operational mode[/bold]", + choices=["research", "compliance"], + default="research", + ) + + # 5. Checksums + console.print() + use_checksums = Confirm.ask( + " [bold]Verify files with a checksums file[/bold]?", default=False + ) + checksums: Path | None = None + if use_checksums: + checksums = _prompt_path("[bold]Checksums file[/bold] (sha256sum format)") + + # 6. BLAST index + console.print() + build_blast = Confirm.ask(" [bold]Build BLAST index[/bold]?", default=True) + + # 7. Force rebuild + force = False + if build_blast: + force = Confirm.ask( + " [bold]Force rebuild[/bold] existing indexes?", default=False + ) + + # ── Summary ────────────────────────────────────────────────────────────── + console.print() + summary_lines = [ + f" Genome: [bold]{genome}[/bold]", + f" FASTA: {fasta}", + ] + if snp_vcf: + summary_lines.append(f" SNP VCF: {snp_vcf}") + else: + summary_lines.append(" SNP VCF: [dim]skipped[/dim]") + summary_lines.append(f" Mode: {mode}") + if checksums: + summary_lines.append(f" Checksums: {checksums}") + summary_lines.append( + f" BLAST: {'build' if build_blast else '[dim]skip[/dim]'}" + ) + if force: + summary_lines.append(" Force: yes") + + console.print( + Panel( + "\n".join(summary_lines), + title="[bold]Summary[/bold]", + border_style="cyan", + ) + ) + + if not Confirm.ask(" [bold]Proceed with initialization?[/bold]", default=True): + console.print("[yellow]Aborted.[/yellow]") + raise typer.Exit(code=0) + + return { + "genome": genome, + "fasta": fasta, + "snp_vcf": snp_vcf, + "skip_snp": skip_snp, + "skip_blast": not build_blast, + "force": force, + "mode": mode, + "checksums": checksums, + } diff --git a/src/plexus/config.py b/src/plexus/config.py index fc8c25e..6af183c 100644 --- a/src/plexus/config.py +++ b/src/plexus/config.py @@ -248,6 +248,62 @@ class SnpCheckParameters(BaseModel): ) +class BlastParameters(BaseModel): + """Parameters for BLAST specificity checking.""" + + length_threshold: int = Field( + default=15, + ge=5, + le=30, + description=( + "Minimum 3'-anchored alignment length (bp) to predict primer binding. " + "A BLAST hit with at least this many bases aligned from the 3' end " + "is classified as 'predicted bound'." + ), + ) + evalue_threshold: float = Field( + default=10.0, + gt=0.0, + description=( + "E-value cutoff for predicted binding. Hits with e-value below this " + "threshold (and anchored at the 3' end) are classified as 'predicted bound'. " + "High default (10) is appropriate for short primer queries where " + "e-values are naturally large." + ), + ) + max_mismatches: int = Field( + default=2, + ge=0, + le=5, + description=( + "Maximum mismatches allowed in a 3'-anchored alignment for it to be " + "classified as 'predicted bound'. A primer with 1-2 mismatches in a " + "15+ bp 3' stretch will still extend in PCR." + ), + ) + max_amplicon_size: int = Field( + default=2000, + ge=100, + le=50000, + description=( + "Maximum distance (bp) between two predicted-bound primers for them " + "to form an amplicon. Products larger than this are unlikely to " + "amplify efficiently under standard PCR conditions." + ), + ) + ontarget_tolerance: int = Field( + default=5, + ge=0, + le=50, + description=( + "Coordinate tolerance (bp) for classifying a BLAST amplicon as on-target. " + "A hit is on-target if both primer positions are within this distance " + "of the expected genomic coordinates. Absorbs minor BLAST alignment " + "shifts while still distinguishing on-target from pseudogene hits." + ), + ) + + class DesignerConfig(BaseModel): """Complete configuration for multiplex primer panel design.""" @@ -262,6 +318,7 @@ class DesignerConfig(BaseModel): default_factory=MultiplexPickerParameters ) snp_check_parameters: SnpCheckParameters = Field(default_factory=SnpCheckParameters) + blast_parameters: BlastParameters = Field(default_factory=BlastParameters) @classmethod def from_json_file(cls, file_path: str | Path) -> DesignerConfig: diff --git a/src/plexus/pipeline.py b/src/plexus/pipeline.py index 88c698a..e63412e 100644 --- a/src/plexus/pipeline.py +++ b/src/plexus/pipeline.py @@ -523,11 +523,17 @@ def run_pipeline( ) blast_dir = output_dir / "blast" + blast_config = config.blast_parameters run_specificity_check( panel, str(blast_dir), str(fasta_file), num_threads=blast_num_threads, + length_threshold=blast_config.length_threshold, + evalue_threshold=blast_config.evalue_threshold, + max_mismatches=blast_config.max_mismatches, + max_amplicon_size=blast_config.max_amplicon_size, + ontarget_tolerance=blast_config.ontarget_tolerance, ) result.steps_completed.append("specificity_checked") logger.info("Specificity check complete") diff --git a/src/plexus/version.py b/src/plexus/version.py index b91585f..f96eeb6 100644 --- a/src/plexus/version.py +++ b/src/plexus/version.py @@ -1 +1 @@ -__version__ = "1.0.0b3" +__version__ = "1.0.0b4" diff --git a/tests/test_blast_specificity.py b/tests/test_blast_specificity.py index 3c4edb1..55ccfe4 100644 --- a/tests/test_blast_specificity.py +++ b/tests/test_blast_specificity.py @@ -43,6 +43,37 @@ def mock_panel(): return panel +def test_blast_archive_removed_after_run(mock_panel, tmp_path): + """The BLAST archive file is cleaned up after a successful specificity check.""" + blast_archive = tmp_path / "blast_archive" + + with ( + patch("plexus.blast.specificity.BlastRunner") as MockRunner, + patch("plexus.blast.specificity.BlastResultsAnnotator") as MockAnnotator, + patch("plexus.blast.specificity.AmpliconFinder") as MockFinder, + ): + runner_instance = MockRunner.return_value + + def fake_run(output_archive, **kwargs): + # Simulate BLAST creating the archive file + open(output_archive, "w").close() + + runner_instance.run.side_effect = fake_run + runner_instance.get_dataframe.return_value = pd.DataFrame({"dummy": [1]}) + + annotator_instance = MockAnnotator.return_value + annotator_instance.get_predicted_bound.return_value = pd.DataFrame( + {"dummy_bound": [1]} + ) + + finder_instance = MockFinder.return_value + finder_instance.amplicon_df = pd.DataFrame() + + run_specificity_check(mock_panel, str(tmp_path), "fake_genome.fa") + + assert not blast_archive.exists(), "BLAST archive should be removed after run" + + def test_run_specificity_check_integration(mock_panel, tmp_path): # Setup mocks for internal classes with ( @@ -120,6 +151,44 @@ def test_run_specificity_check_forwards_num_threads(mock_panel, tmp_path): assert kwargs.get("num_threads") == 6 +def test_run_specificity_check_forwards_blast_parameters(mock_panel, tmp_path): + """Custom BLAST parameters are threaded through to annotator and finder.""" + with ( + patch("plexus.blast.specificity.BlastRunner") as MockRunner, + patch("plexus.blast.specificity.BlastResultsAnnotator") as MockAnnotator, + patch("plexus.blast.specificity.AmpliconFinder") as MockFinder, + patch("os.makedirs"), + ): + runner_instance = MockRunner.return_value + runner_instance.get_dataframe.return_value = pd.DataFrame({"dummy": [1]}) + + annotator_instance = MockAnnotator.return_value + annotator_instance.get_predicted_bound.return_value = pd.DataFrame( + {"dummy_bound": [1]} + ) + + finder_instance = MockFinder.return_value + finder_instance.amplicon_df = pd.DataFrame() + + run_specificity_check( + mock_panel, + str(tmp_path), + "genome.fa", + length_threshold=20, + evalue_threshold=5.0, + max_mismatches=1, + max_amplicon_size=5000, + ) + + # Verify annotator received custom thresholds + annotator_instance.build_annotation_dict.assert_called_once_with( + length_threshold=20, evalue_threshold=5.0, max_mismatches=1 + ) + + # Verify finder received custom max amplicon size + finder_instance.find_amplicons.assert_called_once_with(max_size_bp=5000) + + def test_run_specificity_check_no_hits(mock_panel, tmp_path): with ( patch("plexus.blast.specificity.BlastRunner") as MockRunner, @@ -323,6 +392,22 @@ def test_beyond_tolerance_is_off_target(self): } assert _is_on_target(prod, junction, pair) is False + def test_custom_tolerance(self): + """A larger tolerance classifies previously off-target hits as on-target.""" + junction = self._make_junction(chrom="chr7", design_start=1000) + pair = self._make_pair( + fwd_start=10, fwd_length=22, rev_start=180, rev_length=22 + ) + prod = { + "chrom": "chr7", + "F_start": 1020, # 10bp off from expected 1010 + "R_start": 1211, # 10bp off from expected 1201 + } + # Default tolerance=5 -> off-target + assert _is_on_target(prod, junction, pair) is False + # Custom tolerance=10 -> on-target + assert _is_on_target(prod, junction, pair, tolerance=10) is True + def test_missing_design_start_defaults_to_zero(self): """Junction without design_start uses 0 as default.""" junction = self._make_junction(chrom="chr7", design_start=None) diff --git a/tests/test_cli.py b/tests/test_cli.py index 0fd3265..85dd00c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -896,7 +896,7 @@ class TestInitWizard: @patch("plexus.resources.init_genome") @patch("plexus.resources.genome_status") @patch("plexus.resources.get_operational_mode", return_value="research") - @patch("plexus.cli._is_interactive", return_value=True) + @patch("plexus.cli_init_wizard._is_interactive", return_value=True) def test_init_wizard_activates_when_tty( self, _mock_tty, _mock_mode, mock_status, mock_init ): @@ -915,7 +915,7 @@ def test_init_wizard_activates_when_tty( fa_f.write(b">chr1\nACGT\n") try: - with patch("plexus.cli._run_init_wizard") as mock_wizard: + with patch("plexus.cli_init_wizard._run_init_wizard") as mock_wizard: mock_wizard.return_value = { "genome": "hg38", "fasta": Path(fasta_path), @@ -960,7 +960,7 @@ def test_init_flags_override_wizard(self, _mock_mode, mock_status, mock_init): fa_f.write(b">chr1\nACGT\n") try: - with patch("plexus.cli._run_init_wizard") as mock_wizard: + with patch("plexus.cli_init_wizard._run_init_wizard") as mock_wizard: result = runner.invoke( app, [ diff --git a/tests/test_config.py b/tests/test_config.py index e38bd58..b080d21 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -10,6 +10,7 @@ from pydantic import ValidationError from plexus.config import ( + BlastParameters, DesignerConfig, MultiplexPickerParameters, PCRConditions, @@ -182,6 +183,62 @@ def test_valid_plexity_range(self): assert params.maximum_plexity == 25 +class TestBlastParameters: + """Tests for BlastParameters model.""" + + def test_default_values(self): + """Test that default values match the previously hardcoded values.""" + params = BlastParameters() + assert params.length_threshold == 15 + assert params.evalue_threshold == 10.0 + assert params.max_mismatches == 2 + assert params.max_amplicon_size == 2000 + assert params.ontarget_tolerance == 5 + + def test_valid_custom_values(self): + """Test creating params with valid custom values.""" + params = BlastParameters( + length_threshold=20, + evalue_threshold=5.0, + max_mismatches=1, + max_amplicon_size=5000, + ontarget_tolerance=10, + ) + assert params.length_threshold == 20 + assert params.max_amplicon_size == 5000 + + def test_length_threshold_bounds(self): + """Test that length_threshold outside [5, 30] raises ValidationError.""" + with pytest.raises(ValidationError): + BlastParameters(length_threshold=3) + with pytest.raises(ValidationError): + BlastParameters(length_threshold=35) + + def test_max_mismatches_bounds(self): + """Test that max_mismatches outside [0, 5] raises ValidationError.""" + with pytest.raises(ValidationError): + BlastParameters(max_mismatches=-1) + with pytest.raises(ValidationError): + BlastParameters(max_mismatches=6) + + def test_evalue_must_be_positive(self): + """Test that evalue_threshold <= 0 raises ValidationError.""" + with pytest.raises(ValidationError): + BlastParameters(evalue_threshold=0.0) + + def test_present_in_designer_config(self): + """Test that BlastParameters is accessible on DesignerConfig.""" + config = DesignerConfig() + assert isinstance(config.blast_parameters, BlastParameters) + assert config.blast_parameters.length_threshold == 15 + + def test_loaded_from_preset(self): + """Test that blast_parameters loads from JSON preset files.""" + config = DesignerConfig.from_preset("default") + assert config.blast_parameters.length_threshold == 15 + assert config.blast_parameters.ontarget_tolerance == 5 + + class TestDesignerConfig: """Tests for DesignerConfig model."""