diff --git a/pyproject.toml b/pyproject.toml index 3c072e1..ad27566 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,5 +33,7 @@ dev = [ "click", "flit", "isort==5.13.2", - "agtools[test]" -] \ No newline at end of file + "pytest", + "pytest-cov", + "pytest-xdist", +] diff --git a/src/agtools/__init__.py b/src/agtools/__init__.py index b1840d7..aeeb54b 100644 --- a/src/agtools/__init__.py +++ b/src/agtools/__init__.py @@ -4,7 +4,7 @@ __copyright__ = "Copyright 2025, agtools Project" __credits__ = ["Vijini Mallawaarachchi"] __license__ = "MIT" -__version__ = "1.1.0" +__version__ = "1.1.1" __maintainer__ = "Vijini Mallawaarachchi" __email__ = "viji.mallawaarachchi@gmail.com" __status__ = "Production" diff --git a/src/agtools/cli.py b/src/agtools/cli.py index e004371..aeca66f 100644 --- a/src/agtools/cli.py +++ b/src/agtools/cli.py @@ -58,8 +58,8 @@ def main(): _output = click.option( "--output", "-o", - help="path to the output file", - type=click.Path(exists=False, dir_okay=False), + help="path to the output file, or '-' to write to stdout", + type=click.Path(exists=False, dir_okay=False, allow_dash=True), required=True, ) _log_file = click.option( @@ -382,7 +382,7 @@ def begin_agtools( ctx: click.Context, version: str, repo_url: str, log_file: str | None = None ): """Log version, repo, command name, and parameters of a subcommand run.""" - configure_logger(log_file) + configure_logger(log_file, use_stderr=ctx.params.get("output") == "-") logger.info("agtools: A Software Framework to Manipulate Assembly Graphs") logger.info(f"You are using agtools version {version}") if repo_url: diff --git a/src/agtools/commands/_output.py b/src/agtools/commands/_output.py index c82fb08..c4e3aff 100644 --- a/src/agtools/commands/_output.py +++ b/src/agtools/commands/_output.py @@ -1,6 +1,11 @@ #!/usr/bin/env python3 +import sys +from contextlib import contextmanager from pathlib import Path +from typing import Iterator, TextIO + +STDOUT_PATH = "-" def prepare_output_file(output_path: str) -> str: @@ -10,13 +15,43 @@ def prepare_output_file(output_path: str) -> str: Parameters ---------- output_path : str - Full output file path provided by the user. + Full output file path provided by the user. A single hyphen (`-`) + routes output to stdout. Returns ------- str - Normalized output file path as a string. + Normalized output file path as a string, or `-` for stdout. """ + if output_path == STDOUT_PATH: + return STDOUT_PATH + output_file = Path(output_path).expanduser() output_file.parent.mkdir(parents=True, exist_ok=True) return str(output_file) + + +@contextmanager +def open_output_file(output_path: str) -> Iterator[tuple[str, TextIO]]: + """ + Open an output destination for writing text. + + Parameters + ---------- + output_path : str + Full output file path provided by the user. A single hyphen (`-`) + routes output to stdout. + + Yields + ------ + tuple[str, TextIO] + The normalized output identifier and an open writable text handle. + """ + output_file = prepare_output_file(output_path) + + if output_file == STDOUT_PATH: + yield output_file, sys.stdout + return + + with open(output_file, "w") as output_handle: + yield output_file, output_handle diff --git a/src/agtools/commands/asqg2gfa.py b/src/agtools/commands/asqg2gfa.py index bb03b22..2f1b5c6 100644 --- a/src/agtools/commands/asqg2gfa.py +++ b/src/agtools/commands/asqg2gfa.py @@ -2,7 +2,7 @@ from agtools import __version__ from agtools.commands._format_checks import validate_asqg_input -from agtools.commands._output import prepare_output_file +from agtools.commands._output import open_output_file from agtools.log_config import logger __author__ = "Vijini Mallawaarachchi" @@ -193,9 +193,7 @@ def _write_gfa(segments, links, output_path): Path to the generated GFA file. """ - output_file = prepare_output_file(output_path) - - with open(output_file, "w") as gfa_file: + with open_output_file(output_path) as (output_file, gfa_file): # Write segments for seg_id, seq in segments.items(): diff --git a/src/agtools/commands/concat.py b/src/agtools/commands/concat.py index 1ff7ab5..d96655d 100644 --- a/src/agtools/commands/concat.py +++ b/src/agtools/commands/concat.py @@ -5,7 +5,7 @@ import tempfile from agtools import __version__ -from agtools.commands._output import prepare_output_file +from agtools.commands._output import open_output_file from agtools.log_config import logger __author__ = "Vijini Mallawaarachchi" @@ -44,8 +44,6 @@ def concat(graph_files: list, output_path: str) -> str: } other_lines = tempfile.NamedTemporaryFile(mode="w+", delete=False) - output_file = prepare_output_file(output_path) - segments = set() paths = set() walks = set() @@ -105,7 +103,7 @@ def concat(graph_files: list, output_path: str) -> str: other_lines.write(line_to_write) # Write to concatenated output - with open(output_file, "w") as out: + with open_output_file(output_path) as (output_file, out): # Write each tag group in order for tag in gfa_tags: tf = temp_files[tag] diff --git a/src/agtools/commands/fastg2gfa.py b/src/agtools/commands/fastg2gfa.py index 5375639..783167c 100644 --- a/src/agtools/commands/fastg2gfa.py +++ b/src/agtools/commands/fastg2gfa.py @@ -4,7 +4,7 @@ from agtools import __version__ from agtools.commands._format_checks import validate_fastg_input -from agtools.commands._output import prepare_output_file +from agtools.commands._output import open_output_file __author__ = "Vijini Mallawaarachchi" __copyright__ = "Copyright 2025, agtools Project" @@ -135,8 +135,7 @@ def _write_gfa( Full path to the written GFA file. """ - output_file = prepare_output_file(output_path) - with open(output_file, "w") as f: + with open_output_file(output_path) as (output_file, f): f.write("H\tVN:Z:1.0\n") for segment_id, sequence in segments.items(): f.write(f"S\t{segment_id}\t{sequence}\n") diff --git a/src/agtools/commands/gfa2adj.py b/src/agtools/commands/gfa2adj.py index d3718b8..6803c35 100644 --- a/src/agtools/commands/gfa2adj.py +++ b/src/agtools/commands/gfa2adj.py @@ -2,7 +2,7 @@ from agtools import __version__ from agtools.commands._format_checks import validate_gfa_input -from agtools.commands._output import prepare_output_file +from agtools.commands._output import open_output_file from agtools.core.unitig_graph import UnitigGraph from agtools.log_config import logger @@ -43,7 +43,7 @@ def gfa2adj(gfa_file: str, delimiter: str, output_path: str) -> str: adj_df = ug.get_adjacency_matrix(type="pandas") separator = "," if delimiter == "comma" else "\t" - output_file = prepare_output_file(output_path) - adj_df.to_csv(output_file, sep=separator) + with open_output_file(output_path) as (output_file, output_handle): + adj_df.to_csv(output_handle, sep=separator) return output_file diff --git a/src/agtools/commands/gfa2asqg.py b/src/agtools/commands/gfa2asqg.py index 42b0893..706fec6 100644 --- a/src/agtools/commands/gfa2asqg.py +++ b/src/agtools/commands/gfa2asqg.py @@ -4,7 +4,7 @@ from agtools import __version__ from agtools.commands._format_checks import validate_gfa_input -from agtools.commands._output import prepare_output_file +from agtools.commands._output import open_output_file from agtools.log_config import logger __author__ = "Vijini Mallawaarachchi" @@ -165,10 +165,9 @@ def _write_asqg(segments: dict[str, str], edges: list[list], output_path: str) - Path to the generated ASQG file. """ - output_file = prepare_output_file(output_path) min_overlap = min((edge[3] - edge[2] + 1 for edge in edges), default=0) - with open(output_file, "w") as asqg_file: + with open_output_file(output_path) as (output_file, asqg_file): asqg_file.write(f"HT\tVN:i:1\tER:f:0\tOL:i:{min_overlap}\tCN:i:0\tTE:i:0\n") for segment_id, sequence in segments.items(): diff --git a/src/agtools/commands/gfa2dot.py b/src/agtools/commands/gfa2dot.py index 3201377..5dadb82 100644 --- a/src/agtools/commands/gfa2dot.py +++ b/src/agtools/commands/gfa2dot.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 from agtools.commands._format_checks import validate_gfa_input -from agtools.commands._output import prepare_output_file +from agtools.commands._output import open_output_file from agtools.core.unitig_graph import UnitigGraph @@ -27,9 +27,7 @@ def _write_abyss_dot(graph, output_path): [https://github.com/bcgsc/abyss/wiki/ABySS-File-Formats#dot](https://github.com/bcgsc/abyss/wiki/ABySS-File-Formats#dot) """ - output_file = prepare_output_file(output_path) - - with open(output_file, "w") as f: + with open_output_file(output_path) as (output_file, f): f.write(f"digraph g {{\n") @@ -71,8 +69,8 @@ def _write_dot(graph, output_path): Full path to the generated DOT file. """ - output_file = prepare_output_file(output_path) - graph.graph.write_dot(output_file) + with open_output_file(output_path) as (output_file, output_handle): + graph.graph.write_dot(output_handle) return output_file diff --git a/src/agtools/commands/gfa2fasta.py b/src/agtools/commands/gfa2fasta.py index 44fb668..70c4144 100644 --- a/src/agtools/commands/gfa2fasta.py +++ b/src/agtools/commands/gfa2fasta.py @@ -8,7 +8,7 @@ from agtools import __version__ from agtools.commands._format_checks import validate_gfa_input -from agtools.commands._output import prepare_output_file +from agtools.commands._output import open_output_file __author__ = "Vijini Mallawaarachchi" __copyright__ = "Copyright 2025, agtools Project" @@ -78,8 +78,7 @@ def _write_segment_sequences(sequences: list, output_path: str) -> str: str Path to the output FASTA file. """ - output_file = prepare_output_file(output_path) - with open(f"{output_file}", "w") as output_handle: + with open_output_file(output_path) as (output_file, output_handle): SeqIO.write(sequences, output_handle, "fasta") return output_file diff --git a/src/agtools/commands/gfa2fastg.py b/src/agtools/commands/gfa2fastg.py index 841ab66..978b12d 100644 --- a/src/agtools/commands/gfa2fastg.py +++ b/src/agtools/commands/gfa2fastg.py @@ -6,7 +6,7 @@ from agtools import __version__ from agtools.commands._format_checks import validate_gfa_input -from agtools.commands._output import prepare_output_file +from agtools.commands._output import open_output_file __author__ = "Vijini Mallawaarachchi" __copyright__ = "Copyright 2025, agtools Project" @@ -129,9 +129,7 @@ def _write_fastg( Full path to the written FASTG file. """ - output_file = prepare_output_file(output_path) - - with open(output_file, "w") as file: + with open_output_file(output_path) as (output_file, file): for segment_id, sequence in sequences.items(): for orient in ("+", "-"): node = f"{segment_id}{orient}" diff --git a/src/agtools/commands/rename.py b/src/agtools/commands/rename.py index 65caa93..9ec044c 100644 --- a/src/agtools/commands/rename.py +++ b/src/agtools/commands/rename.py @@ -3,7 +3,7 @@ import re from agtools import __version__ -from agtools.commands._output import prepare_output_file +from agtools.commands._output import open_output_file __author__ = "Vijini Mallawaarachchi" __copyright__ = "Copyright 2025, agtools Project" @@ -117,10 +117,14 @@ def _write_renamed_file( Path to the renamed GFA file. """ - output_file = prepare_output_file(output_path) - # Rewrite file with renamed segment IDs - with open(input_gfa, "r") as infile, open(output_file, "w") as outfile: + with ( + open(input_gfa, "r") as infile, + open_output_file(output_path) as ( + output_file, + outfile, + ), + ): for line in infile: parts = line.strip().split("\t") tag = parts[0] @@ -137,19 +141,21 @@ def _write_renamed_file( elif tag == "P": parts[1] = _remap_element(parts[1], path_map) path_path = parts[2] - segments = re.split(r"([,;])", path_path) - segments = [ - _remap_element(s[:-1], segment_map) + s[-1] for s in segments + path_segments = re.split(r"([,;])", path_path) + remapped_path_segments = [ + _remap_element(s[:-1], segment_map) + s[-1] for s in path_segments ] - parts[2] = "".join(segments) + parts[2] = "".join(remapped_path_segments) outfile.write("\t".join(parts) + "\n") elif tag == "W": parts[1] = _remap_element(parts[1], walk_map) walk_path = parts[-1] - segments = re.split(r"([><])", walk_path) - segments = [_remap_element(s, segment_map) for s in segments] - parts[-1] = "".join(segments) + walk_segments = re.split(r"([><])", walk_path) + remapped_walk_segments = [ + _remap_element(s, segment_map) for s in walk_segments + ] + parts[-1] = "".join(remapped_walk_segments) outfile.write("\t".join(parts) + "\n") else: diff --git a/src/agtools/commands/stats.py b/src/agtools/commands/stats.py index 6e2f9e9..6699c5a 100644 --- a/src/agtools/commands/stats.py +++ b/src/agtools/commands/stats.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 from agtools import __version__ -from agtools.commands._output import prepare_output_file +from agtools.commands._output import open_output_file from agtools.core.unitig_graph import UnitigGraph from agtools.log_config import logger @@ -33,9 +33,7 @@ def _write_stats_file(gfa_file: str, stats: dict, output_path: str) -> str: str Path to the written statistics file. """ - output_file = prepare_output_file(output_path) - - with open(output_file, "w") as f: + with open_output_file(output_path) as (output_file, f): # Write basic graph statistics f.write(f"Basic graph statistics for {gfa_file}:\n") f.write(f"Number of segments: {stats['nsegments']}\n") diff --git a/src/agtools/core/gfa_filter.py b/src/agtools/core/gfa_filter.py index edd0de7..7150802 100644 --- a/src/agtools/core/gfa_filter.py +++ b/src/agtools/core/gfa_filter.py @@ -3,6 +3,8 @@ import re from collections.abc import Callable +from agtools.commands._output import open_output_file + def parse_path_segment_ids(path_field: str) -> list[str]: """ @@ -51,7 +53,13 @@ def write_filtered_gfa( - others: copied unchanged """ - with open(gfa_file, "r") as gfa, open(output_file, "w") as filtered_gfa: + with ( + open(gfa_file, "r") as gfa, + open_output_file(output_file) as ( + _, + filtered_gfa, + ), + ): for line in gfa: parts = line.rstrip("\n").split("\t") if not parts or parts == [""]: diff --git a/src/agtools/log_config.py b/src/agtools/log_config.py index 8a1bb29..50339d6 100644 --- a/src/agtools/log_config.py +++ b/src/agtools/log_config.py @@ -17,17 +17,21 @@ __status__ = "Production" -def configure_logger(log_file: Optional[str] = None): +def configure_logger(log_file: Optional[str] = None, use_stderr: bool = False): """Configure agtools logging sinks. By default, logs are emitted to stdout only. If ``log_file`` is - provided, DEBUG-level logs are also written to that file. + provided, logs are written to that file instead. When command output + is routed to stdout, ``use_stderr`` can be enabled to keep logs on + stderr. """ logger.remove() - logger.add(sink=sys.stdout, level="INFO") - if log_file: logger.add(sink=log_file, level="DEBUG") + elif use_stderr: + logger.add(sink=sys.stderr, level="INFO") + else: + logger.add(sink=sys.stdout, level="INFO") # Default configuration for library/programmatic usage: console logs only. diff --git a/tests/test_cli.py b/tests/test_cli.py index 4f7ffed..f548737 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -286,3 +286,26 @@ def test_agtools_log_file_is_optional(runner, tmp_dir): r = runner.invoke(gfa2adj, args, catch_exceptions=False) assert r.exit_code == 0, r.output assert log_file.exists() + + +def test_agtools_log_file_keeps_stdout_clean(runner, tmp_dir): + graph = DATADIR / "test_graph.gfa" + log_file = tmp_dir / "stdout-clean.log" + args = f"-g {graph} -o - --log-file {log_file}".split() + r = runner.invoke(gfa2adj, args, catch_exceptions=False) + assert r.exit_code == 0, r.output + assert ",seg1,seg2" in r.output + assert "agtools:" not in r.output + assert " | INFO | " not in r.output + assert log_file.exists() + + +def test_agtools_stdout_output_routes_logs_to_stderr(tmp_dir): + runner = CliRunner(mix_stderr=False) + graph = DATADIR / "test_graph.gfa" + args = f"-g {graph} -o -".split() + r = runner.invoke(gfa2adj, args, catch_exceptions=False) + assert r.exit_code == 0, r.output + assert ",seg1,seg2" in r.output + assert "agtools:" not in r.output + assert "agtools:" in r.stderr diff --git a/tests/test_commands_outputs_unit.py b/tests/test_commands_outputs_unit.py index 7c16f47..4132a0c 100644 --- a/tests/test_commands_outputs_unit.py +++ b/tests/test_commands_outputs_unit.py @@ -45,6 +45,18 @@ def test_stats_output_file_content(tmp_path): assert "GC content: 25.00%" in content +def test_stats_output_stdout(tmp_path, capsys): + gfa_file = tmp_path / "graph.gfa" + _write_base_gfa(gfa_file) + + output_file = stats(str(gfa_file), "-") + stdout = capsys.readouterr().out + + assert output_file == "-" + assert "Basic graph statistics for" in stdout + assert "Number of segments: 3" in stdout + + def test_rename_output_file_content(tmp_path): gfa_file = tmp_path / "graph.gfa" _write_base_gfa(gfa_file) @@ -94,13 +106,22 @@ def test_filter_output_file_content(tmp_path): assert "W\tw1\t*\t*\t*\t>s1s1\nAAAA\n") @@ -174,7 +195,9 @@ def test_asqg2gfa_output_file_content(tmp_path): def test_gfa2asqg_output_file_content(tmp_path): gfa_file = tmp_path / "graph.gfa" - gfa_file.write_text("S\tcontig1\tAAAA\nS\tcontig2\tCCCC\nL\tcontig1\t+\tcontig2\t-\t3M\n") + gfa_file.write_text( + "S\tcontig1\tAAAA\nS\tcontig2\tCCCC\nL\tcontig1\t+\tcontig2\t-\t3M\n" + ) target = tmp_path / "converted_graph.asqg" output_file = gfa2asqg(str(gfa_file), str(target)) @@ -217,6 +240,19 @@ def test_gfa2fasta_output_file_content(tmp_path): assert "GGTT" in content +def test_gfa2fasta_output_stdout(tmp_path, capsys): + gfa_file = tmp_path / "graph.gfa" + gfa_file.write_text("S\ts1\tatgcn-\nS\ts2\tGGTT\n") + + output_file = gfa2fasta(str(gfa_file), "-") + stdout = capsys.readouterr().out + + assert output_file == "-" + assert ">s1" in stdout + assert "ATGC" in stdout + assert ">s2" in stdout + + def test_gfa2adj_output_file_content(tmp_path): gfa_file = tmp_path / "graph.gfa" _write_base_gfa(gfa_file) @@ -230,3 +266,15 @@ def test_gfa2adj_output_file_content(tmp_path): assert any(row.startswith("s1,0,1,0") for row in rows[1:]) assert any(row.startswith("s2,1,0,0") for row in rows[1:]) assert any(row.startswith("s3,0,0,0") for row in rows[1:]) + + +def test_gfa2adj_output_stdout(tmp_path, capsys): + gfa_file = tmp_path / "graph.gfa" + _write_base_gfa(gfa_file) + + output_file = gfa2adj(str(gfa_file), delimiter="comma", output_path="-") + stdout = capsys.readouterr().out + + assert output_file == "-" + assert ",s1,s2,s3" in stdout + assert "s1,0,1,0" in stdout diff --git a/tests/test_gfa2dot_unit.py b/tests/test_gfa2dot_unit.py index f66eeff..26e610b 100644 --- a/tests/test_gfa2dot_unit.py +++ b/tests/test_gfa2dot_unit.py @@ -32,12 +32,11 @@ def test_write_abyss_dot_emits_nodes_and_links(tmp_path): def test_write_dot_delegates_to_igraph_writer(tmp_path): class DummyInnerGraph: def __init__(self): - self.called_path = None + self.called_handle = None - def write_dot(self, path): - self.called_path = path - with open(path, "w") as f: - f.write("graph {}") + def write_dot(self, handle): + self.called_handle = handle + handle.write("graph {}") inner_graph = DummyInnerGraph() wrapper = types.SimpleNamespace(graph=inner_graph) @@ -46,10 +45,24 @@ def write_dot(self, path): output_file = _write_dot(wrapper, str(target)) assert output_file == str(target) - assert inner_graph.called_path == str(target) + assert inner_graph.called_handle is not None assert target.read_text() == "graph {}" +def test_write_dot_supports_stdout(capsys): + class DummyInnerGraph: + def write_dot(self, handle): + handle.write("graph {}") + + wrapper = types.SimpleNamespace(graph=DummyInnerGraph()) + + output_file = _write_dot(wrapper, "-") + stdout = capsys.readouterr().out + + assert output_file == "-" + assert stdout == "graph {}" + + def test_gfa2dot_selects_writer_based_on_flag(tmp_path, monkeypatch): dummy_ug = object() calls = [] @@ -77,8 +90,7 @@ def fake_dot(graph, output_path): == "abyss-output" ) assert ( - gfa2dot("graph.gfa", abyss=False, output_path=str(dot_target)) - == "dot-output" + gfa2dot("graph.gfa", abyss=False, output_path=str(dot_target)) == "dot-output" ) assert calls == [ ("abyss", dummy_ug, str(abyss_target)), diff --git a/tests/test_log_config_unit.py b/tests/test_log_config_unit.py index 750b42e..9bbdbc2 100644 --- a/tests/test_log_config_unit.py +++ b/tests/test_log_config_unit.py @@ -36,6 +36,20 @@ def test_configure_logger_adds_file_sink_when_requested(monkeypatch, tmp_path): assert calls == [ ("remove",), - ("add", sys.stdout, "INFO"), ("add", str(log_file), "DEBUG"), ] + + +def test_configure_logger_can_add_stderr_sink(monkeypatch): + calls = [] + + monkeypatch.setattr(log_config_module.logger, "remove", lambda: calls.append(("remove",))) + monkeypatch.setattr( + log_config_module.logger, + "add", + lambda sink, level: calls.append(("add", sink, level)), + ) + + configure_logger(use_stderr=True) + + assert calls == [("remove",), ("add", sys.stderr, "INFO")] diff --git a/tests/test_output_helpers_unit.py b/tests/test_output_helpers_unit.py index 1ba8039..056d8be 100644 --- a/tests/test_output_helpers_unit.py +++ b/tests/test_output_helpers_unit.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 -from agtools.commands._output import prepare_output_file +import io + +from agtools.commands._output import open_output_file, prepare_output_file def test_prepare_output_file_creates_parent_directories_and_expands_user( @@ -12,3 +14,18 @@ def test_prepare_output_file_creates_parent_directories_and_expands_user( assert output_file == str(tmp_path / "nested" / "outputs" / "result.txt") assert (tmp_path / "nested" / "outputs").is_dir() + + +def test_prepare_output_file_preserves_stdout_marker(): + assert prepare_output_file("-") == "-" + + +def test_open_output_file_uses_stdout_for_dash(monkeypatch): + stdout = io.StringIO() + monkeypatch.setattr("sys.stdout", stdout) + + with open_output_file("-") as (output_file, output_handle): + output_handle.write("hello stdout") + + assert output_file == "-" + assert stdout.getvalue() == "hello stdout"