Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install flake8 pytest
python -m pip install flake8 pytest pytest-cov
python -m pip install .
- name: Lint with flake8
run: |
Expand All @@ -33,4 +33,4 @@ jobs:
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Test with pytest
run: |
pytest
pytest --cov=finaletoolkit --cov-report=term-missing
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ filterwarnings = [
"ignore::DeprecationWarning",
]

[tool.coverage.run]
source = ["finaletoolkit"]
omit = ["*/finaletoolkit/_version.py"]

[tool.ruff]
line-length = 88
target-version = "py310"
47 changes: 47 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@
import sys

import pytest
from click.testing import CliRunner

from finaletoolkit.cli.commands import COMMAND_TARGETS, COMMANDS
from finaletoolkit.cli.main_cli import main_cli

# name -> Click command object, for introspecting declared parameters.
_COMMANDS_BY_NAME = {command.name: command for command in COMMANDS}
Expand Down Expand Up @@ -175,3 +177,48 @@ def test_coverage_smoke(self, request):
'12\t34443118\t34443538\t.\t0.25',
'12\t34444968\t34446115\t.\t0.4375',
]


class TestCLIEntryPointsInProcess:
"""Invoke every registered subcommand's entry point via Click's
``CliRunner`` instead of shelling out.

``TestCLIEntryPoint`` above exercises the *installed* ``finaletoolkit``
console script through ``os.system``/``subprocess``, which is a real
end-to-end check of the packaging entry point, but runs in a separate
process invisible to coverage instrumentation and only covers a subset
of commands. This class runs in-process (so ``main_cli`` and each
subcommand's Click wiring show up in coverage) and parametrizes over
every command in ``COMMANDS``, including ``breakpoint-motifs`` and
``interval-breakpoint-motifs`` which aren't covered above.
"""

def test_top_level_help(self):
result = CliRunner().invoke(main_cli, ["--help"])
assert result.exit_code == 0, result.output

def test_version(self):
result = CliRunner().invoke(main_cli, ["--version"])
assert result.exit_code == 0, result.output
assert "FinaleToolkit" in result.output

def test_no_args_shows_help(self):
# A bare `finaletoolkit` invocation (Click's default no-subcommand
# behavior for groups is `no_args_is_help`): prints help rather than
# hanging or crashing. The exact exit code for this case isn't
# stable across Click versions -- Click 8.1 returns 0, Click >=8.2
# returns 2 -- so only the visible behavior (help shown) is checked.
result = CliRunner().invoke(main_cli, [])
assert result.exit_code in (0, 2), result.output
assert "Usage" in result.output

def test_unknown_subcommand_fails_cleanly(self):
result = CliRunner().invoke(main_cli, ["not-a-real-subcommand"])
assert result.exit_code != 0
assert "No such command" in result.output

@pytest.mark.parametrize("name", [command.name for command in COMMANDS])
def test_subcommand_help(self, name: str):
result = CliRunner().invoke(main_cli, [name, "--help"])
assert result.exit_code == 0, result.output
assert "Usage" in result.output
86 changes: 86 additions & 0 deletions tests/test_dispatch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""
Tests for finaletoolkit.cli._dispatch

