Skip to content

Commit bb32eec

Browse files
authored
Merge pull request #184 from epifluidlab/test/coverage-validation-and-cli
test: raise coverage via validation, CLI entry, and lazy-namespace tests
2 parents abc25a9 + 2e23976 commit bb32eec

9 files changed

Lines changed: 540 additions & 3 deletions

File tree

.github/workflows/python-package.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ jobs:
2525
- name: Install dependencies
2626
run: |
2727
python -m pip install --upgrade pip
28-
python -m pip install flake8 pytest
28+
python -m pip install flake8 pytest pytest-cov
2929
python -m pip install .
3030
- name: Lint with flake8
3131
run: |
@@ -35,4 +35,4 @@ jobs:
3535
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
3636
- name: Test with pytest
3737
run: |
38-
pytest
38+
pytest --cov=finaletoolkit --cov-report=term-missing

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ filterwarnings = [
8181
"ignore::DeprecationWarning",
8282
]
8383

84+
[tool.coverage.run]
85+
source = ["finaletoolkit"]
86+
omit = ["*/finaletoolkit/_version.py"]
87+
8488
[tool.ruff]
8589
line-length = 88
8690
target-version = "py310"

tests/test_cli.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@
1010
import sys
1111

1212
import pytest
13+
from click.testing import CliRunner
1314

1415
from finaletoolkit.cli.commands import COMMAND_TARGETS, COMMANDS
16+
from finaletoolkit.cli.main_cli import main_cli
1517

1618
# name -> Click command object, for introspecting declared parameters.
1719
_COMMANDS_BY_NAME = {command.name: command for command in COMMANDS}
@@ -175,3 +177,48 @@ def test_coverage_smoke(self, request):
175177
'12\t34443118\t34443538\t.\t0.25',
176178
'12\t34444968\t34446115\t.\t0.4375',
177179
]
180+
181+
182+
class TestCLIEntryPointsInProcess:
183+
"""Invoke every registered subcommand's entry point via Click's
184+
``CliRunner`` instead of shelling out.
185+
186+
``TestCLIEntryPoint`` above exercises the *installed* ``finaletoolkit``
187+
console script through ``os.system``/``subprocess``, which is a real
188+
end-to-end check of the packaging entry point, but runs in a separate
189+
process invisible to coverage instrumentation and only covers a subset
190+
of commands. This class runs in-process (so ``main_cli`` and each
191+
subcommand's Click wiring show up in coverage) and parametrizes over
192+
every command in ``COMMANDS``, including ``breakpoint-motifs`` and
193+
``interval-breakpoint-motifs`` which aren't covered above.
194+
"""
195+
196+
def test_top_level_help(self):
197+
result = CliRunner().invoke(main_cli, ["--help"])
198+
assert result.exit_code == 0, result.output
199+
200+
def test_version(self):
201+
result = CliRunner().invoke(main_cli, ["--version"])
202+
assert result.exit_code == 0, result.output
203+
assert "FinaleToolkit" in result.output
204+
205+
def test_no_args_shows_help(self):
206+
# A bare `finaletoolkit` invocation (Click's default no-subcommand
207+
# behavior for groups is `no_args_is_help`): prints help rather than
208+
# hanging or crashing. The exact exit code for this case isn't
209+
# stable across Click versions -- Click 8.1 returns 0, Click >=8.2
210+
# returns 2 -- so only the visible behavior (help shown) is checked.
211+
result = CliRunner().invoke(main_cli, [])
212+
assert result.exit_code in (0, 2), result.output
213+
assert "Usage" in result.output
214+
215+
def test_unknown_subcommand_fails_cleanly(self):
216+
result = CliRunner().invoke(main_cli, ["not-a-real-subcommand"])
217+
assert result.exit_code != 0
218+
assert "No such command" in result.output
219+
220+
@pytest.mark.parametrize("name", [command.name for command in COMMANDS])
221+
def test_subcommand_help(self, name: str):
222+
result = CliRunner().invoke(main_cli, [name, "--help"])
223+
assert result.exit_code == 0, result.output
224+
assert "Usage" in result.output

