From f237c0dfb1b64a2381e42caa182ef22b5d2875a7 Mon Sep 17 00:00:00 2001 From: Alastair Smith Date: Mon, 22 Jun 2026 15:46:17 +0100 Subject: [PATCH] feat: enhance CLI reactivity and improve module imports for performance --- seqnado/__init__.py | 21 ++++++++++++++++++--- seqnado/cli/autocomplete.py | 2 +- seqnado/cli/commands/benchmark.py | 20 ++++++++++---------- seqnado/cli/commands/pipeline.py | 4 ++-- seqnado/config/__init__.py | 2 ++ seqnado/config/configs.py | 27 ++++++++++++++++++++++++++- seqnado/core.py | 31 ------------------------------- tests/unit/test_core.py | 2 +- 8 files changed, 60 insertions(+), 49 deletions(-) diff --git a/seqnado/__init__.py b/seqnado/__init__.py index 3a1c49244..2d5edb2b9 100755 --- a/seqnado/__init__.py +++ b/seqnado/__init__.py @@ -17,12 +17,27 @@ Molecule, Organism, FileType, - GenomicCoordinate ) -from . import data, config, inputs, outputs +_LAZY_SUBMODULES = {"data", "config", "inputs", "outputs"} +_LAZY_ATTRS = {"GenomicCoordinate": ("config", "GenomicCoordinate")} +def __getattr__(name: str): + if name in _LAZY_SUBMODULES: + import importlib + mod = importlib.import_module(f".{name}", __name__) + globals()[name] = mod + return mod + if name in _LAZY_ATTRS: + mod_name, attr = _LAZY_ATTRS[name] + import importlib + mod = importlib.import_module(f".{mod_name}", __name__) + obj = getattr(mod, attr) + globals()[name] = obj + return obj + raise AttributeError(f"module 'seqnado' has no attribute {name!r}") + __all__ = [ "Assay", @@ -49,5 +64,5 @@ "Molecule", "Organism", "FileType", - "GenomicCoordinate" + "GenomicCoordinate", ] \ No newline at end of file diff --git a/seqnado/cli/autocomplete.py b/seqnado/cli/autocomplete.py index 51b73b134..f9906a17a 100644 --- a/seqnado/cli/autocomplete.py +++ b/seqnado/cli/autocomplete.py @@ -8,7 +8,7 @@ def _assay_names() -> List[str]: """Get list of available assay names for autocomplete.""" - from seqnado.inputs import Assay # local import to keep CLI startup snappy + from seqnado.core import Assay return list(Assay.all_assay_clean_names()) diff --git a/seqnado/cli/commands/benchmark.py b/seqnado/cli/commands/benchmark.py index 10e673d11..2cf96de32 100644 --- a/seqnado/cli/commands/benchmark.py +++ b/seqnado/cli/commands/benchmark.py @@ -8,16 +8,6 @@ from loguru import logger from seqnado.cli.app_instance import app -from seqnado.cli.benchmark_helpers import ( - compute_assay_output_sizes, - discover_snakemake_logs, - format_compact_number, - load_benchmark_table, - parse_alignment_processing_logs, - parse_snakemake_logs_timeline, - summarize_benchmarks, - write_html_report, -) from seqnado.cli.utils import _configure_logging, cli_print_table, verbose_option @@ -101,6 +91,16 @@ def run_benchmark_report( verbose: bool = False, ) -> None: """Generate benchmark outputs from plain Python code without Typer option wrappers.""" + from seqnado.cli.benchmark_helpers import ( + compute_assay_output_sizes, + discover_snakemake_logs, + format_compact_number, + load_benchmark_table, + parse_alignment_processing_logs, + parse_snakemake_logs_timeline, + summarize_benchmarks, + write_html_report, + ) _configure_logging(verbose) resolved_benchmark_dir = _resolve_benchmark_dir(benchmark_dir) diff --git a/seqnado/cli/commands/pipeline.py b/seqnado/cli/commands/pipeline.py index a490d3ef8..59621a9ed 100644 --- a/seqnado/cli/commands/pipeline.py +++ b/seqnado/cli/commands/pipeline.py @@ -13,7 +13,6 @@ from seqnado.cli.app_instance import app from seqnado.cli.autocomplete import assay_autocomplete -from seqnado.cli.commands.benchmark import run_benchmark_report from seqnado.cli.snakemake_builder import SnakemakeCommandBuilder from seqnado.cli.utils import ( _configure_logging, @@ -26,7 +25,6 @@ verbose_option, preset_option, ) -from seqnado.outputs.multiomics import find_assay_config_paths from seqnado.utils import ( resolve_profile_path, extract_cores_from_options, @@ -44,6 +42,7 @@ def _ensure_default_snakemake_flag(options: List[str], flag: str) -> List[str]: def _run_benchmark_after_success(verbose: bool) -> None: """Best-effort benchmark/report generation after a successful pipeline run.""" try: + from seqnado.cli.commands.benchmark import run_benchmark_report run_benchmark_report(benchmark_dir=Path(".benchmark"), verbose=verbose) except typer.Exit as exc: if exc.exit_code not in (0, None): @@ -246,6 +245,7 @@ def pipeline( require_snakemake() # Detect multiomics configs early + from seqnado.outputs.multiomics import find_assay_config_paths config_files = find_assay_config_paths(Path(".")) use_multiomics = len(config_files) > 1 and not config_file and not assay diff --git a/seqnado/config/__init__.py b/seqnado/config/__init__.py index ce7b69d89..eee7c1853 100644 --- a/seqnado/config/__init__.py +++ b/seqnado/config/__init__.py @@ -27,6 +27,7 @@ SNPCallingConfig, MCCConfig, MethylationConfig, + GenomicCoordinate, ) from .user_input import ( @@ -75,4 +76,5 @@ "MultiomicsConfig", "build_multiomics_config", "render_multiomics_configs", + "GenomicCoordinate", ] diff --git a/seqnado/config/configs.py b/seqnado/config/configs.py index 26cc2ce74..fd1aaa285 100644 --- a/seqnado/config/configs.py +++ b/seqnado/config/configs.py @@ -15,7 +15,6 @@ from seqnado import ( Assay, - GenomicCoordinate, MethylationMethod, MotifMethod, PCRDuplicateHandling, @@ -32,6 +31,32 @@ ) +class GenomicCoordinate(BaseModel): + """Configuration for genomic coordinates.""" + + chromosome: str + start: int + end: int + + @field_validator("start", "end") + def validate_coordinates(cls, v: int) -> int: + if v < 0: + raise ValueError("Genomic coordinates must be non-negative.") + return v + + @field_validator("end") + def validate_end_greater_than_start(cls, v: int, info) -> int: + if "start" in info.data and v < info.data["start"]: + raise ValueError("End coordinate must be greater than start coordinate.") + return v + + @classmethod + def from_string(cls, coord_str: str) -> "GenomicCoordinate": + chromosome, positions = coord_str.split(":") + start, end = map(int, positions.split("-")) + return cls(chromosome=chromosome, start=start, end=end) + + def none_str_to_none(v): if isinstance(v, str) and v.strip().lower() == "none": return None diff --git a/seqnado/core.py b/seqnado/core.py index 615e33c50..1c17b51c9 100644 --- a/seqnado/core.py +++ b/seqnado/core.py @@ -1,7 +1,5 @@ from enum import Enum -from pydantic import BaseModel, field_validator - # ============================================================================= # CONSTANTS # ============================================================================= @@ -210,32 +208,3 @@ class LibraryType(Enum): PAIRED = "paired-end" -class GenomicCoordinate(BaseModel): - """Configuration for genomic coordinates.""" - - chromosome: str - start: int - end: int - - @field_validator("start", "end") - def validate_coordinates(cls, v: int) -> int: - if v < 0: - raise ValueError("Genomic coordinates must be non-negative.") - return v - - # Check that end is greater than start - @field_validator("end") - def validate_end_greater_than_start(cls, v: int, info) -> int: - # Only validate if start is available (i.e., it passed validation) - if "start" in info.data and v < info.data["start"]: - raise ValueError("End coordinate must be greater than start coordinate.") - return v - - @classmethod - def from_string(cls, coord_str: str) -> "GenomicCoordinate": - """ - Create a GenomicCoordinate instance from a string representation. - """ - chromosome, positions = coord_str.split(":") - start, end = map(int, positions.split("-")) - return cls(chromosome=chromosome, start=start, end=end) diff --git a/tests/unit/test_core.py b/tests/unit/test_core.py index 0bab930de..91b1d5318 100644 --- a/tests/unit/test_core.py +++ b/tests/unit/test_core.py @@ -3,9 +3,9 @@ import pytest from pydantic import ValidationError +from seqnado.config import GenomicCoordinate from seqnado.core import ( Assay, - GenomicCoordinate, FileType, PileupMethod, DataScalingTechnique,