Covers the pure, CRAM-independent logic: strand-flag translation, the
input-file-absent/BAM-without-reference fast paths through
``_validate_inputs``, and ``run``'s param-filtering/dispatch mechanics. The
CRAM+reference contig-validation branch (which needs real alignment/reference
files) is already exercised by the filter-file tests in test_cli.py.
"""

import pytest

from finaletoolkit.cli._dispatch import _translate_strand, _validate_inputs, run


class TestTranslateStrand:
def test_both(self):
params = {"strand": "both"}
_translate_strand(params)
assert params == {"both_strands": True, "negative_strand": False}

def test_forward(self):
params = {"strand": "forward"}
_translate_strand(params)
assert params == {"both_strands": False, "negative_strand": False}

def test_reverse(self):
params = {"strand": "reverse"}
_translate_strand(params)
assert params == {"both_strands": False, "negative_strand": True}

def test_no_strand_key_is_a_no_op(self):
params = {"other": 1}
_translate_strand(params)
assert params == {"other": 1}


class TestValidateInputs:
def test_no_input_file_is_a_no_op(self):
_validate_inputs({})
_validate_inputs({"input_file": None})
_validate_inputs({"input_file": ""})

def test_cram_without_reference_exits(self):
with pytest.raises(SystemExit) as exc_info:
_validate_inputs({"input_file": "sample.cram"})
assert exc_info.value.code == 1

def test_bam_without_reference_is_a_no_op(self):
# A reference is optional for BAM; only CRAM requires one.
_validate_inputs({"input_file": "sample.bam"})

def test_non_alignment_input_is_a_no_op(self):
# Neither .bam nor .cram: the reference/contig-compatibility branch
# doesn't apply regardless of whether a reference was given.
_validate_inputs({"input_file": "sample.frag.gz", "reference_file": "ref.fa"})


def _stub_add(a, b=10):
return a + b


def _stub_kwargs(**kwargs):
return kwargs


class TestRun:
def test_filters_params_to_function_signature(self):
result = run(
__name__, "_stub_add", {"a": 1, "b": 2, "unrelated_cli_only_key": "x"}
)
assert result == 3

def test_uses_function_defaults_for_missing_params(self):
result = run(__name__, "_stub_add", {"a": 5})
assert result == 15

def test_varkw_function_receives_all_params_unfiltered(self):
result = run(__name__, "_stub_kwargs", {"a": 1, "anything": "goes"})
assert result == {"a": 1, "anything": "goes"}

def test_strand_translated_before_dispatch(self):
result = run(
__name__, "_stub_kwargs", {"strand": "reverse", "a": 1}
)
assert result == {"a": 1, "both_strands": False, "negative_strand": True}
59 changes: 59 additions & 0 deletions tests/test_io_writers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""
Tests for finaletoolkit.io.writers
"""

import gzip
import sys

from finaletoolkit.io.writers import smart_open_text, is_stdout


class TestIsStdout:
def test_dash_is_stdout(self):
assert is_stdout("-")

def test_path_is_not_stdout(self):
assert not is_stdout("output.txt")


class TestSmartOpenText:
def test_writes_stdout(self, capsys):
with smart_open_text("-") as f:
assert f is sys.stdout
f.write("hello\n")
assert capsys.readouterr().out == "hello\n"

def test_stdout_not_closed_on_exit(self, capsys):
with smart_open_text("-") as f:
pass
assert not sys.stdout.closed

def test_writes_plain_text_file(self, tmp_path):
path = tmp_path / "out.txt"
with smart_open_text(str(path)) as f:
f.write("plain text\n")
assert path.read_text() == "plain text\n"

def test_writes_gzip_file(self, tmp_path):
path = tmp_path / "out.txt.gz"
with smart_open_text(str(path)) as f:
f.write("gzipped text\n")
with gzip.open(path, "rt") as f:
assert f.read() == "gzipped text\n"

def test_file_closed_on_exit(self, tmp_path):
path = tmp_path / "out.txt"
with smart_open_text(str(path)) as f:
handle = f
assert handle.closed

def test_file_closed_on_exception(self, tmp_path):
path = tmp_path / "out.txt"
handle = None
try:
with smart_open_text(str(path)) as f:
handle = f
raise ValueError("boom")
except ValueError:
pass
assert handle.closed
68 changes: 68 additions & 0 deletions tests/test_lazy_namespaces.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""
Tests for the lazy attribute-resolution namespaces:
``finaletoolkit`` (flat public API + submodules + aliases) and
``finaletoolkit.cli`` (lazy ``main_cli`` import), both PEP 562 ``__getattr__``
shims that keep ``import finaletoolkit`` cheap.
"""

import pytest

import finaletoolkit


class TestFlatNamespace:
def test_submodule_lazy_import(self):
import finaletoolkit.frag as frag_direct

assert finaletoolkit.frag is frag_direct

def test_flat_export_resolves_to_real_function(self):
from finaletoolkit.frag import coverage as coverage_direct

assert finaletoolkit.coverage is coverage_direct

def test_flat_export_from_different_submodule(self):
from finaletoolkit.genome import GenomeGaps as GenomeGaps_direct

assert finaletoolkit.GenomeGaps is GenomeGaps_direct

def test_alias_resolves_to_same_object_as_full_name(self):
assert finaletoolkit.end_motif is finaletoolkit.end_motifs

def test_breakpoint_alias(self):
assert finaletoolkit.breakpoint_motif is finaletoolkit.breakpoint_motifs

def test_unknown_attribute_raises(self):
with pytest.raises(AttributeError, match="no attribute"):
finaletoolkit.not_a_real_symbol

def test_dir_includes_flat_exports_and_submodules_and_aliases(self):
names = dir(finaletoolkit)
assert "coverage" in names
assert "frag" in names
assert "end_motif" in names
assert "__version__" in names


class TestCliLazyImport:
def test_main_cli_lazy_import(self):
# Calling __getattr__ directly (rather than via plain attribute
# access, e.g. `cli.main_cli`) is deliberate: `main_cli` is both the
# submodule's name and the Click group defined inside it, and once
# anything else in the process imports the `main_cli` submodule
# (as other test modules do), Python's ordinary import machinery
# binds the *submodule* onto `finaletoolkit.cli.main_cli`, shadowing
# whatever `__getattr__` would return -- exactly the ambiguity the
# module's docstring calls out. Invoking the function isolates the
# lazy-resolution logic itself from that import-order dependent
# shadowing.
import finaletoolkit.cli as cli
from finaletoolkit.cli.main_cli import main_cli as main_cli_direct

assert cli.__getattr__("main_cli") is main_cli_direct

def test_unknown_attribute_raises(self):
import finaletoolkit.cli as cli

with pytest.raises(AttributeError, match="no attribute"):
cli.__getattr__("not_a_real_symbol")
57 changes: 57 additions & 0 deletions tests/test_logging.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""
Tests for finaletoolkit.utils.logging
"""

import logging

from finaletoolkit.utils.logging import Logger, get_logger, set_verbosity


class TestLogger:
def test_get_logger_returns_logger(self):
log = get_logger("finaletoolkit.test_logging.a")
assert isinstance(log, Logger)

def test_handler_attached_once(self):
# Constructing two Logger wrappers for the same name shouldn't
# duplicate stderr handlers.
name = "finaletoolkit.test_logging.b"
first = Logger(name)
second = Logger(name)
assert len(first._logger.handlers) == 1
assert first._logger is second._logger

def test_log_levels_write_to_stderr(self, capsys):
# propagate=False keeps records off pytest's caplog handler, so
# assert on the actual stderr stream the custom handler writes to.
log = get_logger("finaletoolkit.test_logging.c", level=logging.DEBUG)
log.debug("debug msg")
log.info("info msg")
log.warning("warning msg")
log.error("error msg")
log.critical("critical msg")
err = capsys.readouterr().err
for msg in ("debug msg", "info msg", "warning msg", "error msg", "critical msg"):
assert msg in err

def test_default_level_filters_debug(self, capsys):
log = get_logger("finaletoolkit.test_logging.c2")
log.debug("should not appear")
log.info("should appear")
err = capsys.readouterr().err
assert "should not appear" not in err
assert "should appear" in err

def test_set_level_updates_logger_and_handlers(self):
log = get_logger("finaletoolkit.test_logging.d")
log.set_level(logging.ERROR)
assert log._logger.level == logging.ERROR
for handler in log._logger.handlers:
assert handler.level == logging.ERROR

def test_set_verbosity_sets_parent_logger_level(self):
set_verbosity(logging.WARNING)
assert logging.getLogger("finaletoolkit").level == logging.WARNING
# child loggers with no explicit level inherit from the parent
set_verbosity(logging.INFO)
assert logging.getLogger("finaletoolkit").level == logging.INFO
Loading
Loading