tests/test_dispatch.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""
2+
Tests for finaletoolkit.cli._dispatch
3+
4+
Covers the pure, CRAM-independent logic: strand-flag translation, the
5+
input-file-absent/BAM-without-reference fast paths through
6+
``_validate_inputs``, and ``run``'s param-filtering/dispatch mechanics. The
7+
CRAM+reference contig-validation branch (which needs real alignment/reference
8+
files) is already exercised by the filter-file tests in test_cli.py.
9+
"""
10+
11+
import pytest
12+
13+
from finaletoolkit.cli._dispatch import _translate_strand, _validate_inputs, run
14+
15+
16+
class TestTranslateStrand:
17+
def test_both(self):
18+
params = {"strand": "both"}
19+
_translate_strand(params)
20+
assert params == {"both_strands": True, "negative_strand": False}
21+
22+
def test_forward(self):
23+
params = {"strand": "forward"}
24+
_translate_strand(params)
25+
assert params == {"both_strands": False, "negative_strand": False}
26+
27+
def test_reverse(self):
28+
params = {"strand": "reverse"}
29+
_translate_strand(params)
30+
assert params == {"both_strands": False, "negative_strand": True}
31+
32+
def test_no_strand_key_is_a_no_op(self):
33+
params = {"other": 1}
34+
_translate_strand(params)
35+
assert params == {"other": 1}
36+
37+
38+
class TestValidateInputs:
39+
def test_no_input_file_is_a_no_op(self):
40+
_validate_inputs({})
41+
_validate_inputs({"input_file": None})
42+
_validate_inputs({"input_file": ""})
43+
44+
def test_cram_without_reference_exits(self):
45+
with pytest.raises(SystemExit) as exc_info:
46+
_validate_inputs({"input_file": "sample.cram"})
47+
assert exc_info.value.code == 1
48+
49+
def test_bam_without_reference_is_a_no_op(self):
50+
# A reference is optional for BAM; only CRAM requires one.
51+
_validate_inputs({"input_file": "sample.bam"})
52+
53+
def test_non_alignment_input_is_a_no_op(self):
54+
# Neither .bam nor .cram: the reference/contig-compatibility branch
55+
# doesn't apply regardless of whether a reference was given.
56+
_validate_inputs({"input_file": "sample.frag.gz", "reference_file": "ref.fa"})
57+
58+
59+
def _stub_add(a, b=10):
60+
return a + b
61+
62+
63+
def _stub_kwargs(**kwargs):
64+
return kwargs
65+
66+
67+
class TestRun:
68+
def test_filters_params_to_function_signature(self):
69+
result = run(
70+
__name__, "_stub_add", {"a": 1, "b": 2, "unrelated_cli_only_key": "x"}
71+
)
72+
assert result == 3
73+
74+
def test_uses_function_defaults_for_missing_params(self):
75+
result = run(__name__, "_stub_add", {"a": 5})
76+
assert result == 15
77+
78+
def test_varkw_function_receives_all_params_unfiltered(self):
79+
result = run(__name__, "_stub_kwargs", {"a": 1, "anything": "goes"})
80+
assert result == {"a": 1, "anything": "goes"}
81+
82+
def test_strand_translated_before_dispatch(self):
83+
result = run(
84+
__name__, "_stub_kwargs", {"strand": "reverse", "a": 1}
85+
)
86+
assert result == {"a": 1, "both_strands": False, "negative_strand": True}

tests/test_io_writers.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""
2+
Tests for finaletoolkit.io.writers
3+
"""
4+
5+
import gzip
6+
import sys
7+
8+
from finaletoolkit.io.writers import smart_open_text, is_stdout
9+
10+
11+
class TestIsStdout:
12+
def test_dash_is_stdout(self):
13+
assert is_stdout("-")
14+
15+
def test_path_is_not_stdout(self):
16+
assert not is_stdout("output.txt")
17+
18+
19+
class TestSmartOpenText:
20+
def test_writes_stdout(self, capsys):
21+
with smart_open_text("-") as f:
22+
assert f is sys.stdout
23+
f.write("hello\n")
24+
assert capsys.readouterr().out == "hello\n"
25+
26+
def test_stdout_not_closed_on_exit(self, capsys):
27+
with smart_open_text("-") as f:
28+
pass
29+
assert not sys.stdout.closed
30+
31+
def test_writes_plain_text_file(self, tmp_path):
32+
path = tmp_path / "out.txt"
33+
with smart_open_text(str(path)) as f:
34+
f.write("plain text\n")
35+
assert path.read_text() == "plain text\n"
36+
37+
def test_writes_gzip_file(self, tmp_path):
38+
path = tmp_path / "out.txt.gz"
39+
with smart_open_text(str(path)) as f:
40+
f.write("gzipped text\n")
41+
with gzip.open(path, "rt") as f:
42+
assert f.read() == "gzipped text\n"
43+
44+
def test_file_closed_on_exit(self, tmp_path):
45+
path = tmp_path / "out.txt"
46+
with smart_open_text(str(path)) as f:
47+
handle = f
48+
assert handle.closed
49+
50+
def test_file_closed_on_exception(self, tmp_path):
51+
path = tmp_path / "out.txt"
52+
handle = None
53+
try:
54+
with smart_open_text(str(path)) as f:
55+
handle = f
56+
raise ValueError("boom")
57+
except ValueError:
58+
pass
59+
assert handle.closed

tests/test_lazy_namespaces.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""
2+
Tests for the lazy attribute-resolution namespaces:
3+
``finaletoolkit`` (flat public API + submodules + aliases) and
4+
``finaletoolkit.cli`` (lazy ``main_cli`` import), both PEP 562 ``__getattr__``
5+
shims that keep ``import finaletoolkit`` cheap.
6+
"""
7+
8+
import pytest
9+
10+
import finaletoolkit
11+
12+
13+
class TestFlatNamespace:
14+
def test_submodule_lazy_import(self):
15+
import finaletoolkit.frag as frag_direct
16+
17+
assert finaletoolkit.frag is frag_direct
18+
19+
def test_flat_export_resolves_to_real_function(self):
20+
from finaletoolkit.frag import coverage as coverage_direct
21+
22+
assert finaletoolkit.coverage is coverage_direct
23+
24+
def test_flat_export_from_different_submodule(self):
25+
from finaletoolkit.genome import GenomeGaps as GenomeGaps_direct
26+
27+
assert finaletoolkit.GenomeGaps is GenomeGaps_direct
28+
29+
def test_alias_resolves_to_same_object_as_full_name(self):
30+
assert finaletoolkit.end_motif is finaletoolkit.end_motifs
31+
32+
def test_breakpoint_alias(self):
33+
assert finaletoolkit.breakpoint_motif is finaletoolkit.breakpoint_motifs
34+
35+
def test_unknown_attribute_raises(self):
36+
with pytest.raises(AttributeError, match="no attribute"):
37+
finaletoolkit.not_a_real_symbol
38+
39+
def test_dir_includes_flat_exports_and_submodules_and_aliases(self):
40+
names = dir(finaletoolkit)
41+
assert "coverage" in names
42+
assert "frag" in names
43+
assert "end_motif" in names
44+
assert "__version__" in names
45+
46+
47+
class TestCliLazyImport:
48+
def test_main_cli_lazy_import(self):
49+
# Calling __getattr__ directly (rather than via plain attribute
50+
# access, e.g. `cli.main_cli`) is deliberate: `main_cli` is both the
51+
# submodule's name and the Click group defined inside it, and once
52+
# anything else in the process imports the `main_cli` submodule
53+
# (as other test modules do), Python's ordinary import machinery
54+
# binds the *submodule* onto `finaletoolkit.cli.main_cli`, shadowing
55+
# whatever `__getattr__` would return -- exactly the ambiguity the
56+
# module's docstring calls out. Invoking the function isolates the
57+
# lazy-resolution logic itself from that import-order dependent
58+
# shadowing.
59+
import finaletoolkit.cli as cli
60+
from finaletoolkit.cli.main_cli import main_cli as main_cli_direct
61+
62+
assert cli.__getattr__("main_cli") is main_cli_direct
63+
64+
def test_unknown_attribute_raises(self):
65+
import finaletoolkit.cli as cli
66+
67+
with pytest.raises(AttributeError, match="no attribute"):
68+
cli.__getattr__("not_a_real_symbol")

tests/test_logging.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""
2+
Tests for finaletoolkit.utils.logging
3+
"""
4+
5+
import logging
6+
7+
from finaletoolkit.utils.logging import Logger, get_logger, set_verbosity
8+
9+
10+
class TestLogger:
11+
def test_get_logger_returns_logger(self):
12+
log = get_logger("finaletoolkit.test_logging.a")
13+
assert isinstance(log, Logger)
14+
15+
def test_handler_attached_once(self):
16+
# Constructing two Logger wrappers for the same name shouldn't
17+
# duplicate stderr handlers.
18+
name = "finaletoolkit.test_logging.b"
19+
first = Logger(name)
20+
second = Logger(name)
21+
assert len(first._logger.handlers) == 1
22+
assert first._logger is second._logger
23+
24+
def test_log_levels_write_to_stderr(self, capsys):
25+
# propagate=False keeps records off pytest's caplog handler, so
26+
# assert on the actual stderr stream the custom handler writes to.
27+
log = get_logger("finaletoolkit.test_logging.c", level=logging.DEBUG)
28+
log.debug("debug msg")
29+
log.info("info msg")
30+
log.warning("warning msg")
31+
log.error("error msg")
32+
log.critical("critical msg")
33+
err = capsys.readouterr().err
34+
for msg in ("debug msg", "info msg", "warning msg", "error msg", "critical msg"):
35+
assert msg in err
36+
37+
def test_default_level_filters_debug(self, capsys):
38+
log = get_logger("finaletoolkit.test_logging.c2")
39+
log.debug("should not appear")
40+
log.info("should appear")
41+
err = capsys.readouterr().err
42+
assert "should not appear" not in err
43+
assert "should appear" in err
44+
45+
def test_set_level_updates_logger_and_handlers(self):
46+
log = get_logger("finaletoolkit.test_logging.d")
47+
log.set_level(logging.ERROR)
48+
assert log._logger.level == logging.ERROR
49+
for handler in log._logger.handlers:
50+
assert handler.level == logging.ERROR
51+
52+
def test_set_verbosity_sets_parent_logger_level(self):
53+
set_verbosity(logging.WARNING)
54+
assert logging.getLogger("finaletoolkit").level == logging.WARNING
55+
# child loggers with no explicit level inherit from the parent
56+
set_verbosity(logging.INFO)
57+
assert logging.getLogger("finaletoolkit").level == logging.INFO

0 commit comments

Comments
 (0)