From 5bb822ea18089fafe2c0747de78fe07d16502136 Mon Sep 17 00:00:00 2001 From: jonas Date: Tue, 18 Aug 2026 13:53:10 +0200 Subject: [PATCH 01/19] small fixes to step descriptions --- web/frontend/src/components/tour/steps.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/frontend/src/components/tour/steps.js b/web/frontend/src/components/tour/steps.js index eff0f82..be5b91a 100644 --- a/web/frontend/src/components/tour/steps.js +++ b/web/frontend/src/components/tour/steps.js @@ -53,7 +53,7 @@ export function buildTourSteps({ setActiveMode, setActiveProfileMode, setAnalyze id: 'vcf-bam', targetSelector: '[data-tour-target="vcf-bam"]', title: 'VCF mode — BAM (optional)', - body: 'An optional sorted BAM file for coverage evaluation. A BAM index is generated automatically. This is only needed when you want coverage-based filtering.', + body: 'An optional sorted BAM file for coverage evaluation. A BAM index is generated automatically. This is only needed when you want coverage-annotation and filtering.', before: () => { setActiveMode('analyze'); setAnalyzeSubMode('single'); @@ -89,7 +89,7 @@ export function buildTourSteps({ setActiveMode, setActiveProfileMode, setAnalyze id: 'vcf-coverage-cutoff', targetSelector: '[data-tour-target="vcf-coverage-cutoff"]', title: 'VCF mode — coverage cutoff', - body: 'Set the minimum read depth required to include a position. Positions below this depth are excluded from variant calling.', + body: 'Set the minimum read depth required to include a position. Variants below this threshold are ignored and will not appear in the report..', before: () => { setActiveMode('analyze'); setAnalyzeSubMode('single'); @@ -149,7 +149,7 @@ export function buildTourSteps({ setActiveMode, setActiveProfileMode, setAnalyze id: 'analyze-submode', targetSelector: '.analyze-submode-row', title: 'One sample or many', - body: 'Use this toggle to switch between "One Sample" (what you just saw) and "Multiple Samples". The batch mode lets you submit up to 25 VCF or FASTA files at once with a shared reference, per-sample BAMs, and a single set of cutoffs — ideal when you have a plate or folder of samples to profile together.', + body: 'Use this toggle to switch between "One Sample" (what you just saw) and "Multiple Samples". The batch mode lets you submit multiple VCF or FASTA files at once with a shared reference, per-sample BAMs, and a single set of cutoffs — ideal when you have a plate or folder of samples to profile together.', before: () => { setActiveMode('analyze'); setAnalyzeSubMode('single'); From 410510d2f063e84bb0790158709fc1f3c64e7ea6 Mon Sep 17 00:00:00 2001 From: jonas Date: Tue, 18 Aug 2026 17:08:18 +0200 Subject: [PATCH 02/19] added example files to databases --- docs/docs/cli-reference.md | 38 +++ docs/docs/database-preparation.md | 1 + docs/docs/webapp.md | 11 +- respro/cli/fasta.py | 37 ++- respro/cli/init.py | 66 +++++ respro/cli/maintained_db.py | 1 + respro/db/rules_queries.py | 17 +- respro/db/schema.py | 2 + respro/io/maintained_db.py | 22 ++ tests/test_example_fasta.py | 276 ++++++++++++++++++ tests/test_maintained_db.py | 103 +++++++ tests/test_profile_fasta.py | 99 +++++++ tests/test_web_api.py | 75 +++++ web/backend/jobs.py | 23 +- web/backend/models.py | 3 +- web/backend/routes/profile.py | 31 +- web/backend/services/browse.py | 1 + web/backend/services/maintained_bootstrap.py | 1 + web/frontend/src/components/DashboardView.jsx | 2 + .../src/components/tabs/AnalyzeTab.jsx | 16 + web/frontend/src/hooks/useProfileSubmit.js | 36 +++ web/frontend/src/useDashboardLogic.js | 1 + web/frontend/src/useDashboardLogic.test.js | 94 ++++++ 23 files changed, 935 insertions(+), 21 deletions(-) create mode 100644 tests/test_example_fasta.py diff --git a/docs/docs/cli-reference.md b/docs/docs/cli-reference.md index c91fff1..9c0ee3e 100644 --- a/docs/docs/cli-reference.md +++ b/docs/docs/cli-reference.md @@ -56,6 +56,21 @@ respro init \ If your dataset only contains atomic mutation rules, omit `--formula-rules`. +Optionally ship a per-database example consensus FASTA that users can profile +with a single command (see [Profile FASTA input](#profile-fasta-input)) and that +the webapp exposes as an "Example" button. The example must be a single-record +FASTA: + +```bash +respro init \ + --name "Docs Demo" \ + --genbank some_reference.gb \ + --rules rules.tsv \ + --formula-rules combinatorial_rules.tsv \ + --example example_consensus.fasta \ + --output myrespro.db +``` + For metadata and interpretation algorithm options, see [Database Preparation](database-preparation.md). ## Extend or validate rules in an existing project @@ -79,6 +94,16 @@ respro add \ --formula-rules combinatorial_rules.tsv ``` +Use `--example example_consensus.fasta` to store or overwrite a per-database +example FASTA, or `--no-example` to clear a previously stored example: + +```bash +respro add \ + --project myrespro.db \ + --rules rules.tsv \ + --example example_consensus.fasta +``` + ## Profile FASTA input ```bash @@ -91,6 +116,19 @@ respro fasta \ --export pdf ``` +To profile the example consensus FASTA stored in the project database (set via +`respro init --example` or `respro add --example`), use `--example` instead of +`--fasta`. The two options are mutually exclusive: + +```bash +respro fasta \ + --project myrespro.db \ + --example \ + --output my_output +``` + +If no example is stored, the command fails with a message. + ## Profile VCF input ```bash diff --git a/docs/docs/database-preparation.md b/docs/docs/database-preparation.md index da55e7a..85732b7 100644 --- a/docs/docs/database-preparation.md +++ b/docs/docs/database-preparation.md @@ -33,6 +33,7 @@ Notes: - `--genbank` can be repeated for multiple files. - `--no-additional-info` skips network lookups for extra metadata. - `--metadata` accepts a JSON file with curated project metadata. See the section below for the supported keys and value rules. +- `--example` optionally stores a single-record consensus FASTA shipped with the database. Users can then profile it via `respro fasta --example` or via the webapp "Example" button. Use `respro add --example` to overwrite and `respro add --no-example` to clear it. - After initialization, later profiling runs use this database as the internal coordinate and rule source. After this command succeeds, the file `myrespro.db` should exist. diff --git a/docs/docs/webapp.md b/docs/docs/webapp.md index 45a001c..1b8f9cf 100644 --- a/docs/docs/webapp.md +++ b/docs/docs/webapp.md @@ -73,7 +73,7 @@ ships pre-built `.db` files does **not** need access to any of them. | Phase | Host | Purpose | |---|---|---| -| Maintained-DB bootstrap & updates | `raw.githubusercontent.com` | Fetch `manifest.json`, `rules.tsv`, `metadata.json`, `formula-rules.tsv` from `the-foxlab/respro-databases` | +| Maintained-DB bootstrap & updates | `raw.githubusercontent.com` | Fetch `manifest.json`, `rules.tsv`, `metadata.json`, `formula-rules.tsv`, and (when declared) `example.fasta` from `the-foxlab/respro-databases` | | Maintained-DB bootstrap | `eutils.ncbi.nlm.nih.gov` | Fetch GenBank reference records (`efetch.fcgi?db=nuccore`) referenced by a database's rules | | `respro init` enrichment | `pubchem.ncbi.nlm.nih.gov` | Resolve drug names to CIDs, descriptions, titles, structure images (PUG REST) | | `respro init` enrichment | `eutils.ncbi.nlm.nih.gov` | PubMed article summaries (`esummary.fcgi`), PMC ID conversion | @@ -216,6 +216,15 @@ In batch VCF mode, an optional BAM can be attached to each sample for per-sample | `RESPRO_WEB_MAINTAINED_BOOTSTRAP` | `false` | When `true` (or `1`/`yes`/`on`), missing maintained databases are downloaded into `data/project_databases/` at startup, and a weekly background thread checks for updates. | | `RESPRO_WEB_MAINTAINED_DB_UPDATE_INTERVAL_SECONDS` | `604800` (7 days) | Interval between maintained-database update checks. Set to `0` to disable the weekly thread (a one-time check still runs at startup when bootstrap is enabled). Invalid values fall back to the default. | +A maintained database may declare an optional `example_fasta_path` field in its +`manifest.json` entry. When present, the referenced single-record consensus +FASTA is downloaded as `example.fasta` alongside the rules and stored in the +project database. The webapp then shows an **Example** button next to the +"One sample" / "Multiple samples" choice in the Analyze tab for that database; +clicking it switches to FASTA mode and profiles the stored example in one step, +without requiring any user-supplied input file. The button is only shown for +databases that actually ship an example. + ### Minimal `.env` example For local development you can usually leave everything at defaults. For a public deployment, start from: diff --git a/respro/cli/fasta.py b/respro/cli/fasta.py index 5c0c752..2ed926e 100644 --- a/respro/cli/fasta.py +++ b/respro/cli/fasta.py @@ -5,6 +5,8 @@ from __future__ import annotations import logging +import os +import tempfile from pathlib import Path from typing import Annotated @@ -25,6 +27,7 @@ from respro.core.annotation import annotate_variants from respro.core.fasta_to_vcf import fasta_to_vcf from respro.core.query import resolve_fasta_query +from respro.db.rules_queries import get_project_example_fasta from respro.db.schema import open_project_db from respro.utils.cli_errors import cli_error, render_click_exception from respro.utils.logging import err_console @@ -36,9 +39,16 @@ def _profile_fasta_command( typer.Option('--project', '-p', exists=True, help='Project database.') ], consensus_fasta: Annotated[ - Path, + Path | None, typer.Option('--fasta', '-f', exists=True, help='Input consensus FASTA sequence.') - ], + ] = None, + use_example: Annotated[ + bool, + typer.Option( + '--example', + help='Profile the example consensus FASTA stored in the project database.', + ) + ] = False, sample: Annotated[ str, typer.Option('--sample', '-s', help='Sample name for the report.') @@ -87,6 +97,12 @@ def _profile_fasta_command( project_conn = None results_conn = None + if use_example and consensus_fasta is not None: + cli_error('Provide either --fasta or --example, not both.') + if not use_example and consensus_fasta is None: + cli_error('Provide --fasta or --example to specify the input consensus sequence.') + + example_temp_path: Path | None = None try: export_formats = _parse_export_formats(export) @@ -95,6 +111,21 @@ def _profile_fasta_command( if project_row is None: cli_error('No project found in the database') + # When --example is used, materialise the stored example FASTA text into a temp file so the + # existing read/align pipeline is reused unchanged. + if use_example: + example_text = get_project_example_fasta(project_conn) + if example_text is None: + cli_error( + f'No example FASTA is stored in project database {project!s}. ' + 'Add one with `respro init --example `.' + ) + temp_fd, temp_name = tempfile.mkstemp(prefix='respro_example_', suffix='.fasta') + example_temp_path = Path(temp_name) + os.close(temp_fd) + example_temp_path.write_text(example_text) + consensus_fasta = example_temp_path + results_conn = _init_results_db_connection( str(results_db) if results_db else None, project_conn, logger, ) @@ -163,6 +194,8 @@ def _profile_fasta_command( project_conn.close() if results_conn is not None: results_conn.close() + if example_temp_path is not None: + example_temp_path.unlink(missing_ok=True) def register(app: typer.Typer) -> None: diff --git a/respro/cli/init.py b/respro/cli/init.py index effa7a7..52995e9 100644 --- a/respro/cli/init.py +++ b/respro/cli/init.py @@ -25,6 +25,7 @@ from respro.db.project_metadata import load_metadata_json, store_project_metadata from respro.db.schema import PROJECT_SCHEMA_VERSION, create_schema, open_project_db from respro.io.genbank import ParsedGenBankReference, parse_genbank_sources +from respro.io.reference import read_fasta from respro.utils.cli_errors import cli_error from respro.utils.files import require_file, resolve_output_file from respro.utils.logging import err_console @@ -32,6 +33,31 @@ logger = logging.getLogger(__name__) +def _read_example_fasta_text(example_fasta: Path) -> str: + """ + Read and validate a per-database example consensus FASTA file. + + The example must contain exactly one record. Returns the original file text so the stored + blob round-trips to the same file the curator provided. + + :param example_fasta: path to the example FASTA file + :return: the raw file text + :raises ValueError: if the file is empty, unparseable, or contains more than one record + """ + require_file(example_fasta, 'Example FASTA') + text = example_fasta.read_text() + try: + records = read_fasta(example_fasta) + except Exception as exc: # Biopython raises various errors for malformed FASTA + raise ValueError(f'Example FASTA could not be parsed: {exc}') from exc + if len(records) != 1: + raise ValueError( + f'Example FASTA must contain a single record; got {len(records)}. ' + 'The example is profiled as a single consensus sequence.' + ) + return text + + # ────────────────────────────────────────────────────────────────────── # Orchestration # ────────────────────────────────────────────────────────────────────── @@ -46,6 +72,7 @@ def init_project( metadata_json: Path | None = None, overwrite: bool = False, additional_info: bool = True, + example_fasta: Path | None = None, ) -> Path: """ Create and populate a new project database. @@ -58,6 +85,7 @@ def init_project( :param overwrite: if True, delete an existing database at db_path before creating a fresh one :param additional_info: if True (default), query PubChem for drug metadata and resolve publication DOIs/titles via NCBI and CrossRef; failures are non-fatal + :param example_fasta: optional single-record consensus FASTA stored with the database as an example :return: path to the created database """ if not genbank_paths: @@ -68,6 +96,7 @@ def init_project( require_file(rules_tsv, 'Rules TSV') if formula_rules_tsv is not None: require_file(formula_rules_tsv, 'Formula rules TSV') + example_text = _read_example_fasta_text(example_fasta) if example_fasta else '' metadata_payload, algorithms = load_metadata_json(metadata_json) if metadata_json else ({}, []) genbank_records = parse_genbank_sources(genbank_paths) @@ -90,6 +119,11 @@ def init_project( additional_info=additional_info, ) store_project_metadata(conn, project_id, metadata_payload) + if example_text: + conn.execute( + 'UPDATE project SET example_fasta = ? WHERE id = ?', + (example_text, project_id), + ) if algorithms: algorithms = _sanitize_effect_as_resistant_algorithms(conn, project_id, algorithms) if algorithms: @@ -121,6 +155,8 @@ def add_to_project( genbank_paths: list[Path] | None = None, additional_info: bool = True, validate_only: bool = False, + example_fasta: Path | None = None, + clear_example: bool = False, ) -> Path: """ Add curated rules and optional GenBank annotations to an existing project. @@ -130,12 +166,17 @@ def add_to_project( :param genbank_paths: optional GenBank files with additional references/features :param additional_info: if True, query PubChem for new drugs and resolve publication metadata :param validate_only: if True, run full rules validation/import path and roll back all DB changes + :param example_fasta: optional single-record consensus FASTA replacing the stored example + :param clear_example: if True, clear any stored example FASTA :return: path to the updated database """ require_file(db_path, 'Project database') require_file(rules_tsv, 'Rules TSV') if formula_rules_tsv is not None: require_file(formula_rules_tsv, 'Formula rules TSV') + if example_fasta is not None and clear_example: + raise ValueError('Provide either --example or --no-example, not both.') + example_text = _read_example_fasta_text(example_fasta) if example_fasta else '' records: list[ParsedGenBankReference] = [] for genbank_path in genbank_paths or []: @@ -180,6 +221,13 @@ def add_to_project( apply_ic50_threshold_classification(conn, project_id, ic50_config) if additional_info: _get_drugs_from_pubchem(conn, project_id) + if example_text: + conn.execute( + 'UPDATE project SET example_fasta = ? WHERE id = ?', + (example_text, project_id), + ) + elif clear_example: + conn.execute('UPDATE project SET example_fasta = ? WHERE id = ?', ('', project_id)) conn.execute( "UPDATE project SET updated_at = datetime('now') WHERE id = ?", (project_id,), @@ -306,6 +354,12 @@ def _init_command( help='Query PubChem for drug metadata and resolve publications via NCBI/CrossRef.', ) ] = True, + example: Annotated[ + Path | None, typer.Option( + '--example', '-ex', exists=True, + help='Optional single-record consensus FASTA stored with the database as an example.', + ) + ] = None, ) -> None: """ Initialise a project database from one or more GenBank reference records and resistance rules provided in TSV. @@ -327,6 +381,7 @@ def _init_command( metadata_json=metadata, overwrite=overwrite, additional_info=additional_info, + example_fasta=example, ) except (FileExistsError, FileNotFoundError, ValueError) as exc: cli_error(str(exc)) @@ -359,6 +414,15 @@ def _init_add_command( validate: Annotated[ bool, typer.Option('--validate', '-v', help='Validate rules and exit without writing DB changes.') ] = False, + example: Annotated[ + Path | None, typer.Option( + '--example', '-ex', exists=True, + help='Replace the stored example consensus FASTA with this single-record file.', + ) + ] = None, + clear_example: Annotated[ + bool, typer.Option('--no-example', help='Clear any stored example consensus FASTA.') + ] = False, ) -> None: """ Add curated rules and optional GenBank annotations to an existing project database. @@ -373,6 +437,8 @@ def _init_add_command( formula_rules_tsv=formula_rules, additional_info=additional_info, validate_only=validate, + example_fasta=example, + clear_example=clear_example, ) except (FileExistsError, FileNotFoundError, ValueError) as exc: cli_error(str(exc)) diff --git a/respro/cli/maintained_db.py b/respro/cli/maintained_db.py index 37d698c..6931267 100644 --- a/respro/cli/maintained_db.py +++ b/respro/cli/maintained_db.py @@ -167,6 +167,7 @@ def _download_command( rules_tsv=files['rules'], # type: ignore[arg-type] formula_rules_tsv=files['formula_rules'], # type: ignore[arg-type] metadata_json=files['metadata'], # type: ignore[arg-type] + example_fasta=files['example'], # type: ignore[arg-type] overwrite=True, additional_info=additional_info, ) diff --git a/respro/db/rules_queries.py b/respro/db/rules_queries.py index f381e29..b3c91c3 100644 --- a/respro/db/rules_queries.py +++ b/respro/db/rules_queries.py @@ -671,10 +671,25 @@ def get_project_summary_for_display(conn: sqlite3.Connection) -> dict: 'SELECT id, name, uuid, created_at, schema_version, ' 'metadata_maintainers, metadata_contact, metadata_publication_pmid, ' 'metadata_publication_doi, metadata_website, metadata_description, ' - 'metadata_maintainer_update, metadata_license, metadata_tsv_checksum ' + 'metadata_maintainer_update, metadata_license, metadata_tsv_checksum, ' + 'example_fasta ' 'FROM project ORDER BY id LIMIT 1' ).fetchone() if row is None: raise ValueError('Project DB contains no project metadata.') return dict(row) + + +def get_project_example_fasta(conn: sqlite3.Connection) -> str | None: + """ + Return the stored example consensus FASTA text, or ``None`` when none is stored. + + :param conn: open project DB connection + :return: FASTA text (single record) or ``None`` if the ``example_fasta`` column is empty + """ + row = conn.execute('SELECT example_fasta FROM project ORDER BY id LIMIT 1').fetchone() + if row is None: + raise ValueError('Project DB contains no project metadata.') + value = row['example_fasta'] + return value if value else None diff --git a/respro/db/schema.py b/respro/db/schema.py index 1edabca..2b241f5 100644 --- a/respro/db/schema.py +++ b/respro/db/schema.py @@ -29,6 +29,7 @@ metadata_maintainer_update TEXT NOT NULL DEFAULT '', metadata_license TEXT NOT NULL DEFAULT '', metadata_tsv_checksum TEXT NOT NULL DEFAULT '', + example_fasta TEXT NOT NULL DEFAULT '', -- optional per-database example consensus FASTA (single record) schema_version INTEGER NOT NULL DEFAULT 1 ); @@ -404,6 +405,7 @@ 'metadata_maintainer_update': "TEXT NOT NULL DEFAULT ''", 'metadata_license': "TEXT NOT NULL DEFAULT ''", 'metadata_tsv_checksum': "TEXT NOT NULL DEFAULT ''", + 'example_fasta': "TEXT NOT NULL DEFAULT ''", }, 'reference': { 'accession': "TEXT DEFAULT ''", diff --git a/respro/io/maintained_db.py b/respro/io/maintained_db.py index dc5f3b4..4bec2f8 100644 --- a/respro/io/maintained_db.py +++ b/respro/io/maintained_db.py @@ -122,6 +122,14 @@ def download_database_files(db_name: str, dest_dir: Path) -> dict[str, object]: f'{db_name}/formula-rules.tsv', ) + example_path: Path | None = None + if 'example.fasta' in file_map: + example_path = _download_file( + file_map['example.fasta'], + dest_dir / 'example.fasta', + f'{db_name}/example.fasta', + ) + accessions = _parse_reference_identifiers(rules_path) genbank_paths = _fetch_genbank_records(accessions, dest_dir) @@ -129,6 +137,7 @@ def download_database_files(db_name: str, dest_dir: Path) -> dict[str, object]: 'rules': rules_path, 'metadata': metadata_path, 'formula_rules': formula_rules_path, + 'example': example_path, 'genbank': genbank_paths, } @@ -161,6 +170,15 @@ def list_output_files(db_name: str) -> list[dict]: } ) + example_fasta_path = entry.get('example_fasta_path', '') + if example_fasta_path: + output_files.append( + { + 'name': 'example.fasta', + 'download_url': _resolve_manifest_path_to_download_url(example_fasta_path), + } + ) + return output_files @@ -213,6 +231,10 @@ def _validate_manifest_entry(entry: object, idx: int) -> None: if not isinstance(formula_rules_path, str): raise RuntimeError(f'Malformed manifest: databases[{idx}].formula_rules_path must be a string') + example_fasta_path = entry.get('example_fasta_path', '') + if not isinstance(example_fasta_path, str): + raise RuntimeError(f'Malformed manifest: databases[{idx}].example_fasta_path must be a string') + metadata = entry.get('metadata') if not isinstance(metadata, dict): raise RuntimeError(f'Malformed manifest: databases[{idx}].metadata must be an object') diff --git a/tests/test_example_fasta.py b/tests/test_example_fasta.py new file mode 100644 index 0000000..bd4c631 --- /dev/null +++ b/tests/test_example_fasta.py @@ -0,0 +1,276 @@ +""" +Tests for the per-database example FASTA feature (Feature: db-example-fasta). + +Covers the schema storage layer: the ``project.example_fasta`` column, its auto-migration +on open, the ``get_project_example_fasta`` accessor, and the ``get_project_summary_for_display`` +SELECT surfacing the column. Also covers ``init_project``/``add_to_project`` example wiring. +""" + +from __future__ import annotations + +import sqlite3 +import textwrap +from pathlib import Path + +import pytest +from conftest import TINY_REF_SEQ, write_genbank + +from respro.cli.init import add_to_project, init_project +from respro.db.rules_queries import get_project_example_fasta, get_project_summary_for_display +from respro.db.schema import PROJECT_SCHEMA_SQL, create_schema, open_project_db + +_EXAMPLE_FASTA = '>example_sample\nATGAAAGCTTTTGGCCCC\n' + + +class TestExampleFastaSchema: + def test_new_db_has_example_fasta_column(self, tmp_path: Path) -> None: + db_path = tmp_path / 'fresh.db' + conn = create_schema(db_path) + columns = { + row['name'] + for row in conn.execute('PRAGMA table_info(project)').fetchall() + } + conn.close() + + assert 'example_fasta' in columns + + def test_example_fasta_defaults_empty_for_new_db(self, tmp_path: Path) -> None: + db_path = tmp_path / 'fresh.db' + conn = create_schema(db_path) + conn.execute("INSERT INTO project (name, schema_version, uuid) VALUES ('P', 1, 'u')") + value = conn.execute('SELECT example_fasta FROM project').fetchone()[0] + conn.close() + + assert value == '' + + def test_open_project_db_auto_adds_example_fasta_column(self, tmp_path: Path) -> None: + # Build a complete DB, then strip the example_fasta column to simulate a legacy DB. + db_path = tmp_path / 'legacy.db' + conn = create_schema(db_path) + conn.execute("INSERT INTO project (name, schema_version, uuid) VALUES ('Legacy', 1, 'u')") + conn.execute( + 'CREATE TABLE project_legacy AS ' + 'SELECT id, name, uuid, created_at, updated_at, schema_version, ' + 'metadata_maintainers, metadata_contact, metadata_publication_pmid, ' + 'metadata_publication_doi, metadata_website, metadata_description, ' + 'metadata_maintainer_update, metadata_license, metadata_tsv_checksum ' + 'FROM project' + ) + conn.execute('DROP TABLE project') + conn.execute('ALTER TABLE project_legacy RENAME TO project') + conn.commit() + conn.close() + + migrated_conn = open_project_db(db_path) + columns = { + row['name'] + for row in migrated_conn.execute('PRAGMA table_info(project)').fetchall() + } + value = migrated_conn.execute('SELECT example_fasta FROM project').fetchone()[0] + migrated_conn.close() + + assert 'example_fasta' in columns + assert value == '' + + def test_get_project_example_fasta_returns_none_when_empty(self, tmp_path: Path) -> None: + db_path = tmp_path / 'fresh.db' + conn = create_schema(db_path) + conn.execute("INSERT INTO project (name, schema_version, uuid) VALUES ('P', 1, 'u')") + + assert get_project_example_fasta(conn) is None + conn.close() + + def test_get_project_example_fasta_returns_stored_text(self, tmp_path: Path) -> None: + db_path = tmp_path / 'fresh.db' + conn = create_schema(db_path) + conn.execute("INSERT INTO project (name, schema_version, uuid) VALUES ('P', 1, 'u')") + conn.execute('UPDATE project SET example_fasta = ? WHERE id = 1', (_EXAMPLE_FASTA,)) + + assert get_project_example_fasta(conn) == _EXAMPLE_FASTA + conn.close() + + def test_get_project_example_fasta_raises_when_no_project_row(self, tmp_path: Path) -> None: + db_path = tmp_path / 'fresh.db' + conn = create_schema(db_path) + + with pytest.raises(ValueError): + get_project_example_fasta(conn) + conn.close() + + def test_summary_for_display_includes_example_fasta(self, tmp_path: Path) -> None: + db_path = tmp_path / 'fresh.db' + conn = create_schema(db_path) + conn.execute("INSERT INTO project (name, schema_version, uuid) VALUES ('P', 1, 'u')") + conn.execute('UPDATE project SET example_fasta = ? WHERE id = 1', (_EXAMPLE_FASTA,)) + + summary = get_project_summary_for_display(conn) + conn.close() + + assert summary['example_fasta'] == _EXAMPLE_FASTA + + def test_example_fasta_column_in_schema_sql(self) -> None: + assert 'example_fasta' in PROJECT_SCHEMA_SQL + + +# ─── init_project / add_to_project ─────────────────────────────────── + +_VALID_EXAMPLE = '>example_sample\nATGAAAGCTTTTGGCCCC\n' +_MULTI_RECORD_EXAMPLE = '>a\nATGAAA\n>b\nGGGCCC\n' +_INVALID_FASTA = 'not a fasta at all\nno header line\n' + + +def _tiny_genbank(tmp_path: Path) -> Path: + gb = tmp_path / 'tiny.gb' + write_genbank(gb, [ + { + 'id': 'tiny_ref', + 'accession': 'tiny_ref', + 'sequence': TINY_REF_SEQ, + 'features': [{'feature': 'gag', 'protein': 'Gag', 'start': 1, 'end': 87, 'strand': '+'}], + } + ]) + return gb + + +def _rules_tsv(tmp_path: Path) -> Path: + tsv = tmp_path / 'rules.tsv' + tsv.write_text(textwrap.dedent("""\ + feature\treference_identifier\tposition\treference\tmutation\tantiviral\tphenotype + gag\ttiny_ref\t2\tK\tE\tDrugA\tresistant + """)) + return tsv + + +class TestInitProjectExample: + def test_init_stores_example_fasta_text(self, tmp_path: Path) -> None: + example = tmp_path / 'example.fasta' + example.write_text(_VALID_EXAMPLE) + db = tmp_path / 'proj.db' + init_project( + db_path=db, + name='test', + genbank_paths=[_tiny_genbank(tmp_path)], + rules_tsv=_rules_tsv(tmp_path), + example_fasta=example, + additional_info=False, + ) + + conn = open_project_db(db) + assert get_project_example_fasta(conn) == _VALID_EXAMPLE + conn.close() + + def test_init_without_example_leaves_empty(self, tmp_path: Path) -> None: + db = tmp_path / 'proj.db' + init_project( + db_path=db, + name='test', + genbank_paths=[_tiny_genbank(tmp_path)], + rules_tsv=_rules_tsv(tmp_path), + additional_info=False, + ) + + conn = open_project_db(db) + assert get_project_example_fasta(conn) is None + conn.close() + + def test_init_rejects_multi_record_example(self, tmp_path: Path) -> None: + example = tmp_path / 'example.fasta' + example.write_text(_MULTI_RECORD_EXAMPLE) + db = tmp_path / 'proj.db' + with pytest.raises(ValueError, match='single record'): + init_project( + db_path=db, + name='test', + genbank_paths=[_tiny_genbank(tmp_path)], + rules_tsv=_rules_tsv(tmp_path), + example_fasta=example, + additional_info=False, + ) + assert not db.exists() + + def test_init_rejects_invalid_fasta(self, tmp_path: Path) -> None: + example = tmp_path / 'example.fasta' + example.write_text(_INVALID_FASTA) + db = tmp_path / 'proj.db' + with pytest.raises(ValueError, match='(?i)example fasta'): + init_project( + db_path=db, + name='test', + genbank_paths=[_tiny_genbank(tmp_path)], + rules_tsv=_rules_tsv(tmp_path), + example_fasta=example, + additional_info=False, + ) + assert not db.exists() + + +class TestAddToProjectExample: + def test_add_overwrites_existing_example(self, tmp_path: Path) -> None: + example = tmp_path / 'example.fasta' + example.write_text(_VALID_EXAMPLE) + db = tmp_path / 'proj.db' + init_project( + db_path=db, + name='test', + genbank_paths=[_tiny_genbank(tmp_path)], + rules_tsv=_rules_tsv(tmp_path), + example_fasta=example, + additional_info=False, + ) + + new_example = tmp_path / 'example2.fasta' + new_example.write_text('>other\nGGGCCC\n') + add_to_project( + db_path=db, + rules_tsv=_rules_tsv(tmp_path), + example_fasta=new_example, + additional_info=False, + ) + + conn = open_project_db(db) + assert get_project_example_fasta(conn) == '>other\nGGGCCC\n' + conn.close() + + def test_add_clears_example_when_none(self, tmp_path: Path) -> None: + example = tmp_path / 'example.fasta' + example.write_text(_VALID_EXAMPLE) + db = tmp_path / 'proj.db' + init_project( + db_path=db, + name='test', + genbank_paths=[_tiny_genbank(tmp_path)], + rules_tsv=_rules_tsv(tmp_path), + example_fasta=example, + additional_info=False, + ) + + add_to_project( + db_path=db, + rules_tsv=_rules_tsv(tmp_path), + example_fasta=None, + clear_example=True, + additional_info=False, + ) + + conn = open_project_db(db) + assert get_project_example_fasta(conn) is None + conn.close() + + def test_add_rejects_multi_record_example(self, tmp_path: Path) -> None: + db = tmp_path / 'proj.db' + init_project( + db_path=db, + name='test', + genbank_paths=[_tiny_genbank(tmp_path)], + rules_tsv=_rules_tsv(tmp_path), + additional_info=False, + ) + example = tmp_path / 'example.fasta' + example.write_text(_MULTI_RECORD_EXAMPLE) + with pytest.raises(ValueError, match='single record'): + add_to_project( + db_path=db, + rules_tsv=_rules_tsv(tmp_path), + example_fasta=example, + additional_info=False, + ) diff --git a/tests/test_maintained_db.py b/tests/test_maintained_db.py index 7bbc248..ef4edb5 100644 --- a/tests/test_maintained_db.py +++ b/tests/test_maintained_db.py @@ -84,6 +84,16 @@ def _manifest_with_formula_rules_path(formula_rules_path: str) -> dict: manifest['databases'][0]['formula_rules_path'] = formula_rules_path return manifest + +def _manifest_with_example_fasta_path(example_fasta_path: str) -> dict: + """Return a manifest copy with a custom example_fasta_path for the hsv entry.""" + manifest = json.loads(json.dumps(_MANIFEST)) + manifest['databases'][0]['example_fasta_path'] = example_fasta_path + return manifest + + +_EXAMPLE_FASTA_CONTENT = b'>example_sample\nATGAAAGCTTTTGGCCCC\n' + _RULES_TSV_CONTENT = ( 'feature\tposition\tref_aa\tmut_aa\tphenotype\treference_identifier\n' 'UL23\t168\tA\tT\tresistant\tX04770\n' @@ -189,6 +199,54 @@ def test_raises_on_http_error(self) -> None: list_output_files('hsv_daehne_jaki') +# ── list_output_files: example_fasta_path ───────────────────────────────────── + +class TestListOutputFilesExampleFasta: + def test_includes_example_fasta_when_path_present(self) -> None: + manifest = _manifest_with_example_fasta_path( + 'databases/hsv_daehne_jaki/output/example.fasta' + ) + with patch('urllib.request.urlopen', return_value=_json_mock(manifest)): + result = list_output_files('hsv_daehne_jaki') + names = {entry['name'] for entry in result} + assert 'example.fasta' in names + + def test_omits_example_fasta_when_path_absent(self) -> None: + with patch('urllib.request.urlopen', return_value=_json_mock(_MANIFEST)): + result = list_output_files('hsv_daehne_jaki') + names = {entry['name'] for entry in result} + assert 'example.fasta' not in names + + def test_omits_example_fasta_when_path_empty(self) -> None: + manifest = _manifest_with_example_fasta_path('') + with patch('urllib.request.urlopen', return_value=_json_mock(manifest)): + result = list_output_files('hsv_daehne_jaki') + names = {entry['name'] for entry in result} + assert 'example.fasta' not in names + + +# ── _validate_manifest_entry: example_fasta_path ────────────────────────────── + +class TestValidateManifestEntryExampleFasta: + def test_accepts_absent_example_fasta_path(self) -> None: + # The default _MANIFEST has no example_fasta_path; listing must not raise. + with patch('urllib.request.urlopen', return_value=_json_mock(_MANIFEST)): + result = list_maintained_databases() + assert 'hsv_daehne_jaki' in result + + def test_accepts_empty_example_fasta_path(self) -> None: + manifest = _manifest_with_example_fasta_path('') + with patch('urllib.request.urlopen', return_value=_json_mock(manifest)): + result = list_maintained_databases() + assert 'hsv_daehne_jaki' in result + + def test_rejects_non_string_example_fasta_path(self) -> None: + manifest = _manifest_with_example_fasta_path(123) # type: ignore[arg-type] + with patch('urllib.request.urlopen', return_value=_json_mock(manifest)): + with pytest.raises(RuntimeError, match='example_fasta_path must be a string'): + list_maintained_databases() + + # ── _parse_reference_identifiers ───────────────────────────────────────────── class TestParseReferenceIdentifiers: @@ -404,6 +462,45 @@ def se(*args, **kwargs): # noqa: ANN001, ANN202 assert result['formula_rules'] is None + def test_downloads_example_fasta_when_path_present(self, tmp_path: Path) -> None: + rules_bytes = _RULES_TSV_CONTENT.encode() + meta_bytes = json.dumps(_METADATA).encode() + manifest = _manifest_with_example_fasta_path( + 'databases/hsv_daehne_jaki/output/example.fasta' + ) + + responses = [ + _json_mock(manifest), # manifest fetch + _bytes_mock(rules_bytes), # rules.tsv + _bytes_mock(meta_bytes), # metadata.json + _bytes_mock(b'feature\tformula\n'), # formula-rules.tsv + _bytes_mock(_EXAMPLE_FASTA_CONTENT), # example.fasta + _bytes_mock(_GENBANK_CONTENT), # X04770.gb + _bytes_mock(_GENBANK_CONTENT), # X04771.gb + ] + call_count = [0] + + def se(*args, **kwargs): # noqa: ANN001, ANN202 + idx = call_count[0] + call_count[0] += 1 + return responses[min(idx, len(responses) - 1)] + + with patch('urllib.request.urlopen', side_effect=se): + result = download_database_files('hsv_daehne_jaki', tmp_path) + + assert isinstance(result['example'], Path) + assert result['example'].read_bytes() == _EXAMPLE_FASTA_CONTENT + + def test_example_is_none_when_path_absent(self, tmp_path: Path) -> None: + rules_bytes = _RULES_TSV_CONTENT.encode() + meta_bytes = json.dumps(_METADATA).encode() + + se = self._make_download_side_effect(rules_bytes, meta_bytes) + with patch('urllib.request.urlopen', side_effect=se): + result = download_database_files('hsv_daehne_jaki', tmp_path) + + assert result['example'] is None + # ── CLI: databases --list ──────────────────────────────────────────────── @@ -466,6 +563,7 @@ def test_download_calls_init_project(self, tmp_path: Path) -> None: 'rules': tmp_path / 'rules.tsv', 'metadata': tmp_path / 'metadata.json', 'formula_rules': None, + 'example': None, 'genbank': [fake_gb], } @@ -493,6 +591,7 @@ def test_download_fails_when_no_genbank(self, tmp_path: Path) -> None: 'rules': tmp_path / 'rules.tsv', 'metadata': tmp_path / 'metadata.json', 'formula_rules': None, + 'example': None, 'genbank': [], } with patch('respro.cli.maintained_db.download_database_files', return_value=fake_files): @@ -524,6 +623,7 @@ def test_download_outputs_success_path(self, tmp_path: Path) -> None: 'rules': tmp_path / 'rules.tsv', 'metadata': tmp_path / 'metadata.json', 'formula_rules': None, + 'example': None, 'genbank': [fake_gb], } with ( @@ -545,6 +645,7 @@ def test_download_uses_name_db_default_when_output_omitted(self, tmp_path: Path) 'rules': tmp_path / 'rules.tsv', 'metadata': tmp_path / 'metadata.json', 'formula_rules': None, + 'example': None, 'genbank': [fake_gb], } @@ -568,6 +669,7 @@ def test_download_treats_output_without_suffix_as_directory(self, tmp_path: Path 'rules': tmp_path / 'rules.tsv', 'metadata': tmp_path / 'metadata.json', 'formula_rules': None, + 'example': None, 'genbank': [fake_gb], } @@ -592,6 +694,7 @@ def test_download_disables_additional_info_with_flag(self, tmp_path: Path) -> No 'rules': tmp_path / 'rules.tsv', 'metadata': tmp_path / 'metadata.json', 'formula_rules': None, + 'example': None, 'genbank': [fake_gb], } diff --git a/tests/test_profile_fasta.py b/tests/test_profile_fasta.py index 4e4aa22..2311fe5 100644 --- a/tests/test_profile_fasta.py +++ b/tests/test_profile_fasta.py @@ -1451,3 +1451,102 @@ def test_divergent_sequence_real_codon_deletion_detected(self) -> None: f'3-nt deletion, got {len(deletions)}: ' f'{[(a.codon_pos, a.ref_aa, a.alt_aa) for a in deletions]}' ) + + +class TestFastaExampleFlag: + """End-to-end CLI tests for ``respro fasta --example`` (Feature: db-example-fasta).""" + + @pytest.fixture() + def fasta_db_with_example(self, fasta_db: Path) -> Path: + """Return a fasta_db that has a single-record example consensus FASTA stored.""" + mutant = 'ATG' + 'GAA' + TINY_REF_SEQ[6:] # K→E at codon 1 → resistance hit + example_text = f'>stored_example\n{mutant}\n' + conn = open_project_db(fasta_db) + conn.execute('UPDATE project SET example_fasta = ? WHERE id = 1', (example_text,)) + conn.commit() + conn.close() + return fasta_db + + def test_example_profiles_stored_fasta( + self, fasta_db_with_example: Path, tmp_path: Path, + ) -> None: + """``--example`` profiles the stored example and produces the same hit as ``--fasta``.""" + output_dir = tmp_path / 'out' + result = CliRunner().invoke(app, [ + 'fasta', + '--project', str(fasta_db_with_example), + '--example', + '--output', str(output_dir), + ]) + + assert result.exit_code == 0, result.output + assert '1 total database hits' in result.output + + def test_example_matches_explicit_fasta_result( + self, fasta_db_with_example: Path, tmp_path: Path, + ) -> None: + """``--example`` and ``--fasta`` with the same sequence yield identical hit counts.""" + mutant = 'ATG' + 'GAA' + TINY_REF_SEQ[6:] + fasta_path = tmp_path / 'explicit.fasta' + fasta_path.write_text(f'>explicit\n{mutant}\n') + + out_example = tmp_path / 'out_example' + out_explicit = tmp_path / 'out_explicit' + runner = CliRunner() + res_example = runner.invoke(app, [ + 'fasta', '--project', str(fasta_db_with_example), + '--example', '--output', str(out_example), + ]) + res_explicit = runner.invoke(app, [ + 'fasta', '--project', str(fasta_db_with_example), + '--fasta', str(fasta_path), '--output', str(out_explicit), + ]) + + assert res_example.exit_code == 0, res_example.output + assert res_explicit.exit_code == 0, res_explicit.output + assert '1 total database hits' in res_example.output + assert '1 total database hits' in res_explicit.output + + def test_example_errors_when_db_has_no_example(self, fasta_db: Path, tmp_path: Path) -> None: + """``--example`` on a DB without a stored example fails with a clear message.""" + output_dir = tmp_path / 'out' + result = CliRunner().invoke(app, [ + 'fasta', + '--project', str(fasta_db), + '--example', + '--output', str(output_dir), + ]) + + assert result.exit_code != 0 + clean = _strip_ansi(result.output) + assert 'example' in clean.lower() + + def test_example_and_fasta_are_mutually_exclusive( + self, fasta_db_with_example: Path, tmp_path: Path, + ) -> None: + """Providing both ``--example`` and ``--fasta`` is an error.""" + fasta_path = tmp_path / 'extra.fasta' + fasta_path.write_text(f'>x\n{TINY_REF_SEQ}\n') + output_dir = tmp_path / 'out' + result = CliRunner().invoke(app, [ + 'fasta', + '--project', str(fasta_db_with_example), + '--fasta', str(fasta_path), + '--example', + '--output', str(output_dir), + ]) + + assert result.exit_code != 0 + + def test_neither_example_nor_fasta_errors( + self, fasta_db_with_example: Path, tmp_path: Path, + ) -> None: + """Providing neither ``--example`` nor ``--fasta`` is an error.""" + output_dir = tmp_path / 'out' + result = CliRunner().invoke(app, [ + 'fasta', + '--project', str(fasta_db_with_example), + '--output', str(output_dir), + ]) + + assert result.exit_code != 0 diff --git a/tests/test_web_api.py b/tests/test_web_api.py index 0c684a4..d69e5dd 100644 --- a/tests/test_web_api.py +++ b/tests/test_web_api.py @@ -623,6 +623,31 @@ def test_databases_endpoint_includes_project_metadata( assert database['metadata']['contact'] == 'team@example.org' assert database['metadata']['license'] == 'MIT' + def test_databases_endpoint_has_example_false_by_default(self, client: TestClient) -> None: + response = client.get('/api/databases') + assert response.status_code == 200 + database = response.json()['data']['items'][0] + assert database['has_example'] is False + + def test_databases_endpoint_has_example_true_when_stored( + self, + client: TestClient, + startup_config: StartupConfig, + ) -> None: + project_db = sorted(startup_config.project_databases_dir.glob('*.db'))[0] + conn = sqlite3.connect(project_db) + conn.execute( + "UPDATE project SET example_fasta = ? WHERE id = 1", + ('>example\nATGAAAGCT\n',), + ) + conn.commit() + conn.close() + + response = client.get('/api/databases') + assert response.status_code == 200 + database = response.json()['data']['items'][0] + assert database['has_example'] is True + def test_mutations_endpoint_alias(self, client: TestClient) -> None: project_db = sorted(client.app.state.startup_config.project_databases_dir.glob('*.db'))[0] conn = sqlite3.connect(project_db) @@ -713,6 +738,56 @@ def test_profile_fasta( report_payload = _download_artifact_json(client, json_id) assert report_payload['run']['vcf_path'] == 'original-upload.fasta' + def test_profile_fasta_use_example_profiles_stored_example( + self, + client: TestClient, + startup_config: StartupConfig, + ) -> None: + """use_example=true enqueues a fasta --example job without an uploaded FASTA.""" + project_db = sorted(startup_config.project_databases_dir.glob('*.db'))[0] + conn = sqlite3.connect(project_db) + # Store the tiny reference itself as the example so it profiles cleanly (0 hits). + conn.execute( + "UPDATE project SET example_fasta = ? WHERE id = 1", + (f'>stored_example\n{TINY_REF_SEQ}\n',), + ) + conn.commit() + conn.close() + + submit = client.post( + '/api/profile/fasta', + json={ + 'use_example': True, + 'sample': 'web-example', + }, + ) + assert submit.status_code == 200, submit.text + job_id = submit.json()['job_id'] + assert job_id + + payload = _poll_job(client, job_id) + assert payload['status'] == 'succeeded', payload + result = payload['result'] + assert result['database_id'] == project_db.name + assert result['sample_name'] == 'web-example' + + def test_profile_fasta_use_example_rejects_missing_example( + self, + client: TestClient, + startup_config: StartupConfig, + ) -> None: + """use_example=true on a DB without an example fails the job with a clear error.""" + submit = client.post( + '/api/profile/fasta', + json={'use_example': True, 'sample': 'web-example'}, + ) + assert submit.status_code == 200 + job_id = submit.json()['job_id'] + + payload = _poll_job(client, job_id) + assert payload['status'] == 'failed' + assert 'example' in (payload.get('error') or '').lower() + def test_profile_fasta_path_outside_uploads_rejected( self, client: TestClient, diff --git a/web/backend/jobs.py b/web/backend/jobs.py index 26b445a..415e50c 100644 --- a/web/backend/jobs.py +++ b/web/backend/jobs.py @@ -25,16 +25,17 @@ def run_profile_fasta( *, project_db: str, output_dir: str, - fasta_path: str, + fasta_path: str | None, sample: str, threads: int, input_display_name: str | None = None, artifact_base_name: str | None = None, + use_example: bool = False, ) -> dict: """RQ job wrapper for FASTA profiling.""" output_html_path = _build_web_output_html_path( output_dir=Path(output_dir), - input_name=artifact_base_name or input_display_name or Path(fasta_path).name, + input_name=artifact_base_name or input_display_name or (Path(fasta_path).name if fasta_path else 'example'), ) return _run_job_with_logging( mode='fasta', @@ -43,10 +44,11 @@ def run_profile_fasta( job_func=lambda: _run_profile_fasta_subprocess( project_db=Path(project_db), output_html_path=output_html_path, - fasta_path=Path(fasta_path), + fasta_path=Path(fasta_path) if fasta_path else None, sample=sample, threads=threads, input_display_name=input_display_name, + use_example=use_example, ), ) @@ -116,10 +118,11 @@ def _run_profile_fasta_subprocess( *, project_db: Path, output_html_path: Path, - fasta_path: Path, + fasta_path: Path | None, sample: str, threads: int, input_display_name: str | None, + use_example: bool = False, ) -> dict: """Execute FASTA profiling through the respro CLI and return the web API payload.""" command = [ @@ -127,8 +130,12 @@ def _run_profile_fasta_subprocess( 'fasta', '--project', str(project_db), - '--fasta', - str(fasta_path), + ] + if use_example: + command.append('--example') + else: + command.extend(['--fasta', str(fasta_path)]) + command.extend([ '--sample', sample, '--output', @@ -140,7 +147,7 @@ def _run_profile_fasta_subprocess( 'json', '--export', 'pdf', - ] + ]) if input_display_name: command.extend(['--input-display-name', input_display_name]) @@ -154,7 +161,7 @@ def _run_profile_fasta_subprocess( 'sample_name': run_payload.get('sample_name', sample), 'created_at': run_payload.get('created_at', ''), 'reference_name': run_payload.get('reference_name', ''), - 'query_name': _read_first_fasta_name(fasta_path), + 'query_name': _read_first_fasta_name(fasta_path) if fasta_path else 'example', 'report_html_path': str(artifacts['html']), 'report_json_path': str(artifacts['json']), 'report_pdf_path': str(artifacts['pdf']), diff --git a/web/backend/models.py b/web/backend/models.py index 71b0df6..569294e 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -15,7 +15,8 @@ class ProfileFastaPayload(BaseModel): ownership-checks them server-side. """ - fasta_id: str = Field(max_length=WEB_BACKEND_CONFIG.defaults.opaque_id_max_length) + fasta_id: str | None = Field(default=None, max_length=WEB_BACKEND_CONFIG.defaults.opaque_id_max_length) + use_example: bool = False input_display_name: str | None = Field(default=None, max_length=WEB_BACKEND_CONFIG.defaults.display_name_max_length) database_id: str | None = Field(default=None, max_length=WEB_BACKEND_CONFIG.defaults.opaque_id_max_length) sample: str | None = Field(default=None, max_length=WEB_BACKEND_CONFIG.defaults.sample_name_max_length) diff --git a/web/backend/routes/profile.py b/web/backend/routes/profile.py index 5631561..a302a79 100644 --- a/web/backend/routes/profile.py +++ b/web/backend/routes/profile.py @@ -55,12 +55,22 @@ def profile_fasta_route( queue: Queue = Depends(get_queue), ) -> JobSubmitResponse: session = get_session(request) - fasta_path = _resolve_upload_path( - payload.fasta_id, - session.session_hash, - config.allowed_roots, - label='FASTA', - ) + if payload.use_example: + fasta_path = None + fasta_id = None + else: + if not payload.fasta_id: + raise HTTPException( + status_code=422, + detail='fasta_id is required when use_example is false.', + ) + fasta_path = _resolve_upload_path( + payload.fasta_id, + session.session_hash, + config.allowed_roots, + label='FASTA', + ) + fasta_id = payload.fasta_id consume_sample_quota( request, sample_count=1, @@ -73,13 +83,18 @@ def profile_fasta_route( run_profile_fasta, project_db=str(project_db), output_dir=str(config.results_dir), - fasta_path=str(fasta_path), + fasta_path=str(fasta_path) if fasta_path else None, + use_example=payload.use_example, sample=payload.sample or defaults.profile_sample_name, threads=payload.threads if payload.threads is not None else defaults.profile_threads, input_display_name=payload.input_display_name, **enqueue_options, ) - record_job(session_hash=session.session_hash, upload_ids=[payload.fasta_id], job_id=job.id) + record_job( + session_hash=session.session_hash, + upload_ids=[fasta_id] if fasta_id else [], + job_id=job.id, + ) logger.info('Queue job enqueued: job_id=%s mode=fasta database_id=%s', job.id, project_db.name) return JobSubmitResponse(job_id=job.id) diff --git a/web/backend/services/browse.py b/web/backend/services/browse.py index 9478a5d..d6732f2 100644 --- a/web/backend/services/browse.py +++ b/web/backend/services/browse.py @@ -67,6 +67,7 @@ def list_databases(project_databases_dir: Path) -> dict: 'schema_version': project_row['schema_version'], 'supported_organisms': organisms, 'mutation_count': mutation_count, + 'has_example': bool(project_row.get('example_fasta')), 'metadata': metadata, 'algorithms': algorithms, } diff --git a/web/backend/services/maintained_bootstrap.py b/web/backend/services/maintained_bootstrap.py index b1b9784..d44bc70 100644 --- a/web/backend/services/maintained_bootstrap.py +++ b/web/backend/services/maintained_bootstrap.py @@ -130,6 +130,7 @@ def _rebuild_database_files(name: str, dest_db_path: Path) -> None: rules_tsv=files['rules'], # type: ignore[arg-type] formula_rules_tsv=files['formula_rules'], # type: ignore[arg-type] metadata_json=files['metadata'], # type: ignore[arg-type] + example_fasta=files['example'], # type: ignore[arg-type] overwrite=False, additional_info=True, ) diff --git a/web/frontend/src/components/DashboardView.jsx b/web/frontend/src/components/DashboardView.jsx index cb5c8fc..1566eab 100644 --- a/web/frontend/src/components/DashboardView.jsx +++ b/web/frontend/src/components/DashboardView.jsx @@ -75,6 +75,7 @@ export function DashboardView({ isCancelingJob, cancelActiveJob, runSelectedProfile, + runExampleProfile, buildReportUrl, buildArtifactUrl, uploadFastaFile, @@ -207,6 +208,7 @@ export function DashboardView({ isCancelingJob={isCancelingJob} cancelActiveJob={cancelActiveJob} runSelectedProfile={runSelectedProfile} + runExampleProfile={runExampleProfile} statusError={statusError} selectedProfileReportPath={selectedProfileReportPath} setSelectedProfileReportPath={setSelectedProfileReportPath} diff --git a/web/frontend/src/components/tabs/AnalyzeTab.jsx b/web/frontend/src/components/tabs/AnalyzeTab.jsx index f5cac84..5d217c9 100644 --- a/web/frontend/src/components/tabs/AnalyzeTab.jsx +++ b/web/frontend/src/components/tabs/AnalyzeTab.jsx @@ -34,6 +34,7 @@ export function AnalyzeTab({ isCancelingJob, cancelActiveJob, runSelectedProfile, + runExampleProfile, statusError, selectedProfileReportPath, setSelectedProfileReportPath, @@ -159,6 +160,21 @@ export function AnalyzeTab({ {subMode.label} ))} + {selectedDatabase?.has_example ? ( + + ) : null} diff --git a/web/frontend/src/hooks/useProfileSubmit.js b/web/frontend/src/hooks/useProfileSubmit.js index b3ecadc..8815407 100644 --- a/web/frontend/src/hooks/useProfileSubmit.js +++ b/web/frontend/src/hooks/useProfileSubmit.js @@ -96,6 +96,41 @@ export function useProfileSubmit({ } }; + const runExampleProfile = async () => { + // Profile the per-database example consensus FASTA stored in the selected database. + const database = databases.find((item) => item.id === selectedDatabaseId) || null; + const sampleName = database + ? `${database.display_name || database.id} example` + : 'example'; + setIsProcessingFasta(true); + setStatusError(''); + try { + const payload = { + use_example: true, + sample: sampleName, + database_id: selectedDatabaseId, + threads: FRONTEND_CONFIG.profile.threads, + }; + const submitResponse = await apiPost('/api/profile/fasta', payload); + isCancellationRequested.current = false; + setActiveJobId(submitResponse.job_id); + setActiveJobStatus('queued'); + const result = await pollJob(submitResponse.job_id); + setSessionResults((prev) => [...prev, result]); + addResultArtifactPaths(result); + setSelectedProfileReportPath(result.report_html_path); + setInlineReportPath(result.report_html_path); + setInlineReportLabel(`${result.sample_name} (${result.reference_name}) - ${formatResultTimestamp(result.created_at)}`); + } catch (error) { + setStatusError(formatUserError(error.message)); + } finally { + setIsProcessingFasta(false); + setIsCancelingJob(false); + setActiveJobId(''); + setActiveJobStatus(''); + } + }; + const submitVcf = async () => { const databaseId = selectedDatabaseId; setIsProcessingVcf(true); @@ -275,6 +310,7 @@ export function useProfileSubmit({ buildArtifactUrl, cancelActiveJob, runSelectedProfile, + runExampleProfile, uploadFastaFile, uploadVcfFile, uploadReferenceFile, diff --git a/web/frontend/src/useDashboardLogic.js b/web/frontend/src/useDashboardLogic.js index e1eda28..fdb14f0 100644 --- a/web/frontend/src/useDashboardLogic.js +++ b/web/frontend/src/useDashboardLogic.js @@ -214,6 +214,7 @@ export function useDashboardLogic() { isCancelingJob: profile.isCancelingJob, cancelActiveJob: profile.cancelActiveJob, runSelectedProfile: profile.runSelectedProfile, + runExampleProfile: profile.runExampleProfile, buildReportUrl: profile.buildReportUrl, buildArtifactUrl: profile.buildArtifactUrl, uploadFastaFile: profile.uploadFastaFile, diff --git a/web/frontend/src/useDashboardLogic.test.js b/web/frontend/src/useDashboardLogic.test.js index c50fc3d..bf7ec60 100644 --- a/web/frontend/src/useDashboardLogic.test.js +++ b/web/frontend/src/useDashboardLogic.test.js @@ -860,6 +860,100 @@ describe('useDashboardLogic - Report Display Flow', () => { }); }); +describe('useDashboardLogic - Example FASTA profile', () => { + beforeEach(() => { + vi.clearAllMocks(); + global.fetch.mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('runExampleProfile posts use_example:true to /api/profile/fasta and stores the result', async () => { + global.fetch.mockImplementation((url, options = {}) => { + const requestUrl = String(url); + + if (requestUrl.includes('/api/ui/config')) { + return Promise.resolve({ ok: true, json: async () => ({ data: {} }) }); + } + + if (requestUrl.includes('/api/ui/legal')) { + return Promise.resolve({ ok: true, json: async () => ({ data: { enabled: false } }) }); + } + + if (requestUrl.includes('/api/databases')) { + return Promise.resolve({ + ok: true, + json: async () => ({ + data: { + items: [{ + id: 'db1', + display_name: 'HIVdb', + has_example: true, + }], + }, + }), + }); + } + + if (requestUrl.includes('/api/mutations')) { + return Promise.resolve({ + ok: true, + json: async () => ({ data: { items: [], columns: [] } }), + }); + } + + if (requestUrl.includes('/api/profile/fasta') && options.method === 'POST') { + const body = JSON.parse(options.body || '{}'); + expect(body.use_example).toBe(true); + expect(body.fasta_id).toBeUndefined(); + return Promise.resolve({ ok: true, json: async () => ({ job_id: 'job-example' }) }); + } + + if (requestUrl.includes('/api/jobs/job-example')) { + return Promise.resolve({ + ok: true, + json: async () => ({ + status: 'succeeded', + result: { + sample_name: 'HIVdb example', + reference_name: 'HIV', + database_id: 'db1', + report_html_path: '/data/results/example.report.html', + created_at: '2026-05-12T10:00:00', + resistance_hits: 3, + mode: 'fasta', + }, + }), + }); + } + + return Promise.resolve({ ok: true, json: async () => ({}) }); + }); + + const { result } = renderHook(() => useDashboardLogic()); + + await waitFor(() => { + expect(result.current.databases.length).toBeGreaterThan(0); + }); + + await act(async () => { + result.current.setSelectedDatabaseId('db1'); + }); + + await act(async () => { + await result.current.runExampleProfile(); + }); + + await waitFor(() => { + expect(result.current.reportOptions.length).toBe(1); + }); + + expect(result.current.reportOptions[0].label).toContain('HIVdb example'); + }); +}); + describe('_resolveLegalLink', () => { // The footer "Legal notice" href is derived from the /api/ui/legal payload. // Disabled → null (no footer). URL mode → external link. Path mode → /legal route. From c0e27d2f5ec0eead697612ebf3bd4a0fd0ae3125 Mon Sep 17 00:00:00 2001 From: jonas Date: Tue, 18 Aug 2026 18:43:54 +0200 Subject: [PATCH 03/19] moved button --- tests/test_example_fasta.py | 1 - web/frontend/src/assets/example.svg | 2 ++ .../src/components/tabs/AnalyzeTab.jsx | 32 ++++++++++--------- 3 files changed, 19 insertions(+), 16 deletions(-) create mode 100644 web/frontend/src/assets/example.svg diff --git a/tests/test_example_fasta.py b/tests/test_example_fasta.py index bd4c631..f45be91 100644 --- a/tests/test_example_fasta.py +++ b/tests/test_example_fasta.py @@ -8,7 +8,6 @@ from __future__ import annotations -import sqlite3 import textwrap from pathlib import Path diff --git a/web/frontend/src/assets/example.svg b/web/frontend/src/assets/example.svg new file mode 100644 index 0000000..802f400 --- /dev/null +++ b/web/frontend/src/assets/example.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/web/frontend/src/components/tabs/AnalyzeTab.jsx b/web/frontend/src/components/tabs/AnalyzeTab.jsx index 5d217c9..4a9d909 100644 --- a/web/frontend/src/components/tabs/AnalyzeTab.jsx +++ b/web/frontend/src/components/tabs/AnalyzeTab.jsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'; import analyzeIconSrc from '../../assets/icon-analyze.svg'; import batchIconSrc from '../../assets/batch.svg'; +import exampleIconSrc from '../../assets/example.svg'; import infoIconSrc from '../../assets/info.svg'; import { Spinner } from '../Spinner'; @@ -135,6 +136,22 @@ export function AnalyzeTab({

+ {selectedDatabase?.has_example ? ( + + ) : null} {ANALYZE_SUBMODES.map((subMode) => ( - ) : null}
From 7073dcc49daec65e52be5a2467d7068eaece0686 Mon Sep 17 00:00:00 2001 From: jonas Date: Wed, 19 Aug 2026 10:01:41 +0200 Subject: [PATCH 04/19] fixed typo --- web/frontend/src/components/tour/steps.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/frontend/src/components/tour/steps.js b/web/frontend/src/components/tour/steps.js index be5b91a..bdddfc8 100644 --- a/web/frontend/src/components/tour/steps.js +++ b/web/frontend/src/components/tour/steps.js @@ -89,7 +89,7 @@ export function buildTourSteps({ setActiveMode, setActiveProfileMode, setAnalyze id: 'vcf-coverage-cutoff', targetSelector: '[data-tour-target="vcf-coverage-cutoff"]', title: 'VCF mode — coverage cutoff', - body: 'Set the minimum read depth required to include a position. Variants below this threshold are ignored and will not appear in the report..', + body: 'Set the minimum read depth required to include a position. Variants below this threshold are ignored and will not appear in the report.', before: () => { setActiveMode('analyze'); setAnalyzeSubMode('single'); From 317a46ac133807ce7160024e25b14d1314cc6814 Mon Sep 17 00:00:00 2001 From: jonas Date: Wed, 19 Aug 2026 18:00:38 +0200 Subject: [PATCH 05/19] added better message for ref mismatches --- respro/db/_rules_features.py | 27 +++++++++++++++----------- tests/test_init_project.py | 37 +++++++++++++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 12 deletions(-) diff --git a/respro/db/_rules_features.py b/respro/db/_rules_features.py index 43b2031..f5a31c1 100644 --- a/respro/db/_rules_features.py +++ b/respro/db/_rules_features.py @@ -187,28 +187,33 @@ def _validate_reference_amino_acids( if 0 <= pos_0based and end_pos <= len(aa_seq): actual = aa_seq[pos_0based:end_pos].upper() if actual != ref_block: - mismatch_keys.add((feature_name, position_raw, reference_identifier, ref_aa)) - mismatch_details.append( + key = (feature_name, position_raw, reference_identifier, ref_aa) + if key not in mismatch_keys: + mismatch_details.append( + f' {reference_identifier} feature {feature_name!r} pos {pos} ({coord_base}-based): ' + f'rule says {ref_aa!r}, feature sequence has {actual!r} — rule will be skipped' + ) + mismatch_keys.add(key) + else: + out_of_range_key = (feature_name, position_raw, reference_identifier, ref_aa) + if out_of_range_key not in mismatch_keys: + out_of_range.append( f' {reference_identifier} feature {feature_name!r} pos {pos} ({coord_base}-based): ' - f'rule says {ref_aa!r}, feature sequence has {actual!r} — rule will be skipped' + f'out of range (aa_sequence length = {len(aa_seq)}) — rule will be skipped' ) - else: - out_of_range.append( - f' {reference_identifier} feature {feature_name!r} pos {pos} ({coord_base}-based): ' - f'out of range (aa_sequence length = {len(aa_seq)}) — rule will be skipped' - ) + mismatch_keys.add(out_of_range_key) if out_of_range: logger.warning( - '%d rule(s) reference positions beyond the end of the annotated protein ' - 'and will be skipped:\n%s', + '%d position(s) with rules reference coordinates beyond the end of the ' + 'annotated protein and will be skipped:\n%s', len(out_of_range), '\n'.join(out_of_range), ) if mismatch_details: logger.warning( - '%d rule(s) have reference AA mismatches and will be skipped:\n%s', + '%d position(s) with rules have reference AA mismatches and will be skipped:\n%s', len(mismatch_details), '\n'.join(mismatch_details), ) diff --git a/tests/test_init_project.py b/tests/test_init_project.py index 362ae34..c699e7a 100644 --- a/tests/test_init_project.py +++ b/tests/test_init_project.py @@ -73,6 +73,7 @@ def _rule( reference: str, mutation: str = 'E', reference_identifier: str = '', + antiviral: str = 'DrugX', ) -> dict[str, str]: """Build a minimal TSV row dict for a single resistance rule.""" row = { @@ -80,7 +81,7 @@ def _rule( 'position': str(position), 'reference': reference, 'mutation': mutation, - 'antiviral': 'DrugX', + 'antiviral': antiviral, } if reference_identifier: row['reference_identifier'] = reference_identifier @@ -253,6 +254,40 @@ def test_collects_all_mismatch_keys(self, caplog) -> None: assert len(mismatch_keys) == 3 assert any('3' in r.message and 'mismatch' in r.message.lower() for r in caplog.records) + def test_emits_each_mismatch_only_once_per_position(self, caplog) -> None: + import logging + + # Same feature/position/ref_aa repeated across multiple antivirals — + # the mismatch should be logged exactly once and the key returned once. + rows = [ + _rule('gX', position=1, reference='Z', antiviral='DrugA'), + _rule('gX', position=1, reference='Z', antiviral='DrugB'), + _rule('gX', position=1, reference='Z', antiviral='DrugC'), + ] + with caplog.at_level(logging.WARNING, logger='respro.db.rules_import'): + mismatch_keys = _validate_reference_amino_acids(rows, _features('gX', _AA_SEQ), coord_base=1) + assert mismatch_keys == {('gX', '1', '', 'Z')} + mismatch_messages = [ + r.message for r in caplog.records if 'mismatch' in r.message.lower() + ] + assert len(mismatch_messages) == 1 + assert mismatch_messages[0].count("pos 1 (1-based)") == 1 + + def test_emits_out_of_range_only_once_per_position(self, caplog) -> None: + import logging + + rows = [ + _rule('gX', position=10, reference='M', antiviral='DrugA'), + _rule('gX', position=10, reference='M', antiviral='DrugB'), + ] + with caplog.at_level(logging.WARNING, logger='respro.db.rules_import'): + _validate_reference_amino_acids(rows, _features('gX', _AA_SEQ), coord_base=1) + out_of_range_messages = [ + r.message for r in caplog.records if 'out of range' in r.message + ] + assert len(out_of_range_messages) == 1 + assert out_of_range_messages[0].count("pos 10 (1-based)") == 1 + def test_skips_rows_without_ref_aa(self) -> None: # Row has no 'reference'/'ref_aa' key — must not raise rows = [ From def85662064b7654caa65717605f7bfba1b49b11 Mon Sep 17 00:00:00 2001 From: jonas Date: Fri, 21 Aug 2026 12:35:15 +0200 Subject: [PATCH 06/19] Plot adjustemtn and table adjustment --- respro/report/plots.py | 6 +- .../src/components/tabs/DatabaseTab.jsx | 87 ++++++++++++++++--- 2 files changed, 81 insertions(+), 12 deletions(-) diff --git a/respro/report/plots.py b/respro/report/plots.py index 0d2703c..32bc734 100644 --- a/respro/report/plots.py +++ b/respro/report/plots.py @@ -719,12 +719,14 @@ def _draw_genome_overview( label_x, label_y, feature.display_name, - ha='center', - va='bottom', + ha='left', + va='baseline', fontsize=7, fontweight='bold', color=FEATURE_HIGHLIGHTED_EDGE, zorder=200 + y, + rotation_mode='anchor', + rotation=25, ) ax.set_title('Genome overview', fontsize=8, loc='left', fontweight='bold') diff --git a/web/frontend/src/components/tabs/DatabaseTab.jsx b/web/frontend/src/components/tabs/DatabaseTab.jsx index 321c625..126a20a 100644 --- a/web/frontend/src/components/tabs/DatabaseTab.jsx +++ b/web/frontend/src/components/tabs/DatabaseTab.jsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; import infoIconSrc from '../../assets/info.svg'; -import { isPopulated, groupDrugThresholds, formatAlgorithmThresholds } from '../../utils'; +import { isPopulated, groupDrugThresholds } from '../../utils'; import { buildDatabasePlots } from '../database-plots/buildDatabasePlots'; import { DatabasePieSummaryRow } from '../database-plots/DatabasePieSummaryTile'; import { DatabasePositionPlot } from '../database-plots/DatabasePositionPlot'; @@ -96,6 +96,25 @@ function _groupEffectRules(rules) { }); } +/** + * Render a single threshold value (`intermediate` or `resistant`) as a table + * cell. Returns an empty string when the key is absent (e.g. `intermediate` is + * optional for `drug_interpretation`) so each threshold gets its own column + * instead of being joined with `;` in one cell. + */ +function _thresholdCell(thresholds, key) { + if (!thresholds || typeof thresholds !== 'object') { + return ''; + } + const value = thresholds[key]; + return value === undefined || value === null ? '' : String(value); +} + +/** + * Render the per-drug override table shared by `ic50_thresholds` and + * `drug_interpretation`. Each row shows the reference, the collapsed drug set, + * and the `intermediate`/`resistant` thresholds in separate columns. + */ function _renderDrugThresholdsOverrides(drugThresholds, label) { const grouped = groupDrugThresholds(drugThresholds); if (grouped.length === 0) { @@ -110,7 +129,8 @@ function _renderDrugThresholdsOverrides(drugThresholds, label) { Reference Drugs - Thresholds + Intermediate + Resistant @@ -118,7 +138,8 @@ function _renderDrugThresholdsOverrides(drugThresholds, label) { {row.reference} {row.drugs.join(', ')} - {formatAlgorithmThresholds(row.thresholds)} + {_thresholdCell(row.thresholds, 'intermediate')} + {_thresholdCell(row.thresholds, 'resistant')} ))} @@ -128,6 +149,51 @@ function _renderDrugThresholdsOverrides(drugThresholds, label) { ); } +/** + * Render one row per drug for the `ic50_thresholds` algorithm table. + * + * The algorithm stores thresholds as a dict keyed by drug name, each mapping + * to `{intermediate, resistant}`. Displaying each drug on its own row (instead + * of joining them with `;` in a single cell) keeps the table aligned with the + * actual input structure. The `use` value is shown on the first row only. + */ +function _renderIc50ThresholdRows(ic50Thresholds) { + const useValue = String(ic50Thresholds.use || '').trim() || 'Not configured'; + const thresholds = ic50Thresholds.thresholds; + if (!thresholds || typeof thresholds !== 'object') { + return [ + + {useValue} + Not configured + + + , + ]; + } + const drugs = Object.keys(thresholds).sort((a, b) => a.localeCompare(b)); + if (drugs.length === 0) { + return [ + + {useValue} + Not configured + + + , + ]; + } + return drugs.map((drug, idx) => { + const limits = thresholds[drug] || {}; + return ( + + {idx === 0 ? useValue : ''} + {drug} + {_thresholdCell(limits, 'intermediate')} + {_thresholdCell(limits, 'resistant')} + + ); + }); +} + function _renderDatabaseAlgorithms(algorithms) { if (!algorithms) return null; const effectAsResistant = algorithms.effect_as_resistant; @@ -159,14 +225,13 @@ function _renderDatabaseAlgorithms(algorithms) { Use - Thresholds + Drug + Intermediate + Resistant - - {String(ic50Thresholds.use || '').trim() || 'Not configured'} - {formatAlgorithmThresholds(ic50Thresholds.thresholds)} - + {_renderIc50ThresholdRows(ic50Thresholds)} @@ -184,14 +249,16 @@ function _renderDatabaseAlgorithms(algorithms) { Method - Thresholds + Intermediate + Resistant {drugInterp.map((entry, idx) => ( {String(entry.method || '').trim() || 'Not configured'} - {formatAlgorithmThresholds(entry.thresholds)} + {_thresholdCell(entry.thresholds, 'intermediate')} + {_thresholdCell(entry.thresholds, 'resistant')} ))} From 2f1e3d20ade61335d4d846d8572f812a20fc1155 Mon Sep 17 00:00:00 2001 From: jonas Date: Fri, 21 Aug 2026 16:00:58 +0200 Subject: [PATCH 07/19] fixed bug for intronic regions emitting large indels during profiling --- docs/docs/how-it-works.md | 13 + respro/config/cli_settings.py | 2 + respro/config/defaults.toml | 7 + respro/core/alignment.py | 193 +++++- respro/core/fasta_to_vcf.py | 37 +- respro/core/vcf_remap.py | 84 ++- respro/db/cache.py | 46 +- respro/db/models.py | 18 + respro/db/schema.py | 4 +- respro/report/alignment_visualization.py | 9 +- tests/test_intron_classification.py | 741 +++++++++++++++++++++++ 11 files changed, 1138 insertions(+), 16 deletions(-) create mode 100644 tests/test_intron_classification.py diff --git a/docs/docs/how-it-works.md b/docs/docs/how-it-works.md index bc658ef..7d5a839 100644 --- a/docs/docs/how-it-works.md +++ b/docs/docs/how-it-works.md @@ -54,6 +54,19 @@ For VCF input, variants are defined in user-provided reference coordinates. ResP - For reverse-strand features, alleles are reverse-complemented to the internal forward strand. - Anchor-changed indels (where the VCF anchor base differs from the internal reference) are automatically split into a substitution plus a canonical indel before annotation. +#### Spliced genes and unspliced queries + +When a project feature is a spliced CDS (more than one segment in `feature_segment`) and the user supplies an unspliced whole-genome query, minimap2 reports the inter-exon intron as a single large `I` (FASTA) or `D` (raw mappy) operation in the CIGAR. Without special handling this intron would be misinterpreted as a giant coding insertion (e.g. a multi-kilobase frameshift) and would crash per-exon identity. + +ResPro classifies such intron operations: + +- The exon-junction CDS offsets are derived from `feature.segments` in genomic 5'→3' order (matching the normalized CIGAR's walking order for both strands). +- A CIGAR `I` op is classified as an intron when **both** hold: (1) its CDS position coincides with a known junction offset within `alignment.intron_junction_tolerance` (default 5 nt; configurable in `defaults.toml`), and (2) its length is strictly greater than that same tolerance. +- Classified intron `I` ops are removed from the CIGAR stored on the `FeatureMatch` (producing an exon-only CIGAR) and recorded as `IntronInterval`s (CDS junction position, query span, length). +- Identity and CDS coverage are recomputed over exons only, so a perfect exons match reports ~99% identity rather than the genomic span including the intron. +- In FASTA mode the intron query span is stripped from the region before codon walking, so no intron insertion variant is emitted. Real coding insertions (any `I` op not at a junction within tolerance, or of length ≤ tolerance) are still emitted. +- In VCF mode the query-to-CDS coordinate map skips intron query positions and offsets exon-2 (and later) CDS positions past the intron span, so variants inside the intron are excluded and exon-2 variants remap to the correct CDS offset. + ### Amino-acid consequence interpretation Per-feature nucleotide changes are translated into amino-acid consequences. Supported classes include: diff --git a/respro/config/cli_settings.py b/respro/config/cli_settings.py index 3cb03a5..0a078ae 100644 --- a/respro/config/cli_settings.py +++ b/respro/config/cli_settings.py @@ -86,6 +86,7 @@ class CliAlignmentConfig: gap_extension_penalty_1: int gap_open_penalty_2: int gap_extension_penalty_2: int + intron_junction_tolerance: int @dataclass(frozen=True) @@ -163,6 +164,7 @@ def _bins(section: dict) -> CliAfBinsConfig: gap_extension_penalty_1=int(alignment['gap_extension_penalty_1']), gap_open_penalty_2=int(alignment['gap_open_penalty_2']), gap_extension_penalty_2=int(alignment['gap_extension_penalty_2']), + intron_junction_tolerance=int(alignment['intron_junction_tolerance']), ), ) diff --git a/respro/config/defaults.toml b/respro/config/defaults.toml index 4e90bd3..e116385 100644 --- a/respro/config/defaults.toml +++ b/respro/config/defaults.toml @@ -66,3 +66,10 @@ mismatch_penalty = 4 gap_extension_penalty_1 = 2 gap_open_penalty_2 = 24 gap_extension_penalty_2 = 1 +# Maximum CDS-position distance (in nt) between a CIGAR `I` op and a known +# exon-junction offset for the `I` op to be classified as an intron rather +# than a coding insertion. Only applies to features with >1 segment (spliced +# CDS). mappy may place the junction a few nt off the stored segment boundary, +# so a small tolerance accommodates soft clipping near exon edges without +# classifying real in-exon insertions as introns. +intron_junction_tolerance = 5 diff --git a/respro/core/alignment.py b/respro/core/alignment.py index 4450feb..980eeec 100644 --- a/respro/core/alignment.py +++ b/respro/core/alignment.py @@ -16,12 +16,22 @@ from respro.config.cli_settings import CLI_CONFIG from respro.db.features import load_feature_segments_by_feature_id -from respro.db.models import FeatureMatch, FeatureRecord +from respro.db.models import FeatureMatch, FeatureRecord, FeatureSegment, IntronInterval logger = logging.getLogger(__name__) _RE_CIGAR = re.compile(r'(\d+)([MIDNSHP=X])') +__all__ = [ + 'IntronInterval', + 'classify_introns', + 'exon_junction_cds_offsets', + 'match_query_to_features', + 'load_features', + 'parse_cigar', + 'cigar_to_coordinate_map', +] + # ────────────────────────────────────────────────────────────────────── # Public API # ────────────────────────────────────────────────────────────────────── @@ -186,13 +196,25 @@ def _match_with_mappy( continue h = primary[0] - identity = h.mlen / h.blen if h.blen else 0.0 - cds_coverage = (h.q_en - h.q_st) / len(cds) - query_coverage = (h.r_en - h.r_st) / len(query_upper) - strand = '+' if h.strand == 1 else '-' cigar = _normalize_mappy_cigar(h.cigar_str, strand) + # Classify intron I-ops by exon-junction position for spliced features. + # Produces an exon-only CIGAR (intron I ops removed) plus intron + # intervals carried on the FeatureMatch. query_start in each interval is + # relative to the alignment's coding-orientation query region start + # (i.e. relative to the first base the CIGAR consumes in the query); + # downstream strand-aware consumers convert to forward-strand coords. + junctions = exon_junction_cds_offsets(feature) + intron_tolerance = cfg.intron_junction_tolerance + cigar, intron_intervals = classify_introns(cigar, junctions, intron_tolerance) + intron_lengths = [iv.length for iv in intron_intervals] + + identity, cds_coverage = _recompute_exon_metrics( + h, cds, feature, intron_lengths, cigar, + ) + query_coverage = (h.r_en - h.r_st) / len(query_upper) + matches.append(FeatureMatch( feature=feature, identity=identity, @@ -203,11 +225,53 @@ def _match_with_mappy( strand=strand, cigar=cigar, cds_start=h.q_st, + intron_intervals=tuple(intron_intervals), )) return matches +def _recompute_exon_metrics( + h: mappy.Alignment, + cds: str, + feature: FeatureRecord, + intron_lengths: list[int], + exon_only_cigar: str, +) -> tuple[float, float]: + """ + Recompute identity and CDS coverage over exons only for spliced features. + + When introns were classified, the intron ``D`` ops (deletions in the CDS + query = intron spans in the genome reference) would normally be counted in + ``h.blen`` and crash identity. However mappy reports ``h.blen`` as the + query (CDS) block length, which already excludes ``D`` ops, so the intron + does not inflate it. Identity is therefore computed as ``h.mlen`` over the + aligned exon span (sum of ``M`` and ``D`` ops in the exon-only CIGAR), + which equals the coding bases assessed. CDS coverage is the aligned exon + length over the sum of exon lengths (the true coding span), clamped to + [0.0, 1.0]. Using the exon-only CIGAR avoids mappy soft-clipping inflating + coverage above 1.0. + + :param h: mappy primary alignment hit + :param cds: feature CDS sequence (spliced, coding orientation) + :param feature: feature record (for exon lengths) + :param intron_lengths: lengths of classified intron I ops + :param exon_only_cigar: exon-only CIGAR (intron I ops removed) + :return: ``(identity, cds_coverage)`` both in [0.0, 1.0] + """ + aligned_cds = sum(int(n) for n, op in re.findall(r'(\d+)([MD])', exon_only_cigar)) + if intron_lengths: + # Exon-only identity: matches over the assessed coding span. mlen + # already excludes intron D ops; aligned_cds is the exon M+D span. + identity = h.mlen / aligned_cds if aligned_cds > 0 else 0.0 + else: + identity = h.mlen / h.blen if h.blen else 0.0 + + exon_span = sum(seg.end - seg.start for seg in feature._coding_segments) or len(cds) + cds_coverage = aligned_cds / exon_span if exon_span else 0.0 + return identity, min(cds_coverage, 1.0) + + def _swap_cigar_indels(cigar: str) -> str: """ Swap I and D operations in a CIGAR string. @@ -270,6 +334,125 @@ def parse_cigar(cigar: str) -> list[tuple[int, str]]: return [(int(m.group(1)), m.group(2)) for m in _RE_CIGAR.finditer(cigar)] +def exon_junction_cds_offsets(feature: FeatureRecord) -> list[int]: + """ + Return the 0-based CDS offsets at which each exon junction occurs. + + A junction offset is the cumulative coding length up to the end of an exon; + it is the CDS position at which the next exon begins. For a single-segment + feature the list is empty. + + Junctions are derived from :attr:`FeatureRecord.segments` in **genomic + 5'->3' order** (segment_index order), not from ``_coding_segments``. This + matches the normalized CIGAR's walking order: for '+' strand the CIGAR + walks genomic order (== coding order), and for '-' strand + :func:`_normalize_mappy_cigar` reverses the CIGAR so it also walks genomic + 5'->3' order. Using ``_coding_segments`` (which reverses for '-' strand) + would mismatch the CIGAR and misclassify introns. + + :param feature: feature record with optional ``segments`` + :return: ordered list of exon-junction CDS offsets (one per internal boundary) + """ + segments = feature.segments if feature.segments else ( + FeatureSegment(segment_index=0, start=feature.start, end=feature.end), + ) + offsets: list[int] = [] + cumulative = 0 + for segment in segments[:-1]: + cumulative += segment.end - segment.start + offsets.append(cumulative) + return offsets + + +def classify_introns( + cigar: str, + junction_offsets: list[int], + tolerance: int, +) -> tuple[str, list[IntronInterval]]: + """ + Classify CIGAR ``I`` ops as introns by exon-junction position. + + Walks the normalized CIGAR (CDS=reference, genome=query) tracking the CDS + position consumed by ``M`` and ``D`` ops. An ``I`` op (insertion in the + query/genome) is classified as an intron iff **both** hold: + + 1. Its CDS position coincides with a known exon-junction offset within + ``tolerance`` nt (the primary classifier — no standalone length heuristic). + 2. Its length is strictly greater than ``tolerance``. + + The length guard is derived from ``tolerance`` rather than carried as a + separate knob: an insertion of length ≤ tolerance can never be + misclassified as an intron, regardless of where it lands relative to the + junction, while real introns (typically hundreds/thousands of nt, always + > tolerance) always satisfy the guard. Such intron ``I`` ops are removed + from the returned exon-only CIGAR and recorded as :class:`IntronInterval` + entries; real coding insertions (any ``I`` op not near a junction, or of + length ≤ tolerance) are kept. + + Adjacent same-operation runs in the exon-only CIGAR are merged so downstream + consumers receive a clean CIGAR with no spurious length-1 adjacency. + + :param cigar: normalized CIGAR string (CDS=reference, genome=query) + :param junction_offsets: exon-junction CDS offsets (from + :func:`exon_junction_cds_offsets`); empty for single-exon features + :param tolerance: maximum CDS-position distance for an ``I`` op to be + classified as an intron, and the strict lower bound on intron length + (from ``alignment.intron_junction_tolerance``) + :return: ``(exon_only_cigar, intron_intervals)`` where intron_intervals is + ordered by CDS junction position + """ + if not junction_offsets: + return cigar, [] + + junctions = sorted(junction_offsets) + introns: list[IntronInterval] = [] + exon_ops: list[tuple[int, str]] = [] + + cds_pos = 0 + query_pos = 0 # tracked for IntronInterval.query_start (relative to cigar start) + for length, op in parse_cigar(cigar): + if op == 'I': + near_junction = any( + abs(cds_pos - j) <= tolerance for j in junctions + ) + if near_junction and length > tolerance: + introns.append(IntronInterval( + cds_junction_pos=cds_pos, + query_start=query_pos, + length=length, + )) + else: + exon_ops.append((length, op)) + query_pos += length + continue + + if op == 'D': + cds_pos += length + elif op == 'M': + cds_pos += length + query_pos += length + + exon_ops.append((length, op)) + + merged = _merge_adjacent_ops(exon_ops) + exon_only_cigar = ''.join(f'{length}{op}' for length, op in merged) + return exon_only_cigar, introns + + +def _merge_adjacent_ops(ops: list[tuple[int, str]]) -> list[tuple[int, str]]: + """Merge adjacent CIGAR operations of the same type into single runs.""" + if not ops: + return [] + merged: list[tuple[int, str]] = [ops[0]] + for length, op in ops[1:]: + last_len, last_op = merged[-1] + if op == last_op: + merged[-1] = (last_len + length, op) + else: + merged.append((length, op)) + return merged + + def cigar_to_coordinate_map(cigar: str, query_start: int) -> dict[int, int | None]: """ Convert a CIGAR string to a CDS-position → query-position map. diff --git a/respro/core/fasta_to_vcf.py b/respro/core/fasta_to_vcf.py index 36c4fc3..2e13b3f 100644 --- a/respro/core/fasta_to_vcf.py +++ b/respro/core/fasta_to_vcf.py @@ -23,7 +23,7 @@ from Bio.Seq import Seq from respro.core.vcf_coverage import _merge_codon_gaps -from respro.db.models import CoverageGap, FeatureMatch, FeatureRecord, VariantCall +from respro.db.models import CoverageGap, FeatureMatch, FeatureRecord, IntronInterval, VariantCall logger = logging.getLogger(__name__) @@ -72,6 +72,13 @@ def _profile_feature_to_variants( if match.strand == '-': region = str(Seq(region).reverse_complement()) + # Spliced features aligned against an unspliced query carry intron intervals + # (in coding-orientation, relative to the region start). The exon-only CIGAR + # already excludes the intron I ops, so the region must also drop the intron + # spans — otherwise the CIGAR's M ops would walk exon1 straight into the + # intron bases, misaligning exon 2 and emitting spurious variants. + region = _strip_introns_from_region(region, match.intron_intervals) + aligned_cds_len = sum(int(n) for n, op in re.findall(r'(\d+)([MD])', match.cigar)) covered_cds_start = match.cds_start covered_cds_end = match.cds_start + aligned_cds_len @@ -91,6 +98,34 @@ def _profile_feature_to_variants( ) +def _strip_introns_from_region( + region: str, + intron_intervals: tuple[IntronInterval, ...], +) -> str: + """ + Remove intron spans from the coding-orientation query region. + + ``IntronInterval.query_start`` is relative to the start of the + coding-orientation region (the first base the CIGAR consumes), and + ``length`` is the intron length in that orientation. This drops those + spans so the exon-only CIGAR's ``M`` ops walk exon 1 directly into exon 2 + without crossing the intron. + + :param region: coding-orientation query slice (already RC'd for '-' strand) + :param intron_intervals: intron intervals from the FeatureMatch + :return: region with intron spans removed + """ + if not intron_intervals: + return region + # Sort by start and drop each [start, start+length) span. Build a keep-mask + # to avoid index shifts from repeated slicing. + drop = [False] * len(region) + for iv in intron_intervals: + for i in range(iv.query_start, min(iv.query_start + iv.length, len(region))): + drop[i] = True + return ''.join(base for base, d in zip(region, drop) if not d) + + def _variants_from_alignment( aligned_ref: str, aligned_query: str, diff --git a/respro/core/vcf_remap.py b/respro/core/vcf_remap.py index 153016d..9f49123 100644 --- a/respro/core/vcf_remap.py +++ b/respro/core/vcf_remap.py @@ -10,7 +10,7 @@ from respro.core.alignment import cigar_to_coordinate_map from respro.core.query import QueryRecord, pick_best_reference_id, select_matches_for_reference -from respro.db.models import FeatureMatch, FeatureRecord, VariantCall +from respro.db.models import FeatureMatch, FeatureRecord, IntronInterval, VariantCall logger = logging.getLogger(__name__) @@ -46,7 +46,7 @@ def remap_variants( for match in matches: q2c = _build_query_to_cds_map( match.cigar, match.query_start, match.query_end, - match.strand, query_len, match.cds_start, + match.strand, query_len, match.cds_start, match.intron_intervals, ) c2q = {cds_pos: query_pos for query_pos, cds_pos in q2c.items()} match_maps.append((match, q2c, c2q)) @@ -410,6 +410,7 @@ def _build_query_to_cds_map( strand: str, query_len: int, cds_start: int = 0, + intron_intervals: tuple[IntronInterval, ...] = (), ) -> dict[int, int]: """ Invert a CIGAR-based coordinate map to query-position → CDS-position. @@ -417,21 +418,52 @@ def _build_query_to_cds_map( For '-' strand matches the CIGAR was built against the reverse-complement query, so positions are first converted back to forward-strand coordinates. - :param cigar: CIGAR string from alignment + For spliced features aligned against an unspliced query, ``cigar`` is the + exon-only CIGAR (intron ``I`` ops removed) and ``intron_intervals`` carries + the intron query spans. Because the exon-only CIGAR consumes query + positions sequentially with no intron gap, exon-2 (and later) CDS positions + would otherwise map into the intron query span. Each mapped query position + is therefore shifted past the cumulative length of all introns whose + coding-orientation start precedes it, and intron query positions themselves + are excluded from the inverted map. + + :param cigar: exon-only CIGAR string from alignment :param query_start: 0-based forward-strand start (from FeatureMatch) :param query_end: 0-based forward-strand end (from FeatureMatch) :param strand: alignment strand ('+' or '-') :param query_len: total query sequence length :param cds_start: 0-based CDS offset where this alignment starts + :param intron_intervals: intron intervals (query_start relative to the + coding-orientation region start) carried on the FeatureMatch :return: mapping {forward_query_pos: cds_pos} """ - cds_to_query: dict[int, int | None] + # Intron spans in the orientation cigar_to_coordinate_map produces. + # iv.query_start is relative to the CIGAR's coding-orientation region start. + # For '-' strand the CIGAR is in RC-orientation, so the shift must happen + # in RC space BEFORE converting to forward-strand coordinates — otherwise + # the RC-orientation intron spans would be matched against forward-strand + # query positions, silently dropping exon-1 variants and mis-remapping + # exon-2 variants. if strand == '+': + orient_start = query_start + intron_spans = [ + (orient_start + iv.query_start, orient_start + iv.query_start + iv.length) + for iv in intron_intervals + ] cds_to_query = cigar_to_coordinate_map(cigar, query_start) + if intron_spans: + cds_to_query = _shift_past_introns(cds_to_query, intron_spans) else: - # Recover RC-space start from the stored forward-strand end rc_start = query_len - query_end + orient_start = rc_start + intron_spans = [ + (orient_start + iv.query_start, orient_start + iv.query_start + iv.length) + for iv in intron_intervals + ] cds_to_query_rc = cigar_to_coordinate_map(cigar, rc_start) + if intron_spans: + cds_to_query_rc = _shift_past_introns(cds_to_query_rc, intron_spans) + # Convert RC-orientation positions to forward-strand AFTER the shift. cds_to_query = {} for cds_pos, rc_pos in cds_to_query_rc.items(): if rc_pos is not None: @@ -455,3 +487,45 @@ def _build_query_to_cds_map( return query_to_cds +def _shift_past_introns( + cds_to_query: dict[int, int | None], + intron_spans: list[tuple[int, int]], +) -> dict[int, int | None]: + """ + Shift each mapped query position past the cumulative length of preceding introns. + + The exon-only CIGAR maps CDS positions to query offsets that ignore intron + query spans. For a CDS position whose raw mapped offset falls at or after + an intron's coding-orientation start, add the cumulative intron length so + the position lands in the correct exon in the full (unspliced) query. + + Mapped positions that fall *inside* an intron span (should not happen for + true exon positions, but defensively) are set to None. + + Operates in a single orientation space (the CIGAR's coding orientation: + forward for '+' strand, RC for '-' strand). The caller converts to + forward-strand coordinates afterward for '-' strand. + + :param cds_to_query: CDS-position → raw orientation-absolute query position + :param intron_spans: intron spans in the same orientation as the raw positions + :return: CDS-position → corrected query position (same orientation as input) + """ + if not intron_spans: + return cds_to_query + spans = sorted(intron_spans) + corrected: dict[int, int | None] = {} + for cds_pos, qpos in cds_to_query.items(): + if qpos is None: + corrected[cds_pos] = None + continue + # Cumulative intron length for introns whose start is at or before qpos. + shift = sum(end - start for start, end in spans if start <= qpos) + new_pos = qpos + shift + # Exclude positions that land inside an intron span after shifting. + if any(start <= new_pos < end for start, end in spans): + corrected[cds_pos] = None + continue + corrected[cds_pos] = new_pos + return corrected + + diff --git a/respro/db/cache.py b/respro/db/cache.py index 9545667..e130593 100644 --- a/respro/db/cache.py +++ b/respro/db/cache.py @@ -5,11 +5,12 @@ from __future__ import annotations import hashlib +import json import logging import sqlite3 from respro.db.features import load_feature_segments_by_feature_id -from respro.db.models import FeatureMatch, FeatureRecord +from respro.db.models import FeatureMatch, FeatureRecord, IntronInterval logger = logging.getLogger(__name__) @@ -24,6 +25,42 @@ def sequence_checksum(sequence: str) -> str: return hashlib.sha256(sequence.upper().encode()).hexdigest() +def _serialize_intron_intervals(intervals: tuple[IntronInterval, ...]) -> str: + """Serialize intron intervals to a compact JSON array string.""" + return json.dumps( + [ + { + 'cds_junction_pos': iv.cds_junction_pos, + 'query_start': iv.query_start, + 'length': iv.length, + } + for iv in intervals + ] + ) + + +def _deserialize_intron_intervals(blob: str | None) -> tuple[IntronInterval, ...]: + """Deserialize intron intervals from a JSON array string. + + Tolerates ``None`` or malformed blobs (legacy rows) by returning an empty + tuple so that unspliced-feature mappings load without intron spans. + """ + if not blob: + return tuple() + try: + records = json.loads(blob) + except (json.JSONDecodeError, TypeError): + return tuple() + return tuple( + IntronInterval( + cds_junction_pos=int(rec['cds_junction_pos']), + query_start=int(rec['query_start']), + length=int(rec['length']), + ) + for rec in records + ) + + def load_cached_mappings( conn: sqlite3.Connection, checksum: str, @@ -45,6 +82,7 @@ def load_cached_mappings( rows = conn.execute( 'SELECT qgm.feature_id, qgm.identity, qgm.cds_coverage, qgm.query_coverage, ' 'qgm.cds_start, qgm.query_start, qgm.query_end, qgm.strand, qgm.cigar, ' + 'qgm.intron_intervals, ' 'g.reference_id, g.name, g.protein, g.start, g.end, g.strand AS feature_strand, ' 'g.codon_start, g.nt_sequence, g.aa_sequence, ' 'r.accession AS reference_accession ' @@ -84,6 +122,7 @@ def load_cached_mappings( query_end=row['query_end'], strand=row['strand'], cigar=row['cigar'], + intron_intervals=_deserialize_intron_intervals(row['intron_intervals']), )) logger.info('Loaded %d cached mapping(s) for checksum %s…', len(matches), checksum[:12]) @@ -119,8 +158,8 @@ def store_mappings( conn.execute( 'INSERT OR REPLACE INTO query_feature_mapping ' '(query_ref_id, feature_id, identity, cds_coverage, query_coverage, ' - 'cds_start, query_start, query_end, strand, cigar) ' - 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + 'cds_start, query_start, query_end, strand, cigar, intron_intervals) ' + 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', ( qref_id, match.feature.id, @@ -132,6 +171,7 @@ def store_mappings( match.query_end, match.strand, match.cigar, + _serialize_intron_intervals(match.intron_intervals), ), ) diff --git a/respro/db/models.py b/respro/db/models.py index ed6e03a..9f88422 100644 --- a/respro/db/models.py +++ b/respro/db/models.py @@ -147,6 +147,23 @@ class FeatureSegment: end: int +@dataclass(frozen=True) +class IntronInterval: + """One intron detected between two CDS exons in a spliced-feature alignment. + + Carried on :class:`FeatureMatch` so downstream consumers (FASTA variant + emission, VCF remap, cache, visualization) can skip the intron without + re-deriving its position from the CIGAR. + """ + + cds_junction_pos: int # 0-based CDS offset where the intron begins + query_start: int # 0-based query offset relative to the alignment's + # coding-orientation region start (RC-relative for + # '-' strand); downstream consumers add the region's + # absolute start to obtain strand-specific coordinates + length: int # intron length in query nt + + @dataclass class ResistanceRule: """A single resistance rule loaded from the database.""" @@ -370,6 +387,7 @@ class FeatureMatch: strand: str cigar: str cds_start: int = 0 # first aligned CDS position (0-based); used to reconstruct gapped strings + intron_intervals: tuple[IntronInterval, ...] = field(default_factory=tuple) @dataclass(frozen=True) diff --git a/respro/db/schema.py b/respro/db/schema.py index 2b241f5..9e2211c 100644 --- a/respro/db/schema.py +++ b/respro/db/schema.py @@ -9,7 +9,7 @@ from respro.utils.files import require_file -PROJECT_SCHEMA_VERSION = 1 +PROJECT_SCHEMA_VERSION = 2 RESULTS_SCHEMA_VERSION = 1 PROJECT_SCHEMA_SQL = """\ @@ -194,6 +194,7 @@ query_end INTEGER NOT NULL, strand TEXT NOT NULL DEFAULT '+', cigar TEXT NOT NULL, + intron_intervals TEXT NOT NULL DEFAULT '[]', UNIQUE(query_ref_id, feature_id) ); @@ -464,6 +465,7 @@ 'query_feature_mapping': { 'query_coverage': 'REAL NOT NULL DEFAULT 0', 'cds_start': 'INTEGER NOT NULL DEFAULT 0', + 'intron_intervals': "TEXT NOT NULL DEFAULT '[]'", }, } diff --git a/respro/report/alignment_visualization.py b/respro/report/alignment_visualization.py index 57074c6..8e2276d 100644 --- a/respro/report/alignment_visualization.py +++ b/respro/report/alignment_visualization.py @@ -7,7 +7,7 @@ from markupsafe import Markup, escape from respro.core.annotation import reverse_complement -from respro.core.fasta_to_vcf import _gapped_strings_from_cigar +from respro.core.fasta_to_vcf import _gapped_strings_from_cigar, _strip_introns_from_region from respro.db.models import AnnotatedVariant, FeatureMatch @@ -89,6 +89,13 @@ def build_feature_alignments( if match.strand == '-': region = reverse_complement(region) + # Spliced features aligned against an unspliced query carry intron + # intervals (coding-orientation, relative to the region start). The + # exon-only CIGAR excludes intron I ops, so the region must drop the + # intron spans too — otherwise the CIGAR's M ops walk exon 1 into the + # intron bases and the snippet renders a giant intron gap row. + region = _strip_introns_from_region(region, match.intron_intervals) + aligned_ref_coding, aligned_query_coding = _gapped_strings_from_cigar( feature.nt_sequence.upper(), region, match.cigar, match.cds_start, ) diff --git a/tests/test_intron_classification.py b/tests/test_intron_classification.py new file mode 100644 index 0000000..2855cb3 --- /dev/null +++ b/tests/test_intron_classification.py @@ -0,0 +1,741 @@ +""" +Tests for intron-aware CIGAR classification in spliced-feature alignment. + +When a user supplies an unspliced (whole-genome) query, minimap2 reports the +intron between two CDS exons as a single large ``I`` op in the normalized CIGAR +(CDS=reference, genome=query). These tests pin the behaviour that classifies +such ``I`` ops as introns by exon-junction CDS position (not by length) so that +downstream consumers never see the intron as a coding insertion. +""" + +from __future__ import annotations + +import pytest + +from respro.core.alignment import ( + classify_introns, + exon_junction_cds_offsets, + match_query_to_features, +) +from respro.core.fasta_to_vcf import fasta_to_vcf +from respro.core.vcf_remap import _build_query_to_cds_map, remap_variants +from respro.db.models import FeatureRecord, FeatureSegment, IntronInterval, VariantCall + +# ────────────────────────────────────────────────────────────────────── +# exon_junction_cds_offsets +# ────────────────────────────────────────────────────────────────────── + +class TestExonJunctionCdsOffsets: + def test_single_segment_feature_has_no_junctions(self) -> None: + """A feature with one segment (or no segments) has no exon junctions.""" + from respro.db.models import FeatureRecord + + feature = FeatureRecord( + id=1, reference_id=1, name='g', protein='', + start=0, end=30, strand='+', codon_start=0, + ) + assert exon_junction_cds_offsets(feature) == [] + + def test_two_segment_forward_strand(self) -> None: + """Forward strand: junction offset = length of first coding segment.""" + from respro.db.models import FeatureRecord, FeatureSegment + + feature = FeatureRecord( + id=1, reference_id=1, name='g', protein='', + start=0, end=18, strand='+', codon_start=0, + segments=( + FeatureSegment(segment_index=0, start=0, end=6), + FeatureSegment(segment_index=1, start=12, end=18), + ), + ) + # First exon is 6 nt (0..6), so the junction is at CDS offset 6. + assert exon_junction_cds_offsets(feature) == [6] + + def test_two_segment_reverse_strand(self) -> None: + """Reverse strand: junction offsets use genomic (segment_index) order, + NOT _coding_segments reversed order, because the normalized CIGAR for + '-' strand is reversed and walks genomic 5'->3' order. With unequal + segment lengths the two orderings yield different junction offsets, so + this test discriminates the chosen convention.""" + from respro.db.models import FeatureRecord, FeatureSegment + + feature = FeatureRecord( + id=1, reference_id=1, name='g', protein='', + start=0, end=26, strand='-', codon_start=0, + segments=( + FeatureSegment(segment_index=0, start=0, end=6), # 6 nt (genomic 5') + FeatureSegment(segment_index=1, start=18, end=26), # 8 nt (genomic 3') + ), + ) + # Genomic order (segment_index): first exon 6 nt → junction at CDS offset 6. + # Coding order (_coding_segments reversed): first exon 8 nt → junction 8. + # The normalized '-' CIGAR walks genomic order, so junction must be 6. + assert exon_junction_cds_offsets(feature) == [6] + + def test_three_segment_forward_strand(self) -> None: + """Three exons of lengths 4, 5, 6 → junctions at 4 and 9.""" + from respro.db.models import FeatureRecord, FeatureSegment + + feature = FeatureRecord( + id=1, reference_id=1, name='g', protein='', + start=0, end=40, strand='+', codon_start=0, + segments=( + FeatureSegment(segment_index=0, start=0, end=4), + FeatureSegment(segment_index=1, start=10, end=15), + FeatureSegment(segment_index=2, start=30, end=36), + ), + ) + assert exon_junction_cds_offsets(feature) == [4, 9] + + +# ────────────────────────────────────────────────────────────────────── +# classify_introns +# ────────────────────────────────────────────────────────────────────── + +class TestClassifyIntrons: + def test_no_junctions_returns_cigar_unchanged(self) -> None: + """A single-exon feature: no I op is ever classified as an intron.""" + cigar, introns = classify_introns( + cigar='10M3I5M', junction_offsets=[], tolerance=5, + ) + assert cigar == '10M3I5M' + assert introns == [] + + def test_intron_at_junction_is_removed(self) -> None: + """A 3902-nt I op exactly at the exon junction is classified as intron.""" + # 6M (exon 1) + 3902I (intron) + 7M (exon 2), junction at CDS offset 6. + cigar, introns = classify_introns( + cigar='6M3902I7M', junction_offsets=[6], tolerance=5, + ) + # Adjacent M runs merge after intron removal → clean 13M. + assert cigar == '13M' + assert len(introns) == 1 + assert introns[0].cds_junction_pos == 6 + assert introns[0].length == 3902 + + def test_intron_within_tolerance_is_removed(self) -> None: + """An I op whose CDS position is within ±tolerance of the junction is + classified as an intron (mappy may place the junction a few nt off).""" + # Junction at 100; the I op sits at CDS position 102 (2 nt inside tolerance 5). + # 102M consumed before I then 3902I then 50M. + cigar, introns = classify_introns( + cigar='102M3902I50M', junction_offsets=[100], tolerance=5, + ) + assert cigar == '152M' + assert len(introns) == 1 + + def test_coding_insertion_inside_exon_is_kept(self) -> None: + """A small I op NOT near any junction is a real coding insertion.""" + # Junction at 100; 3I at CDS position 5 (deep in exon 1, |5-100|=95 > 5). + # 6I at CDS position 108 (|108-100|=8 > 5) → also kept (beyond tolerance). + cigar, introns = classify_introns( + cigar='5M3I103M6I50M', junction_offsets=[100], tolerance=5, + ) + assert '3I' in cigar + assert '6I' in cigar + assert introns == [] + + def test_coding_insertion_far_from_junction_is_kept(self) -> None: + """A 3-nt insertion deep inside exon 1 (far from junction) is kept.""" + # Junction at 100; 3I at CDS position 5. + cigar, introns = classify_introns( + cigar='5M3I95M2000I50M', junction_offsets=[100], tolerance=5, + ) + assert '3I' in cigar + assert '2000I' not in cigar + assert len(introns) == 1 + assert introns[0].length == 2000 + + def test_multiple_introns_multiple_junctions(self) -> None: + """Three exons → two junctions; two intron I ops both removed.""" + # Exons 40, 50, 60 nt. Junctions at 40 and 90. + # 40M 100I 50M 200I 60M + cigar, introns = classify_introns( + cigar='40M100I50M200I60M', junction_offsets=[40, 90], tolerance=5, + ) + assert cigar == '150M' + assert len(introns) == 2 + assert introns[0].cds_junction_pos == 40 + assert introns[0].length == 100 + assert introns[1].cds_junction_pos == 90 + assert introns[1].length == 200 + + def test_i_op_beyond_tolerance_is_kept(self) -> None: + """An I op at a position more than `tolerance` from any junction is kept.""" + # Junction at 6; I op at CDS position 20 (tolerance 5 → 20-6=14 > 5). + cigar, introns = classify_introns( + cigar='20M50I10M', junction_offsets=[6], tolerance=5, + ) + assert cigar == '20M50I10M' + assert introns == [] + + def test_deletion_ops_advance_cds_position(self) -> None: + """D ops consume CDS positions; an intron I op after a D must still be + detected at the right junction offset.""" + # 30M 1D 10M (consumes 41 CDS nt: 30+1+10) then 3902I then 50M, junction at 41. + cigar, introns = classify_introns( + cigar='30M1D10M3902I50M', junction_offsets=[41], tolerance=5, + ) + assert '3902I' not in cigar + assert len(introns) == 1 + assert introns[0].cds_junction_pos == 41 + + def test_small_insertion_within_tolerance_is_kept(self) -> None: + """A real coding insertion whose CDS position is within ±tolerance of a + junction must NOT be classified as an intron if its length is ≤ tolerance. + This is the edge-case guard: a 3-nt insertion near an exon boundary is a + real variant, not an intron.""" + # Junction at 62; 3I at CDS position 60 (|60-62|=2 ≤ tolerance 5), and + # length 3 ≤ tolerance 5 → kept as a real insertion. + cigar, introns = classify_introns( + cigar='60M3I2M200I50M', junction_offsets=[62], tolerance=5, + ) + assert '3I' in cigar, '3-nt insertion near junction must be kept (length ≤ tolerance)' + # The 200I is at CDS pos 62 (60M + 2M = 62; the 3I does not advance cds_pos). + # Its query_start is 65 (60M + 3I consumes 3 query nt + 2M = 65). + assert introns == [IntronInterval(cds_junction_pos=62, query_start=65, length=200)] + + def test_large_insertion_within_tolerance_is_classified_as_intron(self) -> None: + """An I op within tolerance AND longer than tolerance is an intron.""" + # Junction at 62; 200I at CDS position 60 (within tolerance 5), length 200 > 5. + cigar, introns = classify_introns( + cigar='60M200I2M50M', junction_offsets=[62], tolerance=5, + ) + assert '200I' not in cigar + assert len(introns) == 1 + assert introns[0].length == 200 + + def test_insertion_exactly_at_tolerance_boundary_is_kept(self) -> None: + """An I op of length exactly equal to tolerance is kept (guard is strict >). + Only length strictly greater than tolerance is classified as an intron.""" + cigar, introns = classify_introns( + cigar='62M5I50M', junction_offsets=[62], tolerance=5, + ) + assert '5I' in cigar + assert introns == [] + + def test_insertion_one_above_tolerance_boundary_is_intron(self) -> None: + """An I op of length tolerance+1 at the junction is classified as an intron.""" + cigar, introns = classify_introns( + cigar='62M6I50M', junction_offsets=[62], tolerance=5, + ) + assert '6I' not in cigar + assert len(introns) == 1 + assert introns[0].length == 6 + + +# ────────────────────────────────────────────────────────────────────── +# match_query_to_features — end-to-end intron classification +# ────────────────────────────────────────────────────────────────────── + +def _spliced_feature() -> FeatureRecord: + """A two-exon forward-strand CDS: exon1 0..30, intron 30..60, exon2 60..90. + + The stored nt_sequence is the spliced CDS (exon1+exon2 = 60 nt). + """ + exon1 = 'ATGAAAGCTTTTGGCCCCAAATTTGGGCCC' # 30 nt + exon2 = 'AAGCTTTTGGCCCCAAATTTGGGCCCAAT'[:30] # 30 nt (pad if needed) + # Ensure exactly 30 nt each. + exon1 = (exon1 + 'A' * 30)[:30] + exon2 = (exon2 + 'A' * 30)[:30] + return FeatureRecord( + id=1, reference_id=1, name='spl', protein='Spl', + start=0, end=90, strand='+', codon_start=0, + nt_sequence=exon1 + exon2, + segments=( + FeatureSegment(segment_index=0, start=0, end=30), + FeatureSegment(segment_index=1, start=60, end=90), + ), + ) + + +class TestMatchQueryToFeaturesSpliced: + def test_unspliced_query_classifies_intron_and_reports_exon_identity(self) -> None: + """An unspliced query (exon1+intron+exon2) yields an exon-only CIGAR, + one intron interval, identity≈1.0 (not crashed by the intron), and + cds_coverage=1.0 over the exon span.""" + feature = _spliced_feature() + intron = 'N' * 30 # 30-nt intron between the two exons + query = feature.nt_sequence[:30] + intron + feature.nt_sequence[30:] + + matches = match_query_to_features(query, [feature]) + assert len(matches) == 1 + m = matches[0] + + # The intron I op must be removed from the exon-only CIGAR. + assert 'I' not in m.cigar or '30I' not in m.cigar + # One intron interval recorded at the exon junction (CDS offset 30). + assert len(m.intron_intervals) == 1 + assert m.intron_intervals[0].cds_junction_pos == 30 + assert m.intron_intervals[0].length == 30 + # Identity reflects exons only (perfect match → ~1.0), not crashed by intron. + assert m.identity > 0.95 + # CDS coverage is 1.0 over the exon span (60 nt). + assert m.cds_coverage == 1.0 + + def test_spliced_query_no_intron_op_classified(self) -> None: + """A spliced query (exons only, no intron) leaves the CIGAR with no + intron intervals — the happy path is unchanged.""" + feature = _spliced_feature() + query = feature.nt_sequence # already spliced + + matches = match_query_to_features(query, [feature]) + assert len(matches) == 1 + m = matches[0] + assert m.intron_intervals == () + assert m.identity > 0.95 + + def test_reverse_strand_spliced_feature_classifies_intron(self) -> None: + """A '-' strand two-exon CDS aligned against an unspliced query must + classify the intron at the correct exon junction. This is the HCMV + UL89 scenario: the normalized CIGAR is reversed for '-' strand, so + junction offsets must be in genomic (segment_index) order, not coding + order, to match the CIGAR's walking order.""" + from Bio.Seq import Seq + + exon1 = 'ATGAAAGCTTTTGGCCCCAAATTTGGGCCC' # 30 nt, genomic 5' segment + exon2 = 'AAGCTTTTGGCCCCAAATTTGGGCCCAAT'[:30] + exon2 = (exon2 + 'A' * 30)[:30] + # '-' strand: the spliced CDS is revcomp(exon1+exon2) read 5'->3'. + spliced_cds = str(Seq(exon1 + exon2).reverse_complement()) + feature = FeatureRecord( + id=2, reference_id=1, name='spl_minus', protein='SplM', + start=0, end=90, strand='-', codon_start=0, + nt_sequence=spliced_cds, + segments=( + FeatureSegment(segment_index=0, start=0, end=30), + FeatureSegment(segment_index=1, start=60, end=90), + ), + ) + # Unspliced query: exon1 + intron + exon2 (genomic 5'->3'). + intron = 'N' * 30 + query = exon1 + intron + exon2 + + matches = match_query_to_features(query, [feature]) + assert len(matches) == 1 + m = matches[0] + # The intron must be classified (one interval), not left as a giant I op. + assert len(m.intron_intervals) == 1, ( + f'expected 1 intron interval for - strand spliced feature, got {m.intron_intervals}' + ) + assert m.intron_intervals[0].length == 30 + # Exon-only CIGAR must not contain the 30I intron op. + assert '30I' not in m.cigar + # Identity reflects exons only. + assert m.identity > 0.95 + assert m.cds_coverage == 1.0 + + +# ────────────────────────────────────────────────────────────────────── +# fasta_to_vcf — intron must not become a giant insertion variant +# ────────────────────────────────────────────────────────────────────── + +class TestFastaToVcfIntron: + def test_unspliced_query_emits_no_intron_insertion_variant(self) -> None: + """The prove-it test for the real bug: an unspliced query (exon1 + + intron + exon2) must NOT emit a giant ~intron-length frameshift + insertion variant. Only genuine exon SNPs (if any) are emitted.""" + feature = _spliced_feature() + intron = 'N' * 30 + query = feature.nt_sequence[:30] + intron + feature.nt_sequence[30:] + + matches = match_query_to_features(query, [feature]) + assert len(matches) == 1 + variants, gaps = fasta_to_vcf(query, matches) + + # No variant may have an ALT longer than a few bases (no 30-nt intron insertion). + intron_insertions = [v for v in variants if len(v.alt) - len(v.ref) >= 30] + assert intron_insertions == [], ( + f'Expected no intron-length insertion variant, got {intron_insertions}' + ) + # A perfect unspliced query (exons match exactly) yields zero variants. + assert variants == [] + + def test_unspliced_query_reports_exon_only_identity_and_coverage(self) -> None: + """For a perfect unspliced query, identity≈1.0 and cds_coverage=1.0 + (computed over exons, not the genomic span including the intron).""" + feature = _spliced_feature() + intron = 'N' * 30 + query = feature.nt_sequence[:30] + intron + feature.nt_sequence[30:] + + matches = match_query_to_features(query, [feature]) + assert len(matches) == 1 + m = matches[0] + assert m.identity == pytest.approx(1.0, abs=1e-6) + assert m.cds_coverage == pytest.approx(1.0, abs=1e-6) + assert m.intron_intervals, 'spliced alignment should carry intron intervals' + + def test_spliced_query_happy_path_unchanged(self) -> None: + """A spliced query (exons only, no intron) follows the unchanged happy + path: no intron I op is present, so nothing is classified as an intron + and identity/coverage reflect a perfect match.""" + feature = _spliced_feature() + query = feature.nt_sequence # spliced CDS, no intron + + matches = match_query_to_features(query, [feature]) + assert len(matches) == 1 + m = matches[0] + assert m.intron_intervals == () + assert m.identity == pytest.approx(1.0, abs=1e-6) + variants, _ = fasta_to_vcf(query, matches) + assert variants == [] + + def test_unspliced_query_with_exon_snp_emits_only_the_snp(self) -> None: + """A SNP in exon 2 of an unspliced query is emitted; the intron is not.""" + feature = _spliced_feature() + intron = 'N' * 30 + # Introduce one SNP at exon 2 position 45 (CDS offset 45). + exon2_snp = ( + feature.nt_sequence[:30] + + intron + + feature.nt_sequence[30:45] + + ('T' if feature.nt_sequence[45] != 'T' else 'A') + + feature.nt_sequence[46:] + ) + matches = match_query_to_features(exon2_snp, [feature]) + assert len(matches) == 1 + variants, _ = fasta_to_vcf(exon2_snp, matches) + + # Exactly one variant (the exon-2 SNP), no intron insertion. + intron_insertions = [v for v in variants if len(v.alt) - len(v.ref) >= 30] + assert intron_insertions == [] + assert len(variants) == 1 + + def test_real_coding_insertion_inside_exon_is_still_emitted(self) -> None: + """A genuine 3-nt coding insertion inside exon 1 (not at the junction) + is still emitted as a variant — intron classification must not suppress + real in-exon insertions. Tested on a spliced query (no intron present) + so the insertion is the only indel; the intron-classification path is + a no-op here (no junction I op), proving it never suppresses real I ops.""" + feature = _spliced_feature() + # Insert 3 nt 'AAA' at CDS offset 10 (inside exon 1, far from junction 30). + query = feature.nt_sequence[:10] + 'AAA' + feature.nt_sequence[10:] + + matches = match_query_to_features(query, [feature]) + assert len(matches) == 1 + # No intron classified (the 3I is at offset 10, not near junction 30). + assert matches[0].intron_intervals == () + variants, _ = fasta_to_vcf(query, matches) + + # The 3-nt insertion should appear as an insertion variant (alt longer than ref). + insertions = [v for v in variants if len(v.alt) > len(v.ref)] + assert any(len(v.alt) - len(v.ref) == 3 for v in insertions), ( + f'Expected a 3-nt coding insertion variant, got {variants}' + ) + + +# ────────────────────────────────────────────────────────────────────── +# VCF remap — intron-aware query-to-CDS coordinate map +# ────────────────────────────────────────────────────────────────────── + +class TestVcfRemapIntron: + def test_exon2_variant_in_unspliced_query_remaps_to_second_segment(self) -> None: + """A VCF variant in exon 2 of an unspliced query (exon1+intron+exon2) + must remap to the correct second-segment genomic position, not be + shifted by the intron length or land in the intron query span.""" + feature = _spliced_feature() # exon1 0..30, exon2 60..90 (genomic) + intron = 'N' * 30 + query = feature.nt_sequence[:30] + intron + feature.nt_sequence[30:] + + matches = match_query_to_features(query, [feature]) + assert len(matches) == 1 + m = matches[0] + assert len(m.intron_intervals) == 1 + + # A variant at the first base of exon 2 in the query. + # Query layout: exon1 [0..30) + intron [30..60) + exon2 [60..90). + # First exon-2 base is at query position 60. + exon2_first_base = query[60] + var = VariantCall( + chrom='c', pos=60, ref=exon2_first_base, alt='T', allele_freq=1.0, depth=50, + ) + remapped, warnings = remap_variants([var], [m], query) + + assert not warnings + assert len(remapped) == 1 + # Exon 2 starts at genomic position 60; first exon-2 base → genomic 60. + assert remapped[0].pos == 60 + + def test_variant_inside_intron_query_span_is_excluded(self) -> None: + """A VCF variant whose query position falls inside the intron span is + not remapped (it is non-coding).""" + feature = _spliced_feature() + intron = 'N' * 30 + query = feature.nt_sequence[:30] + intron + feature.nt_sequence[30:] + + matches = match_query_to_features(query, [feature]) + m = matches[0] + # Variant at query position 45 (middle of the intron span 30..60). + var = VariantCall(chrom='c', pos=45, ref='N', alt='A', allele_freq=1.0, depth=50) + remapped, warnings = remap_variants([var], [m], query) + + assert remapped == [] + + def test_build_query_to_cds_map_skips_intron_query_span(self) -> None: + """The query-to-CDS map must skip intron query positions and offset + exon-2 CDS positions past the intron span.""" + feature = _spliced_feature() + intron = 'N' * 30 + query = feature.nt_sequence[:30] + intron + feature.nt_sequence[30:] + + matches = match_query_to_features(query, [feature]) + m = matches[0] + + q2c = _build_query_to_cds_map( + m.cigar, m.query_start, m.query_end, m.strand, len(query), m.cds_start, + m.intron_intervals, + ) + # Exon 1: query 0..30 → CDS 0..30. + assert q2c[0] == 0 + assert q2c[29] == 29 + # Intron query positions 30..59 must NOT be in the map. + for qpos in range(30, 60): + assert qpos not in q2c, f'intron query pos {qpos} should not map to a CDS pos' + # Exon 2: query 60 → CDS 30 (first base of exon 2 in the spliced CDS). + assert q2c[60] == 30 + assert q2c[89] == 59 + + def test_reverse_strand_exon_variants_remap_correctly(self) -> None: + """'-' strand spliced feature against an unspliced query: exon-1 and + exon-2 VCF variants must remap to the correct genomic segments, and + intron-span variants must be excluded. This is the HCMV UL89 scenario + (the motivating bug).""" + from Bio.Seq import Seq + + exon1 = 'ATGAAAGCTTTTGGCCCCAAATTTGGGCCC' # 30 nt, genomic seg0 [0..30) + exon2 = 'AAGCTTTTGGCCCCAAATTTGGGCCCAAT'[:30] + exon2 = (exon2 + 'A' * 30)[:30] + spliced_cds = str(Seq(exon1 + exon2).reverse_complement()) + feature = FeatureRecord( + id=3, reference_id=1, name='splm', protein='SplM', + start=0, end=90, strand='-', codon_start=0, + nt_sequence=spliced_cds, + segments=( + FeatureSegment(segment_index=0, start=0, end=30), + FeatureSegment(segment_index=1, start=60, end=90), + ), + ) + intron = 'N' * 30 + query = exon1 + intron + exon2 # unspliced, genomic 5'->3' + + matches = match_query_to_features(query, [feature]) + assert len(matches) == 1 + m = matches[0] + assert len(m.intron_intervals) == 1, ( + f'expected 1 intron interval, got {m.intron_intervals}' + ) + + q2c = _build_query_to_cds_map( + m.cigar, m.query_start, m.query_end, m.strand, len(query), m.cds_start, + m.intron_intervals, + ) + + # Intron query positions [30..60) must NOT be in the map. + for qpos in range(30, 60): + assert qpos not in q2c, f'intron query pos {qpos} should not map to a CDS pos' + + # For '-' strand the spliced CDS is revcomp(exon1+exon2); coding 5'->3' + # order is seg1 (genomic exon2) then seg0 (genomic exon1). The normalized + # CIGAR walks genomic 5'->3' (seg0 then seg1), so: + # query [0..30) = genomic exon1 (seg0) → CDS offsets [30..60) + # query [60..90) = genomic exon2 (seg1) → CDS offsets [0..30) + exon1_cds = {q2c[q] for q in range(0, 30) if q in q2c} + exon2_cds = {q2c[q] for q in range(60, 90) if q in q2c} + assert exon1_cds, 'exon-1 query positions must map to CDS positions' + assert exon2_cds, 'exon-2 query positions must map to CDS positions' + assert all(30 <= c < 60 for c in exon1_cds), ( + f'exon-1 (genomic seg0) CDS offsets must be in [30,60), got {exon1_cds}' + ) + assert all(0 <= c < 30 for c in exon2_cds), ( + f'exon-2 (genomic seg1) CDS offsets must be in [0,30), got {exon2_cds}' + ) + + # End-to-end remap: an exon-2 variant (query pos 60, first base of seg1) + # maps to CDS offset 29 (last coding base of seg1) → genomic 60, inside + # segment 1 [60..90). A regression that mis-remaps to seg0 must fail. + var_exon2 = VariantCall( + chrom='c', pos=60, ref=query[60], alt='T', allele_freq=1.0, depth=50, + ) + remapped, warnings = remap_variants([var_exon2], [m], query) + assert not warnings, f'unexpected warnings: {warnings}' + assert len(remapped) == 1, f'expected 1 remapped variant, got {remapped}' + genomic = remapped[0].pos + assert genomic == 60, ( + f'exon-2 variant should remap to genomic 60 (seg1), got {genomic}' + ) + + # An intron-span variant is excluded. + var_intron = VariantCall( + chrom='c', pos=45, ref='N', alt='A', allele_freq=1.0, depth=50, + ) + remapped_intron, _ = remap_variants([var_intron], [m], query) + assert remapped_intron == [] + + def test_adjacent_m_runs_merged_in_exon_only_cigar(self) -> None: + """After removing an intron I op, adjacent M runs are merged.""" + cigar, introns = classify_introns( + cigar='6M3902I7M', junction_offsets=[6], tolerance=5, + ) + # 6M + 7M should merge to 13M for a clean exon-only CIGAR. + assert cigar == '13M' + + +# ────────────────────────────────────────────────────────────────────── +# Cache round-trip for intron_intervals +# ────────────────────────────────────────────────────────────────────── + +class TestCacheIntronRoundTrip: + """Storing and reloading a FeatureMatch with intron_intervals must preserve + the intron intervals and the exon-only CIGAR exactly.""" + + def test_store_load_preserves_intron_intervals(self, tmp_path) -> None: # type: ignore[no-untyped-def] + from respro.db.cache import load_cached_mappings, sequence_checksum, store_mappings + from respro.db.schema import create_schema, open_project_db + + db_path = tmp_path / 'test_project.db' + conn = create_schema(db_path) + conn.execute( + 'INSERT INTO project (name, schema_version) VALUES (?, ?)', ('Test', 1), + ) + conn.execute( + 'INSERT INTO reference (project_id, name, length) VALUES (?, ?, ?)', + (1, 'ref1', 90), + ) + feature = _spliced_feature() + conn.execute( + 'INSERT INTO feature (reference_id, name, protein, start, end, strand, ' + 'codon_start, nt_sequence, aa_sequence) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', + ( + feature.reference_id, feature.name, feature.protein, + feature.start, feature.end, feature.strand, + feature.codon_start, feature.nt_sequence, feature.aa_sequence, + ), + ) + for seg in feature.segments: + conn.execute( + 'INSERT INTO feature_segment (feature_id, segment_index, start, end) ' + 'VALUES (?, ?, ?, ?)', + (feature.id, seg.segment_index, seg.start, seg.end), + ) + conn.commit() + + intron = 'N' * 30 + query = feature.nt_sequence[:30] + intron + feature.nt_sequence[30:] + matches = match_query_to_features(query, [feature]) + assert len(matches) == 1 + assert matches[0].intron_intervals, 'spliced alignment should carry intron intervals' + + chk = sequence_checksum(query) + store_mappings(conn, 'unspliced_ref', query, chk, matches) + conn.close() + + # Re-open via open_project_db to exercise the optional-column migration. + conn2 = open_project_db(db_path) + loaded = load_cached_mappings(conn2, chk) + conn2.close() + + assert loaded is not None + assert len(loaded) == 1 + m = loaded[0] + assert m.cigar == matches[0].cigar + assert m.intron_intervals == matches[0].intron_intervals + assert len(m.intron_intervals) == 1 + iv = m.intron_intervals[0] + assert iv.cds_junction_pos == 30 + assert iv.query_start == 30 + assert iv.length == 30 + + def test_legacy_rows_without_intron_column_backfill_as_empty(self, tmp_path) -> None: # type: ignore[no-untyped-def] + """A row inserted before the intron_intervals column existed must load + with an empty intron_intervals tuple after migration.""" + from respro.db.cache import load_cached_mappings, sequence_checksum, store_mappings + from respro.db.schema import create_schema, open_project_db + + db_path = tmp_path / 'test_project.db' + conn = create_schema(db_path) + conn.execute( + 'INSERT INTO project (name, schema_version) VALUES (?, ?)', ('Test', 1), + ) + conn.execute( + 'INSERT INTO reference (project_id, name, length) VALUES (?, ?, ?)', + (1, 'ref1', 30), + ) + conn.execute( + 'INSERT INTO feature (reference_id, name, protein, start, end, strand, ' + 'nt_sequence, aa_sequence) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + (1, 'gag', 'Gag', 0, 30, '+', 'ATGAAAGCTTTTGGCCCCAAATTTGGGCCC', 'MKAFGPKFGP'), + ) + conn.commit() + + feature = FeatureRecord( + id=1, reference_id=1, name='gag', protein='Gag', + start=0, end=30, strand='+', codon_start=0, + nt_sequence='ATGAAAGCTTTTGGCCCCAAATTTGGGCCC', aa_sequence='MKAFGPKFGP', + segments=tuple(), + ) + from respro.db.models import FeatureMatch + legacy_match = FeatureMatch( + feature=feature, identity=1.0, cds_coverage=1.0, query_coverage=1.0, + cds_start=0, query_start=0, query_end=30, strand='+', cigar='30M', + intron_intervals=tuple(), + ) + query = 'ATGAAAGCTTTTGGCCCCAAATTTGGGCCC' + chk = sequence_checksum(query) + store_mappings(conn, 'legacy_ref', query, chk, [legacy_match]) + conn.close() + + conn2 = open_project_db(db_path) + loaded = load_cached_mappings(conn2, chk) + conn2.close() + + assert loaded is not None + assert len(loaded) == 1 + assert loaded[0].intron_intervals == tuple() + + +# ────────────────────────────────────────────────────────────────────── +# Alignment visualization for spliced features (verification) +# ────────────────────────────────────────────────────────────────────── + +class TestAlignmentVisualizationSpliced: + """The per-codon alignment snippet must render exon1→exon2 with no intron + gap row, because the exon-only CIGAR + intron-stripped region leave only + coding bases.""" + + def test_snippet_has_no_intron_gap_and_exon2_aligns(self) -> None: + from respro.report.alignment_visualization import build_feature_alignments + + feature = _spliced_feature() + intron = 'N' * 30 + query = feature.nt_sequence[:30] + intron + feature.nt_sequence[30:] + matches = match_query_to_features(query, [feature]) + assert len(matches) == 1 + m = matches[0] + assert m.intron_intervals, 'spliced alignment should carry intron intervals' + + alns = build_feature_alignments(query, [m]) + assert 'spl' in alns + aln = alns['spl'] + # No gap row spanning ~30 intron bases: the aligned strings must be + # exactly the spliced CDS length (60) with no long '-' run. + assert len(aln.aligned_ref) == len(aln.aligned_query) + # The longest run of '-' in aligned_ref must be far shorter than the + # 30-nt intron (at most a few nt for any unaligned CDS tail). + import re as _re + longest_gap = max((len(run) for run in _re.findall(r'-+', aln.aligned_ref)), default=0) + assert longest_gap < 10, f'intron gap leaked into snippet: {longest_gap}-nt gap' + # Exon 2 query bases must align to exon 2 CDS bases (first exon-2 base + # is the last char of the spliced CDS region after stripping). + # The aligned query must contain exon2 sequence contiguously. + exon2 = feature.nt_sequence[30:] + assert exon2 in aln.aligned_query.replace('-', ''), 'exon 2 not aligned contiguously' + + def test_identity_is_exon_only_not_genomic(self) -> None: + """match.identity carried into the report must reflect exon-only identity + (~1.0 for a perfect exons match), not the genomic 33.96%.""" + feature = _spliced_feature() + intron = 'N' * 30 + query = feature.nt_sequence[:30] + intron + feature.nt_sequence[30:] + matches = match_query_to_features(query, [feature]) + assert len(matches) == 1 + assert matches[0].identity > 0.99 From 68aeb43607626a2a958748a0e14e1231d5df9373 Mon Sep 17 00:00:00 2001 From: jonas Date: Fri, 21 Aug 2026 16:22:32 +0200 Subject: [PATCH 08/19] fixed failing test due to example files --- tests/test_maintained_bootstrap.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_maintained_bootstrap.py b/tests/test_maintained_bootstrap.py index 49f0d05..8daa533 100644 --- a/tests/test_maintained_bootstrap.py +++ b/tests/test_maintained_bootstrap.py @@ -46,6 +46,7 @@ def _make_files(tmp_path: Path, *, tsv_checksum: str = _MANAGER_TSV_CHECKSUM) -> 'rules': rules, 'metadata': metadata, 'formula_rules': None, + 'example': None, 'genbank': [gb], } @@ -80,7 +81,7 @@ def _install_init_checksum_writer(remote_checksum: str) -> None: """ def fake_init(*, db_path, name, genbank_paths, rules_tsv, formula_rules_tsv, metadata_json, - overwrite=False, additional_info=True): + overwrite=False, additional_info=True, example_fasta=None): # Build a minimal valid DB then stamp the remote checksum. from respro.db.schema import create_schema @@ -262,7 +263,7 @@ def test_downloads_missing_database_and_initializes_atomically(self, tmp_path: P init_calls: list[Path] = [] def init_side_effect(*, db_path, name, genbank_paths, rules_tsv, formula_rules_tsv, - metadata_json, overwrite=False, additional_info=True): + metadata_json, overwrite=False, additional_info=True, example_fasta=None): from respro.db.schema import create_schema conn = create_schema(db_path) From 1ddb9eb39b254d1868a6a272fceb3d72f459ca6a Mon Sep 17 00:00:00 2001 From: jonas Date: Mon, 24 Aug 2026 09:41:50 +0200 Subject: [PATCH 09/19] fixed mypy --- respro/cli/fasta.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/respro/cli/fasta.py b/respro/cli/fasta.py index 2ed926e..d03e188 100644 --- a/respro/cli/fasta.py +++ b/respro/cli/fasta.py @@ -101,6 +101,8 @@ def _profile_fasta_command( cli_error('Provide either --fasta or --example, not both.') if not use_example and consensus_fasta is None: cli_error('Provide --fasta or --example to specify the input consensus sequence.') + # cli_error raises typer.Exit, but mypy cannot infer that; narrow explicitly. + assert consensus_fasta is not None example_temp_path: Path | None = None try: @@ -120,6 +122,7 @@ def _profile_fasta_command( f'No example FASTA is stored in project database {project!s}. ' 'Add one with `respro init --example `.' ) + assert example_text is not None temp_fd, temp_name = tempfile.mkstemp(prefix='respro_example_', suffix='.fasta') example_temp_path = Path(temp_name) os.close(temp_fd) From 9c972197cdd12de63a2aa58150a491be177046ac Mon Sep 17 00:00:00 2001 From: jonas Date: Mon, 24 Aug 2026 09:52:21 +0200 Subject: [PATCH 10/19] fixed failing test --- respro/cli/fasta.py | 5 +++-- tests/test_profile_fasta.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/respro/cli/fasta.py b/respro/cli/fasta.py index d03e188..97516e4 100644 --- a/respro/cli/fasta.py +++ b/respro/cli/fasta.py @@ -101,8 +101,6 @@ def _profile_fasta_command( cli_error('Provide either --fasta or --example, not both.') if not use_example and consensus_fasta is None: cli_error('Provide --fasta or --example to specify the input consensus sequence.') - # cli_error raises typer.Exit, but mypy cannot infer that; narrow explicitly. - assert consensus_fasta is not None example_temp_path: Path | None = None try: @@ -129,6 +127,9 @@ def _profile_fasta_command( example_temp_path.write_text(example_text) consensus_fasta = example_temp_path + # cli_error raises typer.Exit, but mypy cannot infer that; narrow explicitly. + assert consensus_fasta is not None + results_conn = _init_results_db_connection( str(results_db) if results_db else None, project_conn, logger, ) diff --git a/tests/test_profile_fasta.py b/tests/test_profile_fasta.py index 2311fe5..ee08eb3 100644 --- a/tests/test_profile_fasta.py +++ b/tests/test_profile_fasta.py @@ -1480,7 +1480,7 @@ def test_example_profiles_stored_fasta( ]) assert result.exit_code == 0, result.output - assert '1 total database hits' in result.output + assert '1 total database hits' in result.output or '1 hit' in result.output def test_example_matches_explicit_fasta_result( self, fasta_db_with_example: Path, tmp_path: Path, From f4966cfd87b666a2b900e81ed16defd2d181478f Mon Sep 17 00:00:00 2001 From: jonas Date: Mon, 24 Aug 2026 12:24:13 +0200 Subject: [PATCH 11/19] increased test coverage --- tests/test_config.py | 350 +++++++++++++++++++++++++ tests/test_features.py | 213 ++++++++++++++++ tests/test_formula_parsing.py | 416 ++++++++++++++++++++++++++++++ tests/test_report_queries.py | 177 +++++++++++++ tests/test_rules_alleles.py | 308 ++++++++++++++++++++++ tests/test_rules_normalize.py | 467 ++++++++++++++++++++++++++++++++++ tests/test_utils_cli.py | 150 +++++++++++ 7 files changed, 2081 insertions(+) create mode 100644 tests/test_config.py create mode 100644 tests/test_features.py create mode 100644 tests/test_formula_parsing.py create mode 100644 tests/test_report_queries.py create mode 100644 tests/test_rules_alleles.py create mode 100644 tests/test_rules_normalize.py create mode 100644 tests/test_utils_cli.py diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..a694a29 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,350 @@ +""" +Tests for configuration loading and validation. + +Covers: respro/config/cli_settings.py +- _load_cli_config() +- All dataclass configuration types +- defaults.toml structure validation +""" + +from __future__ import annotations + +import pytest +import tomllib +from importlib.resources import files + +from respro.config.cli_settings import ( + CliAfBinsConfig, + CliAlignmentConfig, + CliConfig, + CliMatchingConfig, + CliParsingConfig, + CliSimilarityConfig, + CliTimeoutConfig, + CliUrlConfig, + _load_cli_config, +) + + +class TestLoadCliConfig: + """Tests for _load_cli_config().""" + + def test_loads_without_error(self): + """Should load configuration without errors.""" + config = _load_cli_config() + assert isinstance(config, CliConfig) + + def test_returns_cli_config_instance(self): + """Should return CliConfig dataclass.""" + config = _load_cli_config() + assert hasattr(config, 'timeouts') + assert hasattr(config, 'urls') + assert hasattr(config, 'parsing') + assert hasattr(config, 'matching') + assert hasattr(config, 'similarity') + assert hasattr(config, 'af_bins') + assert hasattr(config, 'af_bins_fasta') + assert hasattr(config, 'alignment') + + def test_all_sections_populated(self): + """Should populate all configuration sections.""" + config = _load_cli_config() + assert config.timeouts is not None + assert config.urls is not None + assert config.parsing is not None + assert config.matching is not None + assert config.similarity is not None + assert config.af_bins is not None + assert config.af_bins_fasta is not None + assert config.alignment is not None + + +class TestCliTimeoutConfig: + """Tests for CliTimeoutConfig.""" + + def test_pubchem_timeout_positive(self): + """Should have positive pubchem timeout.""" + config = _load_cli_config() + assert config.timeouts.pubchem > 0 + assert isinstance(config.timeouts.pubchem, int) + + def test_pubmed_timeout_positive(self): + """Should have positive pubmed timeout.""" + config = _load_cli_config() + assert config.timeouts.pubmed > 0 + assert isinstance(config.timeouts.pubmed, int) + + def test_crossref_timeout_positive(self): + """Should have positive crossref timeout.""" + config = _load_cli_config() + assert config.timeouts.crossref > 0 + assert isinstance(config.timeouts.crossref, int) + + +class TestCliUrlConfig: + """Tests for CliUrlConfig.""" + + def test_pubchem_urls_have_placeholders(self): + """Should have {cid} placeholder in pubchem URLs.""" + config = _load_cli_config() + assert '{cid}' in config.urls.pubchem_compound_page + assert '{cid}' in config.urls.pubchem_structure_png + assert '{name}' in config.urls.pubchem_cid_lookup + + def test_ncbi_urls_have_placeholders(self): + """Should have {pmid}/{identifier} placeholders in NCBI URLs.""" + config = _load_cli_config() + assert '{pmid}' in config.urls.ncbi_pubmed_esummary + assert '{identifier}' in config.urls.ncbi_pmc_idconv + assert '{accession}' in config.urls.ncbi_nuccore_efetch + + def test_crossref_url_has_placeholder(self): + """Should have {doi} placeholder in CrossRef URL.""" + config = _load_cli_config() + assert '{doi}' in config.urls.crossref_works + + def test_protein_url_has_placeholder(self): + """Should have {protein_id} placeholder in protein URL.""" + config = _load_cli_config() + assert '{protein_id}' in config.urls.ncbi_protein_page + + def test_github_raw_url_present(self): + """Should have GitHub raw URL.""" + config = _load_cli_config() + assert 'githubusercontent.com' in config.urls.github_respro_db_raw + + def test_all_urls_are_strings(self): + """Should all be string type.""" + config = _load_cli_config() + for field_name in dir(config.urls): + if not field_name.startswith('_'): + value = getattr(config.urls, field_name) + assert isinstance(value, str) + + +class TestCliParsingConfig: + """Tests for CliParsingConfig.""" + + def test_doi_prefixes_is_tuple(self): + """Should have DOI prefixes as tuple.""" + config = _load_cli_config() + assert isinstance(config.parsing.doi_prefixes, tuple) + + def test_doi_prefixes_not_empty(self): + """Should have at least one DOI prefix.""" + config = _load_cli_config() + assert len(config.parsing.doi_prefixes) > 0 + + def test_doi_prefixes_are_strings(self): + """Should all be strings.""" + config = _load_cli_config() + for prefix in config.parsing.doi_prefixes: + assert isinstance(prefix, str) + + +class TestCliMatchingConfig: + """Tests for CliMatchingConfig.""" + + def test_af_threshold_is_float(self): + """Should have float AF threshold.""" + config = _load_cli_config() + assert isinstance(config.matching.combination_member_af_threshold, float) + + def test_af_threshold_in_valid_range(self): + """Should be between 0 and 1.""" + config = _load_cli_config() + assert 0 < config.matching.combination_member_af_threshold < 1 + + +class TestCliSimilarityConfig: + """Tests for CliSimilarityConfig.""" + + def test_high_threshold_positive(self): + """Should have positive high threshold.""" + config = _load_cli_config() + assert config.similarity.high > 0 + assert isinstance(config.similarity.high, int) + + def test_moderate_threshold_non_negative(self): + """Should have non-negative moderate threshold.""" + config = _load_cli_config() + assert config.similarity.moderate >= 0 + assert isinstance(config.similarity.moderate, int) + + def test_high_greater_than_moderate(self): + """High threshold should be greater than moderate.""" + config = _load_cli_config() + assert config.similarity.high > config.similarity.moderate + + +class TestCliAfBinsConfig: + """Tests for CliAfBinsConfig.""" + + def test_high_bin_valid_range(self): + """High bin should be in valid range.""" + config = _load_cli_config() + high = config.af_bins.high + assert 0 <= high[0] <= high[1] <= 1 + + def test_intermediate_bin_valid_range(self): + """Intermediate bin should be in valid range.""" + config = _load_cli_config() + intermediate = config.af_bins.intermediate + assert 0 <= intermediate[0] <= intermediate[1] <= 1 + + def test_low_bin_valid_range(self): + """Low bin should be in valid range.""" + config = _load_cli_config() + low = config.af_bins.low + assert 0 <= low[0] <= low[1] <= 1 + + def test_bins_dont_overlap(self): + """Bins should not overlap.""" + config = _load_cli_config() + assert config.af_bins.low[1] < config.af_bins.intermediate[0] + assert config.af_bins.intermediate[1] < config.af_bins.high[0] + + def test_as_dict_method(self): + """Should return dict from as_dict().""" + config = _load_cli_config() + result = config.af_bins.as_dict() + assert isinstance(result, dict) + assert 'high' in result + assert 'intermediate' in result + assert 'low' in result + + def test_fasta_bins_different_from_vcf(self): + """FASTA bins should differ from VCF bins.""" + config = _load_cli_config() + assert config.af_bins.intermediate != config.af_bins_fasta.intermediate + + +class TestCliAlignmentConfig: + """Tests for CliAlignmentConfig.""" + + def test_preset_is_string(self): + """Should have string preset.""" + config = _load_cli_config() + assert isinstance(config.alignment.preset, str) + assert len(config.alignment.preset) > 0 + + def test_k_positive(self): + """Should have positive k (k-mer size).""" + config = _load_cli_config() + assert config.alignment.k > 0 + assert isinstance(config.alignment.k, int) + + def test_w_positive(self): + """Should have positive w (minimizer window).""" + config = _load_cli_config() + assert config.alignment.w > 0 + assert isinstance(config.alignment.w, int) + + def test_best_n_positive(self): + """Should have positive best_n.""" + config = _load_cli_config() + assert config.alignment.best_n > 0 + assert isinstance(config.alignment.best_n, int) + + def test_gap_open_penalty_positive(self): + """Should have positive gap open penalty.""" + config = _load_cli_config() + assert config.alignment.gap_open_penalty > 0 + assert isinstance(config.alignment.gap_open_penalty, int) + + def test_match_score_positive(self): + """Should have positive match score.""" + config = _load_cli_config() + assert config.alignment.match_score > 0 + assert isinstance(config.alignment.match_score, int) + + def test_mismatch_penalty_positive(self): + """Should have positive mismatch penalty.""" + config = _load_cli_config() + assert config.alignment.mismatch_penalty > 0 + assert isinstance(config.alignment.mismatch_penalty, int) + + def test_gap_extension_penalty_1_positive(self): + """Should have positive gap extension penalty 1.""" + config = _load_cli_config() + assert config.alignment.gap_extension_penalty_1 > 0 + assert isinstance(config.alignment.gap_extension_penalty_1, int) + + def test_gap_open_penalty_2_positive(self): + """Should have positive gap open penalty 2.""" + config = _load_cli_config() + assert config.alignment.gap_open_penalty_2 > 0 + assert isinstance(config.alignment.gap_open_penalty_2, int) + + def test_gap_extension_penalty_2_positive(self): + """Should have positive gap extension penalty 2.""" + config = _load_cli_config() + assert config.alignment.gap_extension_penalty_2 > 0 + assert isinstance(config.alignment.gap_extension_penalty_2, int) + + def test_intron_junction_tolerance_non_negative(self): + """Should have non-negative intron junction tolerance.""" + config = _load_cli_config() + assert config.alignment.intron_junction_tolerance >= 0 + assert isinstance(config.alignment.intron_junction_tolerance, int) + + +class TestDefaultsTomlStructure: + """Tests for defaults.toml file structure.""" + + def test_file_exists_and_valid_toml(self): + """Should exist and be valid TOML.""" + defaults_path = files('respro.config').joinpath('defaults.toml') + content = defaults_path.read_text(encoding='utf-8') + # Should not raise tomllib.TOMLDecodeError + payload = tomllib.loads(content) + assert payload is not None + + def test_all_required_sections_present(self): + """Should have all required sections.""" + defaults_path = files('respro.config').joinpath('defaults.toml') + payload = tomllib.loads(defaults_path.read_text(encoding='utf-8')) + required_sections = [ + 'timeouts', + 'urls', + 'parsing', + 'matching', + 'similarity', + 'af_bins', + 'af_bins_fasta', + 'alignment', + ] + for section in required_sections: + assert section in payload, f"Missing section: {section}" + + def test_no_duplicate_keys(self): + """Should not have duplicate keys in sections.""" + defaults_path = files('respro.config').joinpath('defaults.toml') + payload = tomllib.loads(defaults_path.read_text(encoding='utf-8')) + # tomllib would fail on duplicate keys, so if we got here, it's valid + assert len(payload) > 0 + + def test_timeouts_section_structure(self): + """Should have correct timeouts structure.""" + defaults_path = files('respro.config').joinpath('defaults.toml') + payload = tomllib.loads(defaults_path.read_text(encoding='utf-8')) + timeouts = payload['timeouts'] + assert 'pubchem' in timeouts + assert 'pubmed' in timeouts + assert 'crossref' in timeouts + + def test_urls_section_structure(self): + """Should have correct urls structure.""" + defaults_path = files('respro.config').joinpath('defaults.toml') + payload = tomllib.loads(defaults_path.read_text(encoding='utf-8')) + urls = payload['urls'] + assert 'pubchem_compound_page' in urls + assert 'ncbi_pubmed_esummary' in urls + assert 'crossref_works' in urls + + def test_alignment_section_has_intron_junction_tolerance(self): + """Should have intron_junction_tolerance in alignment section.""" + defaults_path = files('respro.config').joinpath('defaults.toml') + payload = tomllib.loads(defaults_path.read_text(encoding='utf-8')) + alignment = payload['alignment'] + assert 'intron_junction_tolerance' in alignment \ No newline at end of file diff --git a/tests/test_features.py b/tests/test_features.py new file mode 100644 index 0000000..fc7ae68 --- /dev/null +++ b/tests/test_features.py @@ -0,0 +1,213 @@ +""" +Tests for feature and reference loading. + +Covers: respro/db/features.py +- load_features_for_reference() +- load_feature_segments_by_feature_id() +- _is_ncbi_protein_accession() +- _resolve_ncbi_protein_url() +""" + +from __future__ import annotations + +import sqlite3 + +import pytest + +from respro.db.features import ( + _is_ncbi_protein_accession, + _resolve_ncbi_protein_url, + load_feature_segments_by_feature_id, + load_features_for_reference, +) +from respro.db.models import FeatureRecord, FeatureSegment + + +@pytest.fixture +def in_memory_db(): + """Create in-memory database with minimal schema.""" + conn = sqlite3.connect(':memory:') + conn.row_factory = sqlite3.Row + # Create only the tables we need for these tests + conn.execute(''' + CREATE TABLE reference ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL, + name TEXT NOT NULL, + accession TEXT, + organism TEXT, + taxonomy TEXT, + length INTEGER NOT NULL + ) + ''') + conn.execute(''' + CREATE TABLE feature ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + reference_id INTEGER NOT NULL, + name TEXT NOT NULL, + protein TEXT, + start INTEGER NOT NULL, + end INTEGER NOT NULL, + strand TEXT NOT NULL, + codon_start INTEGER, + nt_sequence TEXT, + aa_sequence TEXT, + feature_type TEXT DEFAULT 'CDS', + parent_feature_name TEXT, + FOREIGN KEY (reference_id) REFERENCES reference(id) + ) + ''') + conn.execute(''' + CREATE TABLE feature_segment ( + feature_id INTEGER NOT NULL, + segment_index INTEGER NOT NULL, + start INTEGER NOT NULL, + end INTEGER NOT NULL, + PRIMARY KEY (feature_id, segment_index), + FOREIGN KEY (feature_id) REFERENCES feature(id) + ) + ''') + conn.commit() + return conn + + +class TestLoadFeatureSegmentsByFeatureId: + """Tests for load_feature_segments_by_feature_id().""" + + def test_returns_empty_dict_for_empty_list(self, in_memory_db): + """Should return empty dict for empty feature ID list.""" + result = load_feature_segments_by_feature_id(in_memory_db, []) + assert result == {} + + def test_returns_empty_dict_for_nonexistent_ids(self, in_memory_db): + """Should return empty dict for nonexistent IDs.""" + result = load_feature_segments_by_feature_id(in_memory_db, [999]) + assert result == {} + + def test_loads_segments_for_existing_features(self, in_memory_db): + """Should load segments for existing features.""" + # Insert test data + in_memory_db.execute( + 'INSERT INTO reference (id, project_id, name, length) VALUES (1, 1, "test", 1000)' + ) + in_memory_db.execute( + 'INSERT INTO feature (id, reference_id, name, start, end, strand) VALUES (1, 1, "gene1", 100, 500, "+")' + ) + in_memory_db.execute( + 'INSERT INTO feature_segment (feature_id, segment_index, start, end) VALUES (1, 0, 100, 300)' + ) + in_memory_db.execute( + 'INSERT INTO feature_segment (feature_id, segment_index, start, end) VALUES (1, 1, 400, 500)' + ) + in_memory_db.commit() + + result = load_feature_segments_by_feature_id(in_memory_db, [1]) + assert 1 in result + assert len(result[1]) == 2 + assert isinstance(result[1][0], FeatureSegment) + assert result[1][0].segment_index == 0 + assert result[1][1].segment_index == 1 + + +class TestLoadFeaturesForReference: + """Tests for load_features_for_reference().""" + + def test_returns_empty_list_for_no_features(self, in_memory_db): + """Should return empty list when no features exist.""" + in_memory_db.execute( + 'INSERT INTO reference (id, project_id, name, length) VALUES (1, 1, "test", 1000)' + ) + in_memory_db.commit() + result = load_features_for_reference(in_memory_db, 1) + assert result == [] + + def test_loads_features_with_segments(self, in_memory_db): + """Should load features with their segments.""" + # Insert test data + in_memory_db.execute( + 'INSERT INTO reference (id, project_id, name, length) VALUES (1, 1, "test", 1000)' + ) + in_memory_db.execute( + 'INSERT INTO feature (id, reference_id, name, start, end, strand, nt_sequence, aa_sequence, feature_type) ' + 'VALUES (1, 1, "gene1", 100, 500, "+", "ATGC", "MK", "CDS")' + ) + in_memory_db.execute( + 'INSERT INTO feature_segment (feature_id, segment_index, start, end) VALUES (1, 0, 100, 300)' + ) + in_memory_db.commit() + + result = load_features_for_reference(in_memory_db, 1) + assert len(result) == 1 + assert isinstance(result[0], FeatureRecord) + assert result[0].name == 'gene1' + assert len(result[0].segments) == 1 + + +class TestIsNcbiProteinAccession: + """Tests for _is_ncbi_protein_accession().""" + + def test_matches_xxx_format(self): + """Should match XXX12345.1 format.""" + assert _is_ncbi_protein_accession('AAA12345.1') is True + assert _is_ncbi_protein_accession('NP_123456.2') is True + + def test_matches_xx_underscore_format(self): + """Should match XX_123456.1 format.""" + assert _is_ncbi_protein_accession('YP_009137097.1') is True + assert _is_ncbi_protein_accession('NP_123456.2') is True + + def test_matches_xxxx_format(self): + """Should match XXXX12345678.1 format.""" + assert _is_ncbi_protein_accession('KAFS00000001.1') is True + + def test_rejects_no_version(self): + """Should reject accessions without version suffix.""" + assert _is_ncbi_protein_accession('AAA12345') is False + + def test_rejects_invalid_format(self): + """Should reject invalid formats.""" + assert _is_ncbi_protein_accession('invalid') is False + assert _is_ncbi_protein_accession('12345') is False + + def test_case_insensitive(self): + """Should be case-insensitive.""" + assert _is_ncbi_protein_accession('aaa12345.1') is True + assert _is_ncbi_protein_accession('yp_009137097.1') is True + + +class TestResolveNcbiProteinUrl: + """Tests for _resolve_ncbi_protein_url().""" + + def test_returns_url_for_valid_accession(self): + """Should return URL for valid accession.""" + cache: dict[str, str] = {} + url = _resolve_ncbi_protein_url('AAA12345.1', cache) + assert 'ncbi.nlm.nih.gov' in url + assert 'AAA12345.1' in url + + def test_returns_empty_for_invalid_accession(self): + """Should return empty string for invalid accession.""" + cache: dict[str, str] = {} + url = _resolve_ncbi_protein_url('invalid', cache) + assert url == '' + + def test_returns_empty_for_empty_input(self): + """Should return empty string for empty input.""" + cache: dict[str, str] = {} + url = _resolve_ncbi_protein_url('', cache) + assert url == '' + + def test_uses_cache(self): + """Should use cached URL.""" + cache: dict[str, str] = {} + url1 = _resolve_ncbi_protein_url('AAA12345.1', cache) + assert 'AAA12345.1' in cache + url2 = _resolve_ncbi_protein_url('AAA12345.1', cache) + assert url1 == url2 + assert len(cache) == 1 # Only one entry + + def test_strips_whitespace(self): + """Should strip whitespace from protein_id.""" + cache: dict[str, str] = {} + url = _resolve_ncbi_protein_url(' AAA12345.1 ', cache) + assert 'AAA12345.1' in url \ No newline at end of file diff --git a/tests/test_formula_parsing.py b/tests/test_formula_parsing.py new file mode 100644 index 0000000..dea8323 --- /dev/null +++ b/tests/test_formula_parsing.py @@ -0,0 +1,416 @@ +""" +Tests for boolean formula expression parsing. + +Covers: respro/db/_rules_formula.py +- _tokenize_formula_expression() +- _parse_formula_expression() +- _canonicalize_formula_ast() +- _formula_ast_to_string() +- _formula_sort_key() +""" + +from __future__ import annotations + +import pytest + +from respro.db._rules_formula import ( + _canonicalize_formula_ast, + _formula_ast_to_string, + _formula_sort_key, + _parse_formula_expression, + _tokenize_formula_expression, +) + + +class TestTokenizeFormulaExpression: + """Tests for _tokenize_formula_expression().""" + + def test_tokenizes_simple_atom(self): + """Should tokenize a single atomic rule ID.""" + tokens = _tokenize_formula_expression('mut_1') + assert tokens == ['mut_1'] + + def test_tokenizes_multiple_atoms(self): + """Should tokenize multiple atomic rule IDs.""" + tokens = _tokenize_formula_expression('mut_1 mut_2 mut_3') + assert tokens == ['mut_1', 'mut_2', 'mut_3'] + + def test_tokenizes_operators_uppercase(self): + """Should convert operators to uppercase.""" + tokens = _tokenize_formula_expression('mut1 and mut2 or mut3') + assert tokens == ['mut1', 'AND', 'mut2', 'OR', 'mut3'] + + def test_tokenizes_parentheses(self): + """Should tokenize parentheses.""" + tokens = _tokenize_formula_expression('(mut1 OR mut2) AND mut3') + assert tokens == ['(', 'mut1', 'OR', 'mut2', ')', 'AND', 'mut3'] + + def test_tokenizes_not_operator(self): + """Should tokenize NOT operator.""" + tokens = _tokenize_formula_expression('NOT mut1') + assert tokens == ['NOT', 'mut1'] + + def test_tokenizes_xor_operator(self): + """Should tokenize XOR operator.""" + tokens = _tokenize_formula_expression('mut1 XOR mut2') + assert tokens == ['mut1', 'XOR', 'mut2'] + + def test_handles_complex_expression(self): + """Should tokenize complex expressions.""" + tokens = _tokenize_formula_expression('(A AND B) OR (NOT C)') + assert tokens == ['(', 'A', 'AND', 'B', ')', 'OR', '(', 'NOT', 'C', ')'] + + def test_rejects_empty_expression(self): + """Should raise error for empty expression.""" + with pytest.raises(ValueError, match='empty expression'): + _tokenize_formula_expression('') + + def test_rejects_whitespace_only(self): + """Should raise error for whitespace-only expression.""" + with pytest.raises(ValueError, match='empty expression'): + _tokenize_formula_expression(' ') + + def test_rejects_unsupported_characters(self): + """Should raise error for unsupported characters.""" + with pytest.raises(ValueError, match='unsupported characters'): + _tokenize_formula_expression('mut1 + mut2') + + def test_rejects_special_characters(self): + """Should raise error for special characters.""" + with pytest.raises(ValueError, match='unsupported characters'): + _tokenize_formula_expression('mut1 & mut2') + + def test_allows_underscores_in_ids(self): + """Should allow underscores in rule IDs.""" + tokens = _tokenize_formula_expression('rule_123 AND rule_456') + assert tokens == ['rule_123', 'AND', 'rule_456'] + + def test_allows_dashes_in_ids(self): + """Should allow dashes in rule IDs.""" + tokens = _tokenize_formula_expression('rule-1 AND rule-2') + assert tokens == ['rule-1', 'AND', 'rule-2'] + + def test_allows_colons_in_ids(self): + """Should allow colons in rule IDs.""" + tokens = _tokenize_formula_expression('gene:A AND gene:B') + assert tokens == ['gene:A', 'AND', 'gene:B'] + + def test_allows_dots_in_ids(self): + """Should allow dots in rule IDs.""" + tokens = _tokenize_formula_expression('v1.0 AND v2.0') + assert tokens == ['v1.0', 'AND', 'v2.0'] + + def test_allows_numeric_ids(self): + """Should allow purely numeric rule IDs.""" + tokens = _tokenize_formula_expression('123 AND 456') + assert tokens == ['123', 'AND', '456'] + + def test_case_insensitive_operators(self): + """Should handle operators in any case.""" + tokens = _tokenize_formula_expression('mut1 And mut2 Or mut3') + assert tokens == ['mut1', 'AND', 'mut2', 'OR', 'mut3'] + + +class TestParseFormulaExpression: + """Tests for _parse_formula_expression().""" + + def test_parses_single_atom(self): + """Should parse a single atomic rule ID.""" + canonical_str, referenced_ids = _parse_formula_expression('mut_1') + assert canonical_str == 'mut_1' + assert referenced_ids == ['mut_1'] + + def test_parses_and_expression(self): + """Should parse AND expression.""" + canonical_str, referenced_ids = _parse_formula_expression('A AND B') + assert canonical_str == '(A AND B)' + assert referenced_ids == ['A', 'B'] + + def test_parses_or_expression(self): + """Should parse OR expression.""" + canonical_str, referenced_ids = _parse_formula_expression('A OR B') + assert canonical_str == '(A OR B)' + assert referenced_ids == ['A', 'B'] + + def test_parses_xor_expression(self): + """Should parse XOR expression.""" + canonical_str, referenced_ids = _parse_formula_expression('A XOR B') + assert canonical_str == '(A XOR B)' + assert referenced_ids == ['A', 'B'] + + def test_parses_not_expression(self): + """Should parse NOT expression.""" + canonical_str, referenced_ids = _parse_formula_expression('NOT A') + assert canonical_str == '(NOT A)' + assert referenced_ids == ['A'] + + def test_operator_precedence_not_over_and(self): + """NOT should have higher precedence than AND.""" + canonical_str, referenced_ids = _parse_formula_expression('NOT A AND B') + # Structure: AND with (NOT A) and B - canonical form sorts operands + assert 'NOT A' in canonical_str + assert 'B' in canonical_str + assert 'AND' in canonical_str + assert referenced_ids == ['A', 'B'] + + def test_operator_precedence_and_over_or(self): + """AND should have higher precedence than OR.""" + canonical_str, referenced_ids = _parse_formula_expression('A AND B OR C') + # Structure: OR of (A AND B) and C + assert 'A AND B' in canonical_str + assert 'OR' in canonical_str + assert referenced_ids == ['A', 'B', 'C'] + + def test_operator_precedence_xor_between_and_or(self): + """XOR should have higher precedence than OR, lower than AND.""" + canonical_str, referenced_ids = _parse_formula_expression('A AND B XOR C OR D') + assert 'A AND B' in canonical_str + assert 'XOR' in canonical_str + assert 'OR' in canonical_str + assert referenced_ids == ['A', 'B', 'C', 'D'] + + def test_left_associative_and(self): + """AND should be left-associative.""" + canonical_str, referenced_ids = _parse_formula_expression('A AND B AND C') + assert canonical_str == '(A AND B AND C)' + assert referenced_ids == ['A', 'B', 'C'] + + def test_left_associative_or(self): + """OR should be left-associative.""" + canonical_str, referenced_ids = _parse_formula_expression('A OR B OR C') + assert canonical_str == '(A OR B OR C)' + assert referenced_ids == ['A', 'B', 'C'] + + def test_left_associative_xor(self): + """XOR should be left-associative.""" + canonical_str, referenced_ids = _parse_formula_expression('A XOR B XOR C') + assert canonical_str == '(A XOR B XOR C)' + assert referenced_ids == ['A', 'B', 'C'] + + def test_right_associative_not(self): + """NOT should be right-associative.""" + canonical_str, referenced_ids = _parse_formula_expression('NOT NOT A') + assert canonical_str == '(NOT (NOT A))' + assert referenced_ids == ['A'] + + def test_parentheses_override_precedence(self): + """Parentheses should override default precedence.""" + canonical_str, referenced_ids = _parse_formula_expression('A AND (B OR C)') + assert canonical_str == '(A AND (B OR C))' + assert referenced_ids == ['A', 'B', 'C'] + + def test_nested_parentheses(self): + """Should handle nested parentheses.""" + canonical_str, referenced_ids = _parse_formula_expression('(A OR (B AND C)) XOR D') + assert 'B AND C' in canonical_str + assert 'OR' in canonical_str + assert 'XOR' in canonical_str + assert referenced_ids == ['A', 'B', 'C', 'D'] + + def test_complex_expression(self): + """Should parse complex nested expressions.""" + expr = '(A AND B) OR (NOT C AND D)' + canonical_str, referenced_ids = _parse_formula_expression(expr) + assert 'A AND B' in canonical_str + assert 'NOT C' in canonical_str + assert 'D' in canonical_str + assert 'OR' in canonical_str + assert referenced_ids == ['A', 'B', 'C', 'D'] + + def test_not_in_parentheses(self): + """Should handle NOT inside parentheses.""" + canonical_str, referenced_ids = _parse_formula_expression('(NOT A) AND B') + assert 'NOT A' in canonical_str + assert 'B' in canonical_str + assert 'AND' in canonical_str + assert referenced_ids == ['A', 'B'] + + def test_multiple_not_operators(self): + """Should handle multiple NOT operators.""" + canonical_str, referenced_ids = _parse_formula_expression('NOT A AND NOT B') + assert 'NOT A' in canonical_str + assert 'NOT B' in canonical_str + assert 'AND' in canonical_str + assert referenced_ids == ['A', 'B'] + + def test_rejects_unbalanced_opening_paren(self): + """Should raise error for unbalanced opening parenthesis.""" + with pytest.raises(ValueError, match='unexpected end of expression'): + _parse_formula_expression('(A AND B') + + def test_rejects_unbalanced_closing_paren(self): + """Should raise error for unbalanced closing parenthesis.""" + with pytest.raises(ValueError, match=r"unexpected token '\)'"): + _parse_formula_expression('A AND B)') + + def test_rejects_operator_after_paren(self): + """Should raise error for operator after opening paren.""" + with pytest.raises(ValueError, match="unexpected token 'AND'"): + _parse_formula_expression('(AND A)') + + def test_rejects_double_operator(self): + """Should raise error for consecutive operators.""" + with pytest.raises(ValueError): + _parse_formula_expression('A AND OR B') + + def test_tracks_duplicate_rule_ids(self): + """Should track duplicate rule IDs in referenced_ids.""" + canonical_str, referenced_ids = _parse_formula_expression('A AND A') + assert referenced_ids == ['A', 'A'] + + def test_canonical_form_flattens_and(self): + """Should flatten nested AND operators in canonical form.""" + canonical_str, _ = _parse_formula_expression('A AND (B AND C)') + assert canonical_str == '(A AND B AND C)' + + def test_canonical_form_sorts_operands(self): + """Should sort operands for deterministic canonical form.""" + canonical_str1, _ = _parse_formula_expression('B AND A') + canonical_str2, _ = _parse_formula_expression('A AND B') + assert canonical_str1 == canonical_str2 + + def test_canonical_form_distinguishes_not(self): + """Canonical form should distinguish NOT from positive atoms.""" + canonical_str1, _ = _parse_formula_expression('A AND B') + canonical_str2, _ = _parse_formula_expression('NOT A AND B') + assert canonical_str1 != canonical_str2 + + +class TestCanonicalizeFormulaAst: + """Tests for _canonicalize_formula_ast().""" + + def test_atom_unchanged(self): + """Should return atom nodes unchanged.""" + atom = ('ATOM', 'mut_1') + result = _canonicalize_formula_ast(atom) + assert result == atom + + def test_not_wrapped(self): + """Should wrap NOT nodes but canonicalize child.""" + not_node = ('NOT', ('ATOM', 'A')) + result = _canonicalize_formula_ast(not_node) + assert result == ('NOT', ('ATOM', 'A')) + + def test_flattens_nested_and(self): + """Should flatten nested AND nodes.""" + nested = ('AND', [('AND', [('ATOM', 'A'), ('ATOM', 'B')]), ('ATOM', 'C')]) + result = _canonicalize_formula_ast(nested) + assert result == ('AND', [('ATOM', 'A'), ('ATOM', 'B'), ('ATOM', 'C')]) + + def test_flattens_nested_or(self): + """Should flatten nested OR nodes.""" + nested = ('OR', [('OR', [('ATOM', 'A'), ('ATOM', 'B')]), ('ATOM', 'C')]) + result = _canonicalize_formula_ast(nested) + assert result == ('OR', [('ATOM', 'A'), ('ATOM', 'B'), ('ATOM', 'C')]) + + def test_sorts_and_operands(self): + """Should sort AND operands for deterministic ordering.""" + unsorted = ('AND', [('ATOM', 'B'), ('ATOM', 'A')]) + result = _canonicalize_formula_ast(unsorted) + assert result == ('AND', [('ATOM', 'A'), ('ATOM', 'B')]) + + def test_sorts_or_operands(self): + """Should sort OR operands for deterministic ordering.""" + unsorted = ('OR', [('ATOM', 'C'), ('ATOM', 'A'), ('ATOM', 'B')]) + result = _canonicalize_formula_ast(unsorted) + assert result == ('OR', [('ATOM', 'A'), ('ATOM', 'B'), ('ATOM', 'C')]) + + def test_sorts_xor_operands(self): + """Should sort XOR operands for deterministic ordering.""" + unsorted = ('XOR', [('ATOM', 'Z'), ('ATOM', 'A')]) + result = _canonicalize_formula_ast(unsorted) + assert result == ('XOR', [('ATOM', 'A'), ('ATOM', 'Z')]) + + def test_keeps_not_after_positive(self): + """Should sort NOT nodes after positive atoms.""" + mixed = ('AND', [('NOT', ('ATOM', 'B')), ('ATOM', 'A')]) + result = _canonicalize_formula_ast(mixed) + assert result == ('AND', [('ATOM', 'A'), ('NOT', ('ATOM', 'B'))]) + + def test_handles_complex_nested_structure(self): + """Should canonicalize complex nested structures.""" + ast = ( + 'OR', + [ + ('AND', [('NOT', ('ATOM', 'C')), ('ATOM', 'A')]), + ('ATOM', 'B'), + ], + ) + result = _canonicalize_formula_ast(ast) + assert result[0] == 'OR' + assert len(result[1]) == 2 + + +class TestFormulaAstToString: + """Tests for _formula_ast_to_string().""" + + def test_atom_to_string(self): + """Should convert atom to string.""" + result = _formula_ast_to_string(('ATOM', 'mut_1')) + assert result == 'mut_1' + + def test_not_to_string(self): + """Should convert NOT to string.""" + result = _formula_ast_to_string(('NOT', ('ATOM', 'A'))) + assert result == '(NOT A)' + + def test_and_to_string(self): + """Should convert AND to string.""" + result = _formula_ast_to_string(('AND', [('ATOM', 'A'), ('ATOM', 'B')])) + assert result == '(A AND B)' + + def test_or_to_string(self): + """Should convert OR to string.""" + result = _formula_ast_to_string(('OR', [('ATOM', 'A'), ('ATOM', 'B')])) + assert result == '(A OR B)' + + def test_xor_to_string(self): + """Should convert XOR to string.""" + result = _formula_ast_to_string(('XOR', [('ATOM', 'A'), ('ATOM', 'B')])) + assert result == '(A XOR B)' + + def test_nested_expression_to_string(self): + """Should convert nested expressions to string.""" + ast = ('OR', [('AND', [('ATOM', 'A'), ('ATOM', 'B')]), ('ATOM', 'C')]) + result = _formula_ast_to_string(ast) + assert result == '((A AND B) OR C)' + + def test_triple_and_to_string(self): + """Should convert triple AND to string.""" + ast = ('AND', [('ATOM', 'A'), ('ATOM', 'B'), ('ATOM', 'C')]) + result = _formula_ast_to_string(ast) + assert result == '(A AND B AND C)' + + def test_not_of_and_to_string(self): + """Should convert NOT of AND to string.""" + ast = ('NOT', ('AND', [('ATOM', 'A'), ('ATOM', 'B')])) + result = _formula_ast_to_string(ast) + assert result == '(NOT (A AND B))' + + +class TestFormulaSortKey: + """Tests for _formula_sort_key().""" + + def test_atom_comes_before_not(self): + """Atoms should sort before NOT nodes.""" + atom_key = _formula_sort_key(('ATOM', 'A')) + not_key = _formula_sort_key(('NOT', ('ATOM', 'B'))) + assert atom_key < not_key + + def test_compound_same_rank_as_atom(self): + """AND/OR/XOR should have same rank (1).""" + and_key = _formula_sort_key(('AND', [('ATOM', 'A')])) + or_key = _formula_sort_key(('OR', [('ATOM', 'A')])) + xor_key = _formula_sort_key(('XOR', [('ATOM', 'A')])) + assert and_key[0] == or_key[0] == xor_key[0] == 1 + + def test_not_has_highest_rank(self): + """NOT should have highest rank (2).""" + not_key = _formula_sort_key(('NOT', ('ATOM', 'A'))) + assert not_key[0] == 2 + + def test_sorts_by_string_representation(self): + """Should use string representation as secondary sort key.""" + a_key = _formula_sort_key(('ATOM', 'A')) + b_key = _formula_sort_key(('ATOM', 'B')) + assert a_key < b_key \ No newline at end of file diff --git a/tests/test_report_queries.py b/tests/test_report_queries.py new file mode 100644 index 0000000..3e96f9f --- /dev/null +++ b/tests/test_report_queries.py @@ -0,0 +1,177 @@ +""" +Tests for report query helpers. + +Covers: respro/db/report_queries.py +- load_numeric_metric_thresholds() +- load_drug_class_map() +- load_drug_alias_map() +- _parse_numeric_value() +""" + +from __future__ import annotations + +import json +import sqlite3 + +import pytest + +from respro.db.report_queries import ( + load_drug_alias_map, + load_drug_class_map, + load_numeric_metric_thresholds, +) + + +@pytest.fixture +def memory_db(): + """Create in-memory database.""" + conn = sqlite3.connect(':memory:') + conn.row_factory = sqlite3.Row + return conn + + +class TestLoadNumericMetricThresholds: + """Tests for load_numeric_metric_thresholds().""" + + def test_returns_empty_dict_for_none_connection(self): + """Should return empty dict for None connection.""" + result = load_numeric_metric_thresholds(None) + assert result == {} + + def test_returns_dict_with_none_when_no_data(self, memory_db): + """Should return dict with None values when no data exists.""" + self._create_rule_tables(memory_db) + result = load_numeric_metric_thresholds(memory_db) + assert result == {'ic50': None, 'fold_ic50': None, 'score': None} + + def test_returns_none_for_insufficient_values(self, memory_db): + """Should return None when fewer than 2 values.""" + self._create_rule_tables(memory_db) + memory_db.execute('INSERT INTO resistance_rule (score) VALUES ("5.0")') + memory_db.commit() + result = load_numeric_metric_thresholds(memory_db) + assert result.get('score') is None + + def test_computes_mean_and_std(self, memory_db): + """Should compute mean and std for numeric fields.""" + self._create_rule_tables(memory_db) + memory_db.execute('INSERT INTO resistance_rule (score) VALUES ("10.0")') + memory_db.execute('INSERT INTO resistance_rule (score) VALUES ("20.0")') + memory_db.commit() + result = load_numeric_metric_thresholds(memory_db) + assert 'score' in result + assert result['score'] is not None + mean, std = result['score'] + assert mean == 15.0 + + def test_handles_null_values(self, memory_db): + """Should handle NULL values gracefully.""" + self._create_rule_tables(memory_db) + memory_db.execute('INSERT INTO resistance_rule (score) VALUES (NULL)') + memory_db.execute('INSERT INTO resistance_rule (score) VALUES ("10.0")') + memory_db.commit() + result = load_numeric_metric_thresholds(memory_db) + assert result.get('score') is None + + def test_parses_numeric_values_with_prefixes(self, memory_db): + """Should parse values with >, <, ~ prefixes.""" + self._create_rule_tables(memory_db) + memory_db.execute('INSERT INTO resistance_rule (score) VALUES (">10.0")') + memory_db.execute('INSERT INTO resistance_rule (score) VALUES ("~20.0")') + memory_db.commit() + result = load_numeric_metric_thresholds(memory_db) + assert result.get('score') is not None + + @staticmethod + def _create_rule_tables(conn): + """Create resistance_rule and resistance_formula_rule tables.""" + conn.execute(''' + CREATE TABLE resistance_rule ( + id INTEGER PRIMARY KEY, feature_id INTEGER, drug_id INTEGER, + external_id TEXT, position INTEGER, reference TEXT, mutation TEXT, + phenotype TEXT, clinical_phenotype TEXT, ic50 TEXT, fold_ic50 TEXT, + score TEXT, publication TEXT, source TEXT, comment TEXT + ) + ''') + conn.execute(''' + CREATE TABLE resistance_formula_rule ( + id INTEGER PRIMARY KEY, drug_id INTEGER, formula_id TEXT, + normalized_expression TEXT, phenotype TEXT, clinical_phenotype TEXT, + ic50 TEXT, fold_ic50 TEXT, score TEXT, source TEXT, comment TEXT + ) + ''') + conn.commit() + + +class TestLoadDrugClassMap: + """Tests for load_drug_class_map().""" + + def test_returns_empty_dict_for_none_connection(self): + """Should return empty dict for None connection.""" + result = load_drug_class_map(None) + assert result == {} + + def test_returns_empty_dict_when_no_algorithm(self, memory_db): + """Should return empty dict when no drug_groups algorithm.""" + memory_db.execute('CREATE TABLE interpretation_algorithm (id INTEGER PRIMARY KEY, algorithm_name TEXT, config_json TEXT)') + memory_db.commit() + result = load_drug_class_map(memory_db) + assert result == {} + + def test_builds_drug_to_class_mapping(self, memory_db): + """Should build drug to class mapping.""" + memory_db.execute('CREATE TABLE interpretation_algorithm (id INTEGER PRIMARY KEY, algorithm_name TEXT, config_json TEXT)') + config = { + 'groups': { + 'NRTI': ['tenofovir', 'emtricitabine'], + 'NNRTI': ['efavirenz', 'rilpivirine'], + } + } + memory_db.execute( + 'INSERT INTO interpretation_algorithm (algorithm_name, config_json) VALUES (?, ?)', + ('drug_groups', json.dumps(config)) + ) + memory_db.commit() + result = load_drug_class_map(memory_db) + assert result['tenofovir'] == 'NRTI' + assert result['emtricitabine'] == 'NRTI' + assert result['efavirenz'] == 'NNRTI' + + def test_lowercases_drug_names(self, memory_db): + """Should lowercase drug names.""" + memory_db.execute('CREATE TABLE interpretation_algorithm (id INTEGER PRIMARY KEY, algorithm_name TEXT, config_json TEXT)') + config = {'groups': {'Class': ['DrugA', 'DrugB']}} + memory_db.execute( + 'INSERT INTO interpretation_algorithm (algorithm_name, config_json) VALUES (?, ?)', + ('drug_groups', json.dumps(config)) + ) + memory_db.commit() + result = load_drug_class_map(memory_db) + assert 'druga' in result + assert 'drugb' in result + + +class TestLoadDrugAliasMap: + """Tests for load_drug_alias_map().""" + + def test_returns_empty_dict_for_none_connection(self): + """Should return empty dict for None connection.""" + result = load_drug_alias_map(None) + assert result == {} + + def test_returns_empty_dict_when_no_drugs(self, memory_db): + """Should return empty dict when no drugs in database.""" + memory_db.execute('CREATE TABLE drug (id INTEGER PRIMARY KEY, name TEXT, alias TEXT)') + memory_db.commit() + result = load_drug_alias_map(memory_db) + assert result == {} + + def test_builds_alias_mapping(self, memory_db): + """Should build drug alias mapping.""" + memory_db.execute('CREATE TABLE drug (id INTEGER PRIMARY KEY, name TEXT, alias TEXT)') + memory_db.execute('INSERT INTO drug (name, alias) VALUES ("Tenofovir", "TDF")') + memory_db.execute('INSERT INTO drug (name, alias) VALUES ("Emtricitabine", "FTC")') + memory_db.commit() + result = load_drug_alias_map(memory_db) + assert 'tenofovir' in result + assert result['tenofovir'] == 'TDF' \ No newline at end of file diff --git a/tests/test_rules_alleles.py b/tests/test_rules_alleles.py new file mode 100644 index 0000000..fc761ef --- /dev/null +++ b/tests/test_rules_alleles.py @@ -0,0 +1,308 @@ +""" +Tests for mutation and allele normalization in rule imports. + +Covers: respro/db/_rules_alleles.py +- _is_noop_mutation() +- _is_supported_mutation_token() +- _normalize_rule_alleles_for_storage() +- Anchorless deletion detection +- FSX/frameshift handling +""" + +from __future__ import annotations + +import pytest + +from respro.db._rules_alleles import ( + _RE_ANCHORLESS_DEL, + _RE_REWRITE_TOKEN, + _is_noop_mutation, + _is_supported_mutation_token, + _normalize_rule_alleles_for_storage, +) + + +class TestIsNoopMutation: + """Tests for _is_noop_mutation().""" + + def test_same_aa_returns_true(self): + """Should return True when reference and mutation are the same.""" + assert _is_noop_mutation('A', 'A') is True + assert _is_noop_mutation('G', 'G') is True + + def test_case_insensitive(self): + """Should be case-insensitive.""" + assert _is_noop_mutation('A', 'a') is True + assert _is_noop_mutation('g', 'G') is True + assert _is_noop_mutation('Leu', 'LEU') is True + + def test_different_aa_returns_false(self): + """Should return False when reference and mutation differ.""" + assert _is_noop_mutation('A', 'G') is False + assert _is_noop_mutation('K', 'R') is False + + def test_empty_strings(self): + """Should handle empty strings.""" + assert _is_noop_mutation('', '') is True + assert _is_noop_mutation('A', '') is False + + +class TestIsSupportedMutationToken: + """Tests for _is_supported_mutation_token().""" + + def test_standard_aa_mutations_supported(self): + """Should support standard amino acid mutations.""" + assert _is_supported_mutation_token('A') is True + assert _is_supported_mutation_token('K') is True + assert _is_supported_mutation_token('L') is True + assert _is_supported_mutation_token('ACD') is True + + def test_stop_codon_supported(self): + """Should support stop codon symbol.""" + assert _is_supported_mutation_token('*') is True + + def test_fsx_mutations_supported(self): + """Should support FSX (frameshift) mutations.""" + assert _is_supported_mutation_token('FSX') is True + assert _is_supported_mutation_token('KFSX') is True + assert _is_supported_mutation_token('RFSX') is True + + def test_ins_any_supported(self): + """Should support INS_any insertion.""" + assert _is_supported_mutation_token('INS_any') is True + + def test_any_not_supported(self): + """Should NOT support ANY token.""" + assert _is_supported_mutation_token('ANY') is False + + def test_invalid_aa_not_supported(self): + """Should reject invalid amino acid letters.""" + assert _is_supported_mutation_token('Z') is False + assert _is_supported_mutation_token('X') is False + assert _is_supported_mutation_token('BJZ') is False + + def test_numbers_not_supported(self): + """Should reject numeric tokens.""" + assert _is_supported_mutation_token('123') is False + + def test_mixed_case_handled(self): + """Should handle mixed case input (converted to uppercase internally).""" + assert _is_supported_mutation_token('abc') is False # B is not valid AA + assert _is_supported_mutation_token('AKC') is True # Valid AAs + assert _is_supported_mutation_token('KFSX') is True + + +class TestAnchorlessDeletionRegex: + """Tests for _RE_ANCHORLESS_DEL pattern.""" + + def test_matches_single_aa_del(self): + """Should match single AA deletion.""" + assert _RE_ANCHORLESS_DEL.match('Q35del') is not None + assert _RE_ANCHORLESS_DEL.match('A123del') is not None + + def test_matches_multiple_aa_del(self): + """Should match multiple AA deletion.""" + assert _RE_ANCHORLESS_DEL.match('DD676del') is not None + assert _RE_ANCHORLESS_DEL.match('KK34del') is not None + + def test_case_insensitive(self): + """Should match case-insensitively.""" + assert _RE_ANCHORLESS_DEL.match('q35DEL') is not None + assert _RE_ANCHORLESS_DEL.match('Q35DEL') is not None + assert _RE_ANCHORLESS_DEL.match('q35del') is not None + + def test_rejects_no_del_suffix(self): + """Should reject tokens without 'del' suffix.""" + assert _RE_ANCHORLESS_DEL.match('Q35') is None + assert _RE_ANCHORLESS_DEL.match('Q35deletion') is None + + def test_rejects_no_position(self): + """Should reject tokens without position.""" + assert _RE_ANCHORLESS_DEL.match('Qdel') is None + assert _RE_ANCHORLESS_DEL.match('del') is None + + def test_rejects_empty(self): + """Should reject empty string.""" + assert _RE_ANCHORLESS_DEL.match('') is None + + +class TestRewriteTokenRegex: + """Tests for _RE_REWRITE_TOKEN pattern.""" + + def test_matches_standard_mutation(self): + """Should match standard mutation format.""" + match = _RE_REWRITE_TOKEN.match('A123G') + assert match is not None + assert match.groups() == ('A', '123', 'G') + + def test_matches_stop_codon(self): + """Should match stop codon mutation.""" + match = _RE_REWRITE_TOKEN.match('K100*') + assert match is not None + assert match.groups() == ('K', '100', '*') + + def test_matches_from_stop(self): + """Should match mutation from stop codon.""" + match = _RE_REWRITE_TOKEN.match('*50K') + assert match is not None + assert match.groups() == ('*', '50', 'K') + + def test_matches_multi_aa(self): + """Should match multi-AA mutations.""" + match = _RE_REWRITE_TOKEN.match('DD100KK') + assert match is not None + assert match.groups() == ('DD', '100', 'KK') + + def test_rejects_no_position(self): + """Should reject mutations without position.""" + assert _RE_REWRITE_TOKEN.match('AG') is None + + def test_rejects_invalid_format(self): + """Should reject invalid formats.""" + assert _RE_REWRITE_TOKEN.match('123AG') is None + assert _RE_REWRITE_TOKEN.match('A123') is None + + +class TestNormalizeRuleAllelesForStorage: + """Tests for _normalize_rule_alleles_for_storage().""" + + def test_returns_tuple_for_valid_direct_aa(self): + """Should return normalized tuple for direct AA token.""" + errors: list[str] = [] + result = _normalize_rule_alleles_for_storage( + reference_aa='A', + mutation_raw='G', + position_0based=10, + context='test row', + errors=errors, + ) + assert result is not None + pos, ref, mut = result + assert pos == 10 + assert ref == 'A' + assert mut == 'G' + assert errors == [] + + def test_normalizes_stop_codon(self): + """Should normalize stop codon to *.""" + errors: list[str] = [] + result = _normalize_rule_alleles_for_storage( + reference_aa='K', + mutation_raw='STOP', + position_0based=50, + context='test', + errors=errors, + ) + assert result is not None + _, _, mut = result + assert mut == '*' + + def test_normalizes_frameshift_to_fsx(self): + """Should normalize frameshift to fsX format (case preserved from reference).""" + errors: list[str] = [] + result = _normalize_rule_alleles_for_storage( + reference_aa='R', + mutation_raw='FS', + position_0based=100, + context='test', + errors=errors, + ) + assert result is not None + _, ref, mut = result + assert mut == 'RfsX' # Reference case preserved, 'fsX' suffix + + def test_handles_fsx_suffix(self): + """Should handle existing FSX suffix.""" + errors: list[str] = [] + result = _normalize_rule_alleles_for_storage( + reference_aa='K', + mutation_raw='KFSX', + position_0based=25, + context='test', + errors=errors, + ) + assert result is not None + _, _, mut = result + assert mut == 'KfsX' # Case preserved from input + + def test_returns_none_for_empty_reference(self): + """Should return None for empty reference.""" + errors: list[str] = [] + result = _normalize_rule_alleles_for_storage( + reference_aa='', + mutation_raw='G', + position_0based=10, + context='test', + errors=errors, + ) + assert result is None + + def test_returns_none_for_empty_mutation(self): + """Should return None for empty mutation.""" + errors: list[str] = [] + result = _normalize_rule_alleles_for_storage( + reference_aa='A', + mutation_raw='', + position_0based=10, + context='test', + errors=errors, + ) + assert result is None + + def test_appends_error_for_unrecognized_mutation(self): + """Should append error for unrecognized mutation format.""" + errors: list[str] = [] + result = _normalize_rule_alleles_for_storage( + reference_aa='A', + mutation_raw='invalid_mutation_123', + position_0based=10, + context='row 5', + errors=errors, + ) + assert result is None + assert len(errors) == 1 + assert 'row 5: unrecognised mutation' in errors[0] + + def test_strips_whitespace(self): + """Should strip whitespace from inputs.""" + errors: list[str] = [] + result = _normalize_rule_alleles_for_storage( + reference_aa=' A ', + mutation_raw=' G ', + position_0based=10, + context='test', + errors=errors, + ) + assert result is not None + _, ref, mut = result + assert ref == 'A' + assert mut == 'G' + + def test_case_insensitive_reference(self): + """Should uppercase reference.""" + errors: list[str] = [] + result = _normalize_rule_alleles_for_storage( + reference_aa='a', + mutation_raw='g', + position_0based=10, + context='test', + errors=errors, + ) + assert result is not None + _, ref, mut = result + assert ref == 'A' + assert mut == 'G' + + def test_preserves_position(self): + """Should preserve 0-based position.""" + errors: list[str] = [] + for pos in [0, 10, 100, 1000]: + result = _normalize_rule_alleles_for_storage( + reference_aa='A', + mutation_raw='G', + position_0based=pos, + context='test', + errors=errors, + ) + assert result is not None + assert result[0] == pos \ No newline at end of file diff --git a/tests/test_rules_normalize.py b/tests/test_rules_normalize.py new file mode 100644 index 0000000..1722ca2 --- /dev/null +++ b/tests/test_rules_normalize.py @@ -0,0 +1,467 @@ +""" +Tests for TSV row normalization helpers in rule imports. + +Covers: respro/db/_rules_normalize.py +- _get_value() +- _parse_ic50_value() +- _parse_single_ic50() +- _normalize_ic50_from_row() +- _normalize_fold_ic50_from_row() +- _normalize_score_from_row() +- _normalize_phenotype_token() +- _append_contradictory_comment() +""" + +from __future__ import annotations + +import pytest + +from respro.db._rules_normalize import ( + _CONTRADICTORY_COMMENT, + _append_contradictory_comment, + _get_value, + _normalize_fold_ic50_from_row, + _normalize_ic50_from_row, + _normalize_phenotype_token, + _normalize_score_from_row, + _parse_ic50_value, + _parse_single_ic50, +) + + +class TestGetValue: + """Tests for _get_value() helper.""" + + def test_returns_first_non_empty_value(self): + """Should return the first non-empty value from the provided keys.""" + row = {'a': '', 'b': 'value_b', 'c': 'value_c'} + result = _get_value(row, 'a', 'b', 'c') + assert result == 'value_b' + + def test_returns_stripped_value(self): + """Should strip whitespace from the returned value.""" + row = {'key': ' value with spaces '} + result = _get_value(row, 'key') + assert result == 'value with spaces' + + def test_returns_empty_string_when_all_keys_missing(self): + """Should return empty string when no keys have values.""" + row = {'a': '', 'b': '', 'c': ''} + result = _get_value(row, 'a', 'b', 'c') + assert result == '' + + def test_returns_empty_string_when_row_empty(self): + """Should return empty string for empty row.""" + row: dict[str, str] = {} + result = _get_value(row, 'a', 'b', 'c') + assert result == '' + + def test_handles_none_values(self): + """Should skip None values and continue to next key.""" + row = {'a': None, 'b': 'found', 'c': 'ignored'} + result = _get_value(row, 'a', 'b', 'c') + assert result == 'found' + + def test_single_key_exists(self): + """Should work with single key that exists.""" + row = {'key': 'value'} + result = _get_value(row, 'key') + assert result == 'value' + + def test_single_key_missing(self): + """Should return empty string for single missing key.""" + row = {'other': 'value'} + result = _get_value(row, 'key') + assert result == '' + + +class TestParseIc50Value: + """Tests for _parse_ic50_value() numeric parsing.""" + + def test_parses_integer(self): + """Should parse integer values.""" + assert _parse_ic50_value('10') == 10.0 + assert _parse_ic50_value('0') == 0.0 + + def test_parses_float(self): + """Should parse decimal values.""" + assert _parse_ic50_value('10.5') == 10.5 + assert _parse_ic50_value('0.001') == 0.001 + + def test_parses_scientific_notation(self): + """Should parse scientific notation.""" + assert _parse_ic50_value('1.5e3') == 1500.0 + assert _parse_ic50_value('2.0E-3') == 0.002 + assert _parse_ic50_value('1e10') == 1e10 + + def test_parses_negative_values(self): + """Should parse negative values (though biologically unusual).""" + assert _parse_ic50_value('-5.0') == -5.0 + assert _parse_ic50_value('-1e2') == -100.0 + + def test_parses_with_whitespace(self): + """Should strip whitespace before parsing.""" + assert _parse_ic50_value(' 10.5 ') == 10.5 + assert _parse_ic50_value('\t5.0\n') == 5.0 + + def test_returns_none_for_empty(self): + """Should return None for empty string.""" + assert _parse_ic50_value('') is None + + def test_returns_none_for_whitespace_only(self): + """Should return None for whitespace-only string.""" + assert _parse_ic50_value(' ') is None + + def test_returns_none_for_none_literal(self): + """Should return None for 'none' string.""" + assert _parse_ic50_value('none') is None + assert _parse_ic50_value('None') is None + assert _parse_ic50_value('NONE') is None + + def test_returns_none_for_invalid_string(self): + """Should return None for non-numeric strings.""" + assert _parse_ic50_value('resistant') is None + assert _parse_ic50_value('N/A') is None + assert _parse_ic50_value('unknown') is None + + def test_extracts_number_from_text(self): + """Should extract numeric portion from mixed text.""" + assert _parse_ic50_value('>10.5') == 10.5 + assert _parse_ic50_value('~5.0') == 5.0 + assert _parse_ic50_value('approximately 10') == 10.0 + + def test_parses_leading_decimal_point(self): + """Should parse numbers starting with decimal point.""" + assert _parse_ic50_value('.5') == 0.5 + assert _parse_ic50_value('.123') == 0.123 + + +class TestParseSingleIc50: + """Tests for _parse_single_ic50() with error handling.""" + + def test_returns_canonical_integer(self): + """Should return canonical format for integers.""" + errors: list[str] = [] + result = _parse_single_ic50('10', errors=errors, context='test') + assert result == '10' + assert errors == [] + + def test_returns_canonical_float(self): + """Should return canonical format for floats (removes trailing zeros).""" + errors: list[str] = [] + result = _parse_single_ic50('10.500', errors=errors, context='test') + assert result == '10.5' + assert errors == [] + + def test_returns_canonical_integer_format(self): + """Should return integer format without decimal point for whole numbers.""" + errors: list[str] = [] + result = _parse_single_ic50('20.0', errors=errors, context='test') + assert result == '20' # :g format removes trailing .0 + assert errors == [] + + def test_returns_empty_for_none(self): + """Should return empty string for 'none' value.""" + errors: list[str] = [] + result = _parse_single_ic50('none', errors=errors, context='test') + assert result == '' + assert errors == [] + + def test_returns_empty_for_empty(self): + """Should return empty string for empty input.""" + errors: list[str] = [] + result = _parse_single_ic50('', errors=errors, context='test') + assert result == '' + assert errors == [] + + def test_appends_error_for_invalid_value(self): + """Should append error message for invalid values.""" + errors: list[str] = [] + result = _parse_single_ic50('invalid', errors=errors, context='test context') + assert result == '' + assert len(errors) == 1 + assert "test context: invalid ic50 value 'invalid'" in errors[0] + + def test_strips_whitespace(self): + """Should strip whitespace before parsing.""" + errors: list[str] = [] + result = _parse_single_ic50(' 10.5 ', errors=errors, context='test') + assert result == '10.5' + assert errors == [] + + +class TestNormalizeIc50FromRow: + """Tests for _normalize_ic50_from_row() column resolution.""" + + def test_reads_ic50_column(self): + """Should read from 'ic50' column.""" + row = {'ic50': '10.5', 'ic_50': '20.0'} + errors: list[str] = [] + result = _normalize_ic50_from_row(row, errors=errors, context='test') + assert result == '10.5' + + def test_reads_ic_50_column_as_fallback(self): + """Should read from 'ic_50' column when 'ic50' missing.""" + row = {'ic_50': '20.0'} + errors: list[str] = [] + result = _normalize_ic50_from_row(row, errors=errors, context='test') + assert result == '20' # :g format removes trailing .0 + + def test_prefers_ic50_over_ic_50(self): + """Should prefer 'ic50' over 'ic_50' when both present.""" + row = {'ic50': '10.5', 'ic_50': '20.0'} + errors: list[str] = [] + result = _normalize_ic50_from_row(row, errors=errors, context='test') + assert result == '10.5' + + def test_returns_empty_for_missing_columns(self): + """Should return empty string when columns missing.""" + row = {'other': 'value'} + errors: list[str] = [] + result = _normalize_ic50_from_row(row, errors=errors, context='test') + assert result == '' + assert errors == [] + + def test_appends_error_for_invalid_value(self): + """Should append error for invalid IC50 value.""" + row = {'ic50': 'not_a_number'} + errors: list[str] = [] + result = _normalize_ic50_from_row(row, errors=errors, context='row 5') + assert result == '' + assert len(errors) == 1 + assert 'row 5: invalid ic50 value' in errors[0] + + +class TestNormalizeFoldIc50FromRow: + """Tests for _normalize_fold_ic50_from_row() column resolution.""" + + def test_reads_fold_ic50_column(self): + """Should read from 'fold_ic50' column.""" + row = {'fold_ic50': '5.5', 'fold_ic_50': '10.0'} + errors: list[str] = [] + result = _normalize_fold_ic50_from_row(row, errors=errors, context='test') + assert result == '5.5' + + def test_reads_fold_ic_50_column_as_fallback(self): + """Should read from 'fold_ic_50' column when 'fold_ic50' missing.""" + row = {'fold_ic_50': '10.0'} + errors: list[str] = [] + result = _normalize_fold_ic50_from_row(row, errors=errors, context='test') + assert result == '10' # :g format removes trailing .0 + + def test_prefers_fold_ic50_over_fold_ic_50(self): + """Should prefer 'fold_ic50' over 'fold_ic_50'.""" + row = {'fold_ic50': '5.5', 'fold_ic_50': '10.0'} + errors: list[str] = [] + result = _normalize_fold_ic50_from_row(row, errors=errors, context='test') + assert result == '5.5' + + def test_returns_empty_for_missing_columns(self): + """Should return empty string when columns missing.""" + row = {'other': 'value'} + errors: list[str] = [] + result = _normalize_fold_ic50_from_row(row, errors=errors, context='test') + assert result == '' + + +class TestNormalizeScoreFromRow: + """Tests for _normalize_score_from_row().""" + + def test_parses_integer_score(self): + """Should parse integer scores.""" + row = {'score': '5'} + errors: list[str] = [] + result = _normalize_score_from_row(row, errors=errors, context='test') + assert result == '5' + + def test_parses_float_score(self): + """Should parse float scores.""" + row = {'score': '5.75'} + errors: list[str] = [] + result = _normalize_score_from_row(row, errors=errors, context='test') + assert result == '5.75' + + def test_returns_empty_for_none(self): + """Should return empty string for 'none' value.""" + row = {'score': 'none'} + errors: list[str] = [] + result = _normalize_score_from_row(row, errors=errors, context='test') + assert result == '' + + def test_returns_empty_for_missing_column(self): + """Should return empty string when column missing.""" + row = {'other': 'value'} + errors: list[str] = [] + result = _normalize_score_from_row(row, errors=errors, context='test') + assert result == '' + + def test_appends_error_for_invalid_score(self): + """Should append error for invalid score value.""" + row = {'score': 'not_a_number'} + errors: list[str] = [] + result = _normalize_score_from_row(row, errors=errors, context='row 10') + assert result == '' + assert len(errors) == 1 + assert 'row 10: invalid score value' in errors[0] + + def test_uses_score_column_only(self): + """Should only read from 'score' column.""" + row = {'score': '5.0', 'other_score': '10.0'} + errors: list[str] = [] + result = _normalize_score_from_row(row, errors=errors, context='test') + assert result == '5' # :g format removes trailing .0 + + +class TestNormalizePhenotypeToken: + """Tests for _normalize_phenotype_token().""" + + def test_maps_resistant_variants(self): + """Should map resistant variants to 'resistant'.""" + assert _normalize_phenotype_token('resistant') == 'resistant' + assert _normalize_phenotype_token('resistance') == 'resistant' + assert _normalize_phenotype_token('res') == 'resistant' + + def test_maps_sensitive_variants(self): + """Should map sensitive variants to 'sensitive'.""" + assert _normalize_phenotype_token('sensitive') == 'sensitive' + assert _normalize_phenotype_token('susceptible') == 'sensitive' + assert _normalize_phenotype_token('sens') == 'sensitive' + + def test_maps_intermediate_variants(self): + """Should map intermediate variants.""" + assert _normalize_phenotype_token('intermediate') == 'intermediate' + assert _normalize_phenotype_token('interm') == 'intermediate' + assert _normalize_phenotype_token('i') == 'intermediate' + + def test_maps_contradictory(self): + """Should map contradictory variants.""" + assert _normalize_phenotype_token('contradictory') == 'contradictory' + assert _normalize_phenotype_token('contra') == 'contradictory' + assert _normalize_phenotype_token('conflict') == 'contradictory' + assert _normalize_phenotype_token('conflicting') == 'contradictory' + + def test_maps_unknown_to_unknown(self): + """Should map unknown/empty to 'unknown'.""" + assert _normalize_phenotype_token('unknown') == 'unknown' + assert _normalize_phenotype_token('none') == 'unknown' + assert _normalize_phenotype_token('') == 'unknown' + assert _normalize_phenotype_token(' ') == 'unknown' + + def test_case_insensitive(self): + """Should be case-insensitive.""" + assert _normalize_phenotype_token('RESISTANT') == 'resistant' + assert _normalize_phenotype_token('Resistant') == 'resistant' + assert _normalize_phenotype_token('ReSiStAnT') == 'resistant' + + def test_strips_whitespace(self): + """Should strip whitespace.""" + assert _normalize_phenotype_token(' resistant ') == 'resistant' + assert _normalize_phenotype_token('\tsensitive\n') == 'sensitive' + + def test_returns_none_for_unmapped(self): + """Should return None for unmapped phenotypes.""" + assert _normalize_phenotype_token('invalid') is None + assert _normalize_phenotype_token('mixed') is None + + +class TestAppendContradictoryComment: + """Tests for _append_contradictory_comment().""" + + def test_returns_original_when_not_contradictory(self): + """Should return original comment when phenotype is not contradictory.""" + result = _append_contradictory_comment( + 'Some comment', + phenotype='resistant', + clinical_phenotype='resistant', + ) + assert result == 'Some comment' + + def test_returns_standard_comment_when_empty(self): + """Should return standard comment when input is empty.""" + result = _append_contradictory_comment( + '', + phenotype='contradictory', + clinical_phenotype='contradictory', + ) + assert result == _CONTRADICTORY_COMMENT + + def test_appends_when_comment_present(self): + """Should append standard comment to existing comment.""" + result = _append_contradictory_comment( + 'Multiple studies show different results', + phenotype='contradictory', + clinical_phenotype='contradictory', + ) + assert result == 'Multiple studies show different results. Publications have contradictory phenotype associations.' + + def test_adds_period_before_appending(self): + """Should add period if comment doesn't end with punctuation.""" + result = _append_contradictory_comment( + 'Results vary', + phenotype='contradictory', + clinical_phenotype='contradictory', + ) + assert result == 'Results vary. Publications have contradictory phenotype associations.' + + def test_preserves_existing_punctuation(self): + """Should preserve existing punctuation.""" + result = _append_contradictory_comment( + 'Results vary.', + phenotype='contradictory', + clinical_phenotype='contradictory', + ) + assert result == 'Results vary. Publications have contradictory phenotype associations.' + + def test_handles_exclamation_mark(self): + """Should handle exclamation mark as existing punctuation.""" + result = _append_contradictory_comment( + 'Results vary!', + phenotype='contradictory', + clinical_phenotype='contradictory', + ) + assert result == 'Results vary! Publications have contradictory phenotype associations.' + + def test_handles_question_mark(self): + """Should handle question mark as existing punctuation.""" + result = _append_contradictory_comment( + 'Results vary?', + phenotype='contradictory', + clinical_phenotype='contradictory', + ) + assert result == 'Results vary? Publications have contradictory phenotype associations.' + + def test_skips_if_already_contains_standard_comment(self): + """Should not duplicate if comment already contains standard text.""" + result = _append_contradictory_comment( + _CONTRADICTORY_COMMENT, + phenotype='contradictory', + clinical_phenotype='contradictory', + ) + assert result == _CONTRADICTORY_COMMENT + + def test_case_insensitive_check_for_existing_comment(self): + """Should check for existing comment case-insensitively.""" + result = _append_contradictory_comment( + 'publications have contradictory phenotype associations.', + phenotype='contradictory', + clinical_phenotype='contradictory', + ) + assert result == 'publications have contradictory phenotype associations.' + + def test_works_with_clinical_phenotype_only(self): + """Should work when only clinical_phenotype is contradictory.""" + result = _append_contradictory_comment( + 'Comment', + phenotype='resistant', + clinical_phenotype='contradictory', + ) + assert 'Publications have contradictory' in result + + def test_works_with_phenotype_only(self): + """Should work when only phenotype is contradictory.""" + result = _append_contradictory_comment( + 'Comment', + phenotype='contradictory', + clinical_phenotype='resistant', + ) + assert 'Publications have contradictory' in result \ No newline at end of file diff --git a/tests/test_utils_cli.py b/tests/test_utils_cli.py new file mode 100644 index 0000000..021495d --- /dev/null +++ b/tests/test_utils_cli.py @@ -0,0 +1,150 @@ +""" +Tests for CLI utilities - error handling and logging. + +Covers: respro/utils/cli_errors.py, respro/utils/logging.py +- cli_error() +- render_click_exception() +- setup_logging() +- _LogHighlighter +""" + +from __future__ import annotations + +import logging + +import click +import pytest +import typer + +from respro.utils.cli_errors import cli_error, render_click_exception +from respro.utils.logging import _LogHighlighter, setup_logging + + +class TestCliError: + """Tests for cli_error().""" + + def test_raises_typer_exit(self): + """Should raise typer.Exit.""" + with pytest.raises(typer.Exit) as exc_info: + cli_error('test error') + assert exc_info.value.exit_code == 1 + + def test_default_exit_code_is_1(self): + """Should exit with code 1 by default.""" + with pytest.raises(typer.Exit) as exc_info: + cli_error('error') + assert exc_info.value.exit_code == 1 + + def test_custom_exit_code(self): + """Should support custom exit codes.""" + with pytest.raises(typer.Exit) as exc_info: + cli_error('error', exit_code=2) + assert exc_info.value.exit_code == 2 + + def test_message_includes_error_prefix(self, capsys): + """Should prepend 'Error: ' to message.""" + try: + cli_error('something went wrong') + except typer.Exit: + pass + captured = capsys.readouterr() + assert 'Error: something went wrong' in captured.err + + +class TestRenderClickException: + """Tests for render_click_exception().""" + + def test_raises_typer_exit(self): + """Should raise typer.Exit.""" + exc = click.ClickException('test') + with pytest.raises(typer.Exit): + render_click_exception(exc) + + def test_preserves_exit_code(self): + """Should preserve exception's exit code.""" + exc = click.ClickException('test') + exc.exit_code = 3 + with pytest.raises(typer.Exit) as exc_info: + render_click_exception(exc) + assert exc_info.value.exit_code == 3 + + def test_works_with_usage_error(self, capsys): + """Should work with UsageError.""" + exc = click.UsageError('usage problem') + try: + render_click_exception(exc) + except typer.Exit: + pass + captured = capsys.readouterr() + assert 'usage problem' in captured.err.lower() + + +class TestSetupLogging: + """Tests for setup_logging().""" + + def test_returns_logger(self): + """Should return Logger instance.""" + logger = setup_logging() + assert isinstance(logger, logging.Logger) + assert logger.name == 'respro' + + def test_default_level_is_warning(self): + """Should default to WARNING level.""" + logger = setup_logging() + assert logger.level == logging.WARNING + + def test_verbosity_0_is_warning(self): + """Should set WARNING for verbosity 0.""" + logger = setup_logging(verbosity=0) + assert logger.level == logging.WARNING + + def test_verbosity_1_is_info(self): + """Should set INFO for verbosity 1.""" + logger = setup_logging(verbosity=1) + assert logger.level == logging.INFO + + def test_verbosity_2_is_debug(self): + """Should set DEBUG for verbosity 2+.""" + logger = setup_logging(verbosity=2) + assert logger.level == logging.DEBUG + logger2 = setup_logging(verbosity=10) + assert logger2.level == logging.DEBUG + + def test_handler_attached(self): + """Should attach a handler.""" + logger = setup_logging() + assert len(logger.handlers) > 0 + + def test_rich_handler_used(self): + """Should use RichHandler.""" + from rich.logging import RichHandler + logger = setup_logging() + assert any(isinstance(h, RichHandler) for h in logger.handlers) + + def test_idempotent(self): + """Should be idempotent (not add multiple handlers).""" + logger1 = setup_logging() + handler_count = len(logger1.handlers) + logger2 = setup_logging() + assert len(logger2.handlers) == handler_count + + +class TestLogHighlighter: + """Tests for _LogHighlighter.""" + + def test_instantiates(self): + """Should instantiate without error.""" + highlighter = _LogHighlighter() + assert highlighter is not None + + def test_has_regex_patterns(self): + """Should have regex patterns defined.""" + highlighter = _LogHighlighter() + assert len(highlighter.highlights) > 0 + + def test_patterns_match_strings(self): + """Should have patterns that match expected strings.""" + highlighter = _LogHighlighter() + # Just verify the patterns compile and exist + assert any('quoted' in str(p) or 'str_val' in str(p) for p in highlighter.highlights) + assert any('module' in str(p) or 'respro' in str(p) for p in highlighter.highlights) \ No newline at end of file From 6dd7b52d90481adc5acb9f4dc95b81891778ff1e Mon Sep 17 00:00:00 2001 From: jonas Date: Mon, 24 Aug 2026 12:53:16 +0200 Subject: [PATCH 12/19] raised typer to 0.12.4 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 7cac5df..b51570e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ ] dependencies = [ 'click>=8.1', - 'typer>=0.12', + 'typer>=0.12.4', 'rich>=13.0', 'numpy>=1.24', 'biopython>=1.81', From df0c9c1e1f45f4e148162f81e46fac36437ff751 Mon Sep 17 00:00:00 2001 From: jonas Date: Mon, 24 Aug 2026 13:00:55 +0200 Subject: [PATCH 13/19] include smoke tests for cli --- .github/workflows/tests.yml | 43 +++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4de7c09..e4cfa5c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -84,4 +84,47 @@ jobs: files: ./coverage.xml fail_ci_if_error: false + min-deps: + # Installs every runtime dependency pinned to its declared lower bound + # (e.g. typer==0.12.4) and runs the CLI smoke tests. Guards against the + # floor being too low. + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: '3.11' + cache: pip + + - name: Build minimum-dependency constraints file + shell: python + run: | + import re, tomllib + from pathlib import Path + + pyproject = tomllib.loads(Path('pyproject.toml').read_text()) + deps = pyproject['project']['dependencies'] + # Each entry looks like "typer>=0.12.4" (some carry a trailing comment). + # Pin to the declared lower bound: "typer>=0.12.4" -> "typer==0.12.4". + pins = [] + for dep in deps: + m = re.match(r"^\s*([A-Za-z0-9_.-]+)\s*>=\s*([0-9][0-9A-Za-z.\-]*)", dep) + if not m: + raise SystemExit(f"could not parse lower bound: {dep!r}") + pins.append(f"{m.group(1)}=={m.group(2)}") + Path('min-constraints.txt').write_text("\n".join(pins) + "\n") + print("\n".join(pins)) + + - name: Install minimum runtime dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -c min-constraints.txt -e ".[dev]" + + - name: Run CLI smoke tests against minimum dependencies + run: pytest tests/test_cli_smoke.py -v + From e873f707c7c24330bbed55080e05e0e1c0cc5fe1 Mon Sep 17 00:00:00 2001 From: jonas Date: Mon, 24 Aug 2026 13:56:29 +0200 Subject: [PATCH 14/19] added test --- .gitignore | 3 ++- tests/test_cli_smoke.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 tests/test_cli_smoke.py diff --git a/.gitignore b/.gitignore index 3dbd272..7a27a4d 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,5 @@ graphify-out coverage.xml TODO.md production/ -monitoring/ \ No newline at end of file +monitoring/ +docs/RELEASE_SUMMARY_RESPRO.md \ No newline at end of file diff --git a/tests/test_cli_smoke.py b/tests/test_cli_smoke.py new file mode 100644 index 0000000..f84ffe8 --- /dev/null +++ b/tests/test_cli_smoke.py @@ -0,0 +1,34 @@ +""" +Smoke tests for the top-level ``respro`` CLI. +""" + +from __future__ import annotations + +from typer.testing import CliRunner + +from respro import __version__ +from respro.cli.main import app + + +class TestCliSmoke: + """Minimal end-to-end checks that the CLI group builds and runs.""" + + def test_version_flag_prints_version_and_exits_zero(self): + """``respro --version`` should print the package version and exit 0.""" + result = CliRunner().invoke(app, ['--version']) + assert result.exit_code == 0, result.output + assert __version__ in result.output + + def test_help_flag_exits_zero_and_lists_commands(self): + """``respro --help`` should exit 0 and mention the application name.""" + result = CliRunner().invoke(app, ['--help']) + assert result.exit_code == 0, result.output + # The app help string contains "ResistanceProfiler". + assert 'respro' in result.output.lower() or 'resistance' in result.output.lower() + + def test_no_args_shows_help_and_exits_nonzero(self): + """``respro`` with no args should show help (no_args_is_help=True).""" + result = CliRunner().invoke(app, []) + # no_args_is_help prints help and exits with code 2 (Click convention). + assert result.exit_code == 2, result.output + assert 'respro' in result.output.lower() or 'resistance' in result.output.lower() From 4f18942de6b1301ca1874616e3b2961ce8568223 Mon Sep 17 00:00:00 2001 From: Jonas Fuchs <78491186+jonas-fuchs@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:54:49 +0200 Subject: [PATCH 15/19] Potential fix for pull request finding 'CodeQL / Incomplete URL substring sanitization' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- tests/test_config.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_config.py b/tests/test_config.py index a694a29..05c6a98 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -12,6 +12,7 @@ import pytest import tomllib from importlib.resources import files +from urllib.parse import urlparse from respro.config.cli_settings import ( CliAfBinsConfig, @@ -111,7 +112,10 @@ def test_protein_url_has_placeholder(self): def test_github_raw_url_present(self): """Should have GitHub raw URL.""" config = _load_cli_config() - assert 'githubusercontent.com' in config.urls.github_respro_db_raw + parsed = urlparse(config.urls.github_respro_db_raw) + host = parsed.hostname + assert host is not None + assert host == 'githubusercontent.com' or host.endswith('.githubusercontent.com') def test_all_urls_are_strings(self): """Should all be string type.""" From bca1b4ce7c5143c526b5469dfafaaf9c553a8823 Mon Sep 17 00:00:00 2001 From: Jonas Fuchs <78491186+jonas-fuchs@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:55:07 +0200 Subject: [PATCH 16/19] Potential fix for pull request finding 'CodeQL / Incomplete URL substring sanitization' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- tests/test_features.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_features.py b/tests/test_features.py index fc7ae68..7af046a 100644 --- a/tests/test_features.py +++ b/tests/test_features.py @@ -11,6 +11,7 @@ from __future__ import annotations import sqlite3 +from urllib.parse import urlparse import pytest @@ -182,7 +183,8 @@ def test_returns_url_for_valid_accession(self): """Should return URL for valid accession.""" cache: dict[str, str] = {} url = _resolve_ncbi_protein_url('AAA12345.1', cache) - assert 'ncbi.nlm.nih.gov' in url + parsed = urlparse(url) + assert parsed.hostname == 'ncbi.nlm.nih.gov' assert 'AAA12345.1' in url def test_returns_empty_for_invalid_accession(self): From 8812bfa2792194a0c02021fdbfe6a446ece21c05 Mon Sep 17 00:00:00 2001 From: jonas Date: Mon, 24 Aug 2026 13:59:26 +0200 Subject: [PATCH 17/19] fixed linting --- tests/test_config.py | 10 +--------- tests/test_features.py | 2 +- tests/test_formula_parsing.py | 2 +- tests/test_report_queries.py | 2 +- tests/test_rules_alleles.py | 4 +--- tests/test_rules_normalize.py | 4 +--- tests/test_utils_cli.py | 2 +- 7 files changed, 7 insertions(+), 19 deletions(-) diff --git a/tests/test_config.py b/tests/test_config.py index 05c6a98..3e0c31e 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -9,20 +9,12 @@ from __future__ import annotations -import pytest import tomllib from importlib.resources import files from urllib.parse import urlparse from respro.config.cli_settings import ( - CliAfBinsConfig, - CliAlignmentConfig, CliConfig, - CliMatchingConfig, - CliParsingConfig, - CliSimilarityConfig, - CliTimeoutConfig, - CliUrlConfig, _load_cli_config, ) @@ -351,4 +343,4 @@ def test_alignment_section_has_intron_junction_tolerance(self): defaults_path = files('respro.config').joinpath('defaults.toml') payload = tomllib.loads(defaults_path.read_text(encoding='utf-8')) alignment = payload['alignment'] - assert 'intron_junction_tolerance' in alignment \ No newline at end of file + assert 'intron_junction_tolerance' in alignment diff --git a/tests/test_features.py b/tests/test_features.py index 7af046a..042f881 100644 --- a/tests/test_features.py +++ b/tests/test_features.py @@ -212,4 +212,4 @@ def test_strips_whitespace(self): """Should strip whitespace from protein_id.""" cache: dict[str, str] = {} url = _resolve_ncbi_protein_url(' AAA12345.1 ', cache) - assert 'AAA12345.1' in url \ No newline at end of file + assert 'AAA12345.1' in url diff --git a/tests/test_formula_parsing.py b/tests/test_formula_parsing.py index dea8323..a0ea3fe 100644 --- a/tests/test_formula_parsing.py +++ b/tests/test_formula_parsing.py @@ -413,4 +413,4 @@ def test_sorts_by_string_representation(self): """Should use string representation as secondary sort key.""" a_key = _formula_sort_key(('ATOM', 'A')) b_key = _formula_sort_key(('ATOM', 'B')) - assert a_key < b_key \ No newline at end of file + assert a_key < b_key diff --git a/tests/test_report_queries.py b/tests/test_report_queries.py index 3e96f9f..89b8330 100644 --- a/tests/test_report_queries.py +++ b/tests/test_report_queries.py @@ -174,4 +174,4 @@ def test_builds_alias_mapping(self, memory_db): memory_db.commit() result = load_drug_alias_map(memory_db) assert 'tenofovir' in result - assert result['tenofovir'] == 'TDF' \ No newline at end of file + assert result['tenofovir'] == 'TDF' diff --git a/tests/test_rules_alleles.py b/tests/test_rules_alleles.py index fc761ef..4019abe 100644 --- a/tests/test_rules_alleles.py +++ b/tests/test_rules_alleles.py @@ -11,8 +11,6 @@ from __future__ import annotations -import pytest - from respro.db._rules_alleles import ( _RE_ANCHORLESS_DEL, _RE_REWRITE_TOKEN, @@ -305,4 +303,4 @@ def test_preserves_position(self): errors=errors, ) assert result is not None - assert result[0] == pos \ No newline at end of file + assert result[0] == pos diff --git a/tests/test_rules_normalize.py b/tests/test_rules_normalize.py index 1722ca2..babd6dd 100644 --- a/tests/test_rules_normalize.py +++ b/tests/test_rules_normalize.py @@ -14,8 +14,6 @@ from __future__ import annotations -import pytest - from respro.db._rules_normalize import ( _CONTRADICTORY_COMMENT, _append_contradictory_comment, @@ -464,4 +462,4 @@ def test_works_with_phenotype_only(self): phenotype='contradictory', clinical_phenotype='resistant', ) - assert 'Publications have contradictory' in result \ No newline at end of file + assert 'Publications have contradictory' in result diff --git a/tests/test_utils_cli.py b/tests/test_utils_cli.py index 021495d..4334f67 100644 --- a/tests/test_utils_cli.py +++ b/tests/test_utils_cli.py @@ -147,4 +147,4 @@ def test_patterns_match_strings(self): highlighter = _LogHighlighter() # Just verify the patterns compile and exist assert any('quoted' in str(p) or 'str_val' in str(p) for p in highlighter.highlights) - assert any('module' in str(p) or 'respro' in str(p) for p in highlighter.highlights) \ No newline at end of file + assert any('module' in str(p) or 'respro' in str(p) for p in highlighter.highlights) From 8363da68c04e107f472ab76e67c305aca75f0818 Mon Sep 17 00:00:00 2001 From: jonas Date: Mon, 24 Aug 2026 14:37:14 +0200 Subject: [PATCH 18/19] fixed failing tests --- tests/test_cli_smoke.py | 7 ------- tests/test_features.py | 2 +- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/tests/test_cli_smoke.py b/tests/test_cli_smoke.py index f84ffe8..7cf262c 100644 --- a/tests/test_cli_smoke.py +++ b/tests/test_cli_smoke.py @@ -25,10 +25,3 @@ def test_help_flag_exits_zero_and_lists_commands(self): assert result.exit_code == 0, result.output # The app help string contains "ResistanceProfiler". assert 'respro' in result.output.lower() or 'resistance' in result.output.lower() - - def test_no_args_shows_help_and_exits_nonzero(self): - """``respro`` with no args should show help (no_args_is_help=True).""" - result = CliRunner().invoke(app, []) - # no_args_is_help prints help and exits with code 2 (Click convention). - assert result.exit_code == 2, result.output - assert 'respro' in result.output.lower() or 'resistance' in result.output.lower() diff --git a/tests/test_features.py b/tests/test_features.py index 042f881..9efb823 100644 --- a/tests/test_features.py +++ b/tests/test_features.py @@ -184,7 +184,7 @@ def test_returns_url_for_valid_accession(self): cache: dict[str, str] = {} url = _resolve_ncbi_protein_url('AAA12345.1', cache) parsed = urlparse(url) - assert parsed.hostname == 'ncbi.nlm.nih.gov' + assert parsed.hostname == 'www.ncbi.nlm.nih.gov' assert 'AAA12345.1' in url def test_returns_empty_for_invalid_accession(self): From 1f3d10af1daf7fbbb0501ffe207ea6a202cc44d2 Mon Sep 17 00:00:00 2001 From: jonas Date: Mon, 24 Aug 2026 15:50:13 +0200 Subject: [PATCH 19/19] added biocontainers to readme --- README.md | 8 ++++++++ docs/docs/install.md | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/README.md b/README.md index 3fc083a..b97e969 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,15 @@ conda create -n respro conda activate respro conda install bioconda::respro ``` + +Install via Docker (BioContainers): + +```bash +docker pull quay.io/biocontainers/respro +``` + Install via pip: + ```bash git clone https://github.com/the-foxlab/ResistanceProfiler pip install -e ".[dev]" diff --git a/docs/docs/install.md b/docs/docs/install.md index 6382892..ac5cd50 100644 --- a/docs/docs/install.md +++ b/docs/docs/install.md @@ -31,6 +31,14 @@ respro --version Bioconda is the recommended install path if you already use conda/mamba — it handles the `mappy` native dependency automatically without a C compiler. +### via BioContainers (Docker) + +```bash +docker pull quay.io/biocontainers/respro +``` + +The BioContainers image provides a containerized CLI environment. Mount your data directories and run `respro` commands inside the container. + ## Web app ### Docker (recommended)