From 080e5ed510191df664ae57538bb07da2c1a14bab Mon Sep 17 00:00:00 2001 From: jamesli124 <58196853+jamesli124@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:46:22 -0500 Subject: [PATCH 1/2] test: raise coverage via validation, CLI entry, and lazy-namespace tests Adds unit tests for utils/validation.py (0% -> 100%), in-process CLI entry-point tests for every registered subcommand via Click's CliRunner (main_cli.py 0% -> 97%, closing the gap for breakpoint-motifs and interval-breakpoint-motifs which had no entry-point test at all), and lightweight tests for several other under-covered pure-logic modules (finaletoolkit's flat namespace, cli/__init__ lazy import, _comparison, _intervals, logging, io/writers, and part of cli/_dispatch). Wires pytest-cov into python-package.yml so coverage is reported on every PR. Overall coverage: 54% -> 61%. 225 passed, 11 skipped (same CRAM-gated skips as baseline). Co-Authored-By: Claude Sonnet 5 --- .github/workflows/python-package.yml | 4 +- pyproject.toml | 4 + tests/test_cli.py | 44 +++++++++ tests/test_dispatch.py | 86 ++++++++++++++++ tests/test_io_writers.py | 59 +++++++++++ tests/test_lazy_namespaces.py | 68 +++++++++++++ tests/test_logging.py | 57 +++++++++++ tests/test_utils.py | 78 ++++++++++++++- tests/test_validation.py | 140 +++++++++++++++++++++++++++ 9 files changed, 537 insertions(+), 3 deletions(-) create mode 100644 tests/test_dispatch.py create mode 100644 tests/test_io_writers.py create mode 100644 tests/test_lazy_namespaces.py create mode 100644 tests/test_logging.py create mode 100644 tests/test_validation.py diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index b29a3c9..a14ff6c 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -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: | @@ -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 diff --git a/pyproject.toml b/pyproject.toml index f1f7b55..9469c33 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,6 +73,10 @@ filterwarnings = [ "ignore::DeprecationWarning", ] +[tool.coverage.run] +source = ["finaletoolkit"] +omit = ["*/finaletoolkit/_version.py"] + [tool.ruff] line-length = 88 target-version = "py310" diff --git a/tests/test_cli.py b/tests/test_cli.py index cccdad6..7571962 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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} @@ -175,3 +177,45 @@ 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, exit 0. + result = CliRunner().invoke(main_cli, []) + assert result.exit_code == 0, 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 diff --git a/tests/test_dispatch.py b/tests/test_dispatch.py new file mode 100644 index 0000000..f34cbc6 --- /dev/null +++ b/tests/test_dispatch.py @@ -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} diff --git a/tests/test_io_writers.py b/tests/test_io_writers.py new file mode 100644 index 0000000..368b176 --- /dev/null +++ b/tests/test_io_writers.py @@ -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 diff --git a/tests/test_lazy_namespaces.py b/tests/test_lazy_namespaces.py new file mode 100644 index 0000000..06f90fa --- /dev/null +++ b/tests/test_lazy_namespaces.py @@ -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") diff --git a/tests/test_logging.py b/tests/test_logging.py new file mode 100644 index 0000000..314304a --- /dev/null +++ b/tests/test_logging.py @@ -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 diff --git a/tests/test_utils.py b/tests/test_utils.py index 87a0b7a..35b5d88 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -66,4 +66,80 @@ def test_low_quality_read_pairs(self, request): assert low_quality_read_pairs(next(read)) assert low_quality_read_pairs(next(read)) assert low_quality_read_pairs(next(read)) - assert not low_quality_read_pairs(next(read)) \ No newline at end of file + assert not low_quality_read_pairs(next(read)) + + +class TestNoneToleranceComparisons: + """_none_leq/_none_geq/_none_eq: a None operand means "unbounded".""" + + def test_none_leq(self): + assert _none_leq(1, 2) + assert not _none_leq(2, 1) + assert _none_leq(None, 2) + assert _none_leq(1, None) + assert _none_leq(None, None) + + def test_none_geq(self): + assert _none_geq(2, 1) + assert not _none_geq(1, 2) + assert _none_geq(None, 2) + assert _none_geq(1, None) + assert _none_geq(None, None) + + def test_none_eq(self): + assert _none_eq(3, 3) + assert not _none_eq(3, 4) + assert _none_eq(None, 3) + assert _none_eq(3, None) + assert _none_eq(None, None) + + +class TestMergeIntervals: + """_merge_overlapping_intervals and friends, used to collapse a BED file's + intervals per contig before further processing.""" + + def test_merge_overlapping_intervals(self): + intervals = [(10, 20), (15, 25), (30, 40), (100, 200)] + assert _merge_overlapping_intervals(intervals) == [ + (10, 25), (30, 40), (100, 200) + ] + + def test_merge_overlapping_intervals_no_overlap(self): + intervals = [(30, 40), (10, 20)] + assert _merge_overlapping_intervals(intervals) == [(10, 20), (30, 40)] + + def test_merge_overlapping_intervals_containment(self): + # A fully-contained interval shouldn't shrink the merged span. + assert _merge_overlapping_intervals([(10, 100), (20, 30)]) == [(10, 100)] + + def test_merge_overlapping_intervals_empty(self): + assert _merge_overlapping_intervals([]) == [] + + def test_reduce_overlaps_in_file(self, tmp_path): + bed = tmp_path / "intervals.bed" + bed.write_text( + "1\t10\t20\n" + "1\t15\t25\n" + "2\t5\t8\n" + ) + assert _reduce_overlaps_in_file(str(bed)) == { + "1": [(10, 25)], + "2": [(5, 8)], + } + + def test_convert_to_list(self): + reduced = {"1": [(10, 20), (30, 40)]} + assert _convert_to_list(reduced) == { + "1": [["1", 10, 20], ["1", 30, 40]] + } + + def test_merge_all_intervals(self): + converted = { + "1": [["1", 10, 20]], + "2": [["2", 5, 8], ["2", 50, 60]], + } + result = _merge_all_intervals(converted) + assert result == [["1", 10, 20], ["2", 5, 8], ["2", 50, 60]] + + def test_merge_all_intervals_empty(self): + assert _merge_all_intervals({}) == [] \ No newline at end of file diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 0000000..278c125 --- /dev/null +++ b/tests/test_validation.py @@ -0,0 +1,140 @@ +""" +Tests for finaletoolkit.utils.validation +""" + +import pytest + +from finaletoolkit.utils.validation import ( + validate_compatible_contigs, + valid_interval, +) + + +class TestValidateCompatibleContigs: + def test_exact_match_lists(self): + assert validate_compatible_contigs(["1", "2", "3"], ["1", "2", "3"]) + + def test_subset_allowed_by_default(self): + assert validate_compatible_contigs(["1", "2", "3"], ["1", "2"]) + + def test_extra_input_contig_raises_by_default(self): + with pytest.raises(ValueError, match="not found in reference"): + validate_compatible_contigs(["1", "2"], ["1", "2", "4"]) + + def test_extra_input_contig_returns_false_when_not_throwing(self): + assert not validate_compatible_contigs( + ["1", "2"], ["1", "2", "4"], throw_on_error=False + ) + + def test_subset_disallowed_raises(self): + with pytest.raises(ValueError, match="not found in input"): + validate_compatible_contigs( + ["1", "2", "3"], ["1", "2"], allow_subset=False + ) + + def test_subset_disallowed_returns_false_when_not_throwing(self): + assert not validate_compatible_contigs( + ["1", "2", "3"], ["1", "2"], allow_subset=False, throw_on_error=False + ) + + def test_subset_disallowed_but_sets_match_passes(self): + assert validate_compatible_contigs( + ["1", "2"], ["1", "2"], allow_subset=False + ) + + def test_validate_sizes_matching(self): + assert validate_compatible_contigs( + {"1": 100, "2": 200}, {"1": 100, "2": 200}, validate_sizes=True + ) + + def test_validate_sizes_mismatch_raises(self): + with pytest.raises(RuntimeError, match="length mismatch"): + validate_compatible_contigs( + {"1": 100, "2": 200}, {"1": 100, "2": 999}, validate_sizes=True + ) + + def test_validate_sizes_mismatch_returns_false_when_not_throwing(self): + assert not validate_compatible_contigs( + {"1": 100, "2": 200}, + {"1": 100, "2": 999}, + validate_sizes=True, + throw_on_error=False, + ) + + def test_validate_sizes_requires_dicts_raises_typeerror(self): + with pytest.raises(TypeError, match="requires both"): + validate_compatible_contigs( + ["1", "2"], ["1", "2"], validate_sizes=True + ) + + def test_validate_sizes_requires_dicts_returns_false_when_not_throwing(self): + assert not validate_compatible_contigs( + ["1", "2"], ["1", "2"], validate_sizes=True, throw_on_error=False + ) + + def test_validate_sizes_only_checks_input_contigs(self): + # Reference may have extra contigs (allowed by default subset rule); + # their sizes shouldn't be checked since they aren't in input_names. + assert validate_compatible_contigs( + {"1": 100, "2": 999999}, + {"1": 100}, + validate_sizes=True, + ) + + +class TestValidInterval: + def test_contig_missing_returns_false_by_default(self): + assert not valid_interval(["1", "2"], "3") + + def test_contig_missing_raises_when_requested(self): + with pytest.raises(ValueError, match="not found in reference"): + valid_interval(["1", "2"], "3", throw_on_error=True) + + def test_valid_bounds_with_lengths(self): + assert valid_interval({"1": 1000}, "1", start=0, stop=1000) + + def test_start_negative_with_lengths_returns_false(self): + assert not valid_interval({"1": 1000}, "1", start=-1) + + def test_start_negative_with_lengths_raises(self): + with pytest.raises(IndexError, match="out of bounds"): + valid_interval({"1": 1000}, "1", start=-1, throw_on_error=True) + + def test_start_beyond_length_returns_false(self): + assert not valid_interval({"1": 1000}, "1", start=1000) + + def test_stop_negative_returns_false(self): + assert not valid_interval({"1": 1000}, "1", stop=-1) + + def test_stop_beyond_length_raises(self): + with pytest.raises(IndexError, match="out of bounds"): + valid_interval({"1": 1000}, "1", stop=1001, throw_on_error=True) + + def test_start_not_less_than_stop_returns_false(self): + assert not valid_interval({"1": 1000}, "1", start=500, stop=500) + + def test_start_not_less_than_stop_raises(self): + with pytest.raises(ValueError, match="must be less than stop"): + valid_interval( + {"1": 1000}, "1", start=500, stop=100, throw_on_error=True + ) + + def test_only_start_provided_skips_stop_checks(self): + assert valid_interval({"1": 1000}, "1", start=999) + + def test_only_stop_provided_skips_start_checks(self): + assert valid_interval({"1": 1000}, "1", stop=1000) + + def test_list_input_contig_present_no_length_checks(self): + # Without lengths, only a negative start can be rejected. + assert valid_interval(["1", "2"], "1", start=10**9) + + def test_list_input_negative_start_returns_false(self): + assert not valid_interval(["1", "2"], "1", start=-1) + + def test_list_input_negative_start_raises(self): + with pytest.raises(IndexError, match="cannot be negative"): + valid_interval(["1", "2"], "1", start=-1, throw_on_error=True) + + def test_list_input_no_start_or_stop_is_valid(self): + assert valid_interval(["1", "2"], "1") From 2e23976b9d6713320e45a70bcbe0f6bd79882a9e Mon Sep 17 00:00:00 2001 From: jamesli124 <58196853+jamesli124@users.noreply.github.com> Date: Thu, 23 Jul 2026 10:30:39 -0500 Subject: [PATCH 2/2] fix(tests): tolerate Click's no_args_is_help exit-code change Click 8.1 returns exit code 0 for a bare group invocation with no subcommand (no_args_is_help); Click >=8.2 returns 2. This project doesn't pin click's version, so CI can resolve either depending on what's newest at install time -- reproduced locally by upgrading to click 8.4.2. The test now accepts either code and checks the actual behavior that matters (help text shown, no crash/hang). Co-Authored-By: Claude Sonnet 5 --- tests/test_cli.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 7571962..46c9ec4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -204,9 +204,12 @@ def test_version(self): 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, exit 0. + # 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 == 0, result.output + assert result.exit_code in (0, 2), result.output assert "Usage" in result.output def test_unknown_subcommand_fails_cleanly(self):