From 02a06666543bc98ccf5033e6f6464788e0004997 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Jun 2026 05:58:54 +0000 Subject: [PATCH 1/9] Implement stdout output target --- src/agtools/cli.py | 4 +-- src/agtools/commands/_output.py | 39 +++++++++++++++++++++-- src/agtools/commands/asqg2gfa.py | 6 ++-- src/agtools/commands/concat.py | 6 ++-- src/agtools/commands/fastg2gfa.py | 5 ++- src/agtools/commands/gfa2adj.py | 6 ++-- src/agtools/commands/gfa2asqg.py | 5 ++- src/agtools/commands/gfa2dot.py | 10 +++--- src/agtools/commands/gfa2fasta.py | 5 ++- src/agtools/commands/gfa2fastg.py | 6 ++-- src/agtools/commands/rename.py | 18 ++++++----- src/agtools/commands/stats.py | 6 ++-- src/agtools/core/gfa_filter.py | 7 ++++- tests/test_commands_outputs_unit.py | 49 +++++++++++++++++++++++++++++ tests/test_gfa2dot_unit.py | 25 +++++++++++---- tests/test_output_helpers_unit.py | 19 ++++++++++- 16 files changed, 162 insertions(+), 54 deletions(-) diff --git a/src/agtools/cli.py b/src/agtools/cli.py index e004371..f380119 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( 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..b1f474e 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,11 @@ 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,9 +138,9 @@ 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 + _remap_element(s[:-1], segment_map) + s[-1] + for s in re.split(r"([,;])", path_path) ] parts[2] = "".join(segments) outfile.write("\t".join(parts) + "\n") @@ -147,8 +148,9 @@ def _write_renamed_file( 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] + segments = [ + _remap_element(s, segment_map) for s in re.split(r"([><])", walk_path) + ] parts[-1] = "".join(segments) outfile.write("\t".join(parts) + "\n") 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..4921869 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,10 @@ 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/tests/test_commands_outputs_unit.py b/tests/test_commands_outputs_unit.py index 7c16f47..3b73c82 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,6 +106,18 @@ def test_filter_output_file_content(tmp_path): assert "W\tw1\t*\t*\t*\t>s1s1" 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 +267,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..c142d6a 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 = [] 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" From 0f3d908fb0eaf8687251701123bdd28c49258277 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Jun 2026 06:00:28 +0000 Subject: [PATCH 2/9] Polish stdout output tests --- src/agtools/commands/rename.py | 12 ++++++++---- src/agtools/core/gfa_filter.py | 9 ++++++--- tests/test_commands_outputs_unit.py | 9 ++++----- tests/test_gfa2dot_unit.py | 3 +-- 4 files changed, 19 insertions(+), 14 deletions(-) diff --git a/src/agtools/commands/rename.py b/src/agtools/commands/rename.py index b1f474e..5d5a91b 100644 --- a/src/agtools/commands/rename.py +++ b/src/agtools/commands/rename.py @@ -118,9 +118,12 @@ def _write_renamed_file( """ # Rewrite file with renamed segment IDs - with open(input_gfa, "r") as infile, open_output_file(output_path) as ( - output_file, - 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") @@ -149,7 +152,8 @@ def _write_renamed_file( parts[1] = _remap_element(parts[1], walk_map) walk_path = parts[-1] segments = [ - _remap_element(s, segment_map) for s in re.split(r"([><])", walk_path) + _remap_element(s, segment_map) + for s in re.split(r"([><])", walk_path) ] parts[-1] = "".join(segments) outfile.write("\t".join(parts) + "\n") diff --git a/src/agtools/core/gfa_filter.py b/src/agtools/core/gfa_filter.py index 4921869..7150802 100644 --- a/src/agtools/core/gfa_filter.py +++ b/src/agtools/core/gfa_filter.py @@ -53,9 +53,12 @@ def write_filtered_gfa( - others: copied unchanged """ - with open(gfa_file, "r") as gfa, open_output_file(output_file) 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") diff --git a/tests/test_commands_outputs_unit.py b/tests/test_commands_outputs_unit.py index 3b73c82..4132a0c 100644 --- a/tests/test_commands_outputs_unit.py +++ b/tests/test_commands_outputs_unit.py @@ -121,10 +121,7 @@ def test_filter_output_stdout(tmp_path, capsys): def test_clean_output_file_content(tmp_path): gfa_file = tmp_path / "graph.gfa" gfa_file.write_text( - "S\ts1\t\tLN:i:4\n" - "S\ts2\tCC\n" - "L\ts1\t+\ts2\t+\t1M\n" - "P\tp1\ts1+,s2+\t*\n" + "S\ts1\t\tLN:i:4\n" "S\ts2\tCC\n" "L\ts1\t+\ts2\t+\t1M\n" "P\tp1\ts1+,s2+\t*\n" ) fasta_file = tmp_path / "contigs.fasta" fasta_file.write_text(">s1\nAAAA\n") @@ -198,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)) diff --git a/tests/test_gfa2dot_unit.py b/tests/test_gfa2dot_unit.py index c142d6a..26e610b 100644 --- a/tests/test_gfa2dot_unit.py +++ b/tests/test_gfa2dot_unit.py @@ -90,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)), From 8fe8676891bf92a344dc00c2e26ed1ccc7cde194 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Jun 2026 06:01:54 +0000 Subject: [PATCH 3/9] Refine rename stdout path handling --- src/agtools/commands/rename.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/agtools/commands/rename.py b/src/agtools/commands/rename.py index 5d5a91b..bb8bc35 100644 --- a/src/agtools/commands/rename.py +++ b/src/agtools/commands/rename.py @@ -141,9 +141,9 @@ def _write_renamed_file( elif tag == "P": parts[1] = _remap_element(parts[1], path_map) path_path = parts[2] + path_segments = re.split(r"([,;])", path_path) segments = [ - _remap_element(s[:-1], segment_map) + s[-1] - for s in re.split(r"([,;])", path_path) + _remap_element(s[:-1], segment_map) + s[-1] for s in path_segments ] parts[2] = "".join(segments) outfile.write("\t".join(parts) + "\n") @@ -151,10 +151,8 @@ def _write_renamed_file( elif tag == "W": parts[1] = _remap_element(parts[1], walk_map) walk_path = parts[-1] - segments = [ - _remap_element(s, segment_map) - for s in re.split(r"([><])", walk_path) - ] + walk_segments = re.split(r"([><])", walk_path) + segments = [_remap_element(s, segment_map) for s in walk_segments] parts[-1] = "".join(segments) outfile.write("\t".join(parts) + "\n") From 4b3222abdd34221229b66baf3131a237f18540a2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Jun 2026 06:02:53 +0000 Subject: [PATCH 4/9] Clarify rename remapping variables --- src/agtools/commands/rename.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/agtools/commands/rename.py b/src/agtools/commands/rename.py index bb8bc35..9ec044c 100644 --- a/src/agtools/commands/rename.py +++ b/src/agtools/commands/rename.py @@ -142,18 +142,20 @@ def _write_renamed_file( parts[1] = _remap_element(parts[1], path_map) path_path = parts[2] path_segments = re.split(r"([,;])", path_path) - segments = [ + 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] walk_segments = re.split(r"([><])", walk_path) - segments = [_remap_element(s, segment_map) for s in walk_segments] - parts[-1] = "".join(segments) + 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: From 1f489a53d18e58b9b8d4f0c128ce8eac2fd55852 Mon Sep 17 00:00:00 2001 From: linsalrob Date: Wed, 17 Jun 2026 14:32:12 +0800 Subject: [PATCH 5/9] removing circular install which installed agtools[test] before installing agtools --- pyproject.toml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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", +] From ab50d4a91dda91c5504970de0d87940561738d3e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Jun 2026 06:47:15 +0000 Subject: [PATCH 6/9] Keep logs out of stdout when log file is set --- src/agtools/log_config.py | 7 ++++--- tests/test_cli.py | 12 ++++++++++++ tests/test_log_config_unit.py | 1 - 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/agtools/log_config.py b/src/agtools/log_config.py index 8a1bb29..024a86e 100644 --- a/src/agtools/log_config.py +++ b/src/agtools/log_config.py @@ -21,13 +21,14 @@ def configure_logger(log_file: Optional[str] = None): """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 so command output + can be cleanly redirected from stdout. """ logger.remove() - logger.add(sink=sys.stdout, level="INFO") - if log_file: logger.add(sink=log_file, level="DEBUG") + 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..cf7bfd4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -286,3 +286,15 @@ 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() diff --git a/tests/test_log_config_unit.py b/tests/test_log_config_unit.py index 750b42e..3e95f17 100644 --- a/tests/test_log_config_unit.py +++ b/tests/test_log_config_unit.py @@ -36,6 +36,5 @@ def test_configure_logger_adds_file_sink_when_requested(monkeypatch, tmp_path): assert calls == [ ("remove",), - ("add", sys.stdout, "INFO"), ("add", str(log_file), "DEBUG"), ] From 2670175872c9103941ecd82cbf1662643fc3a5d0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Jun 2026 07:03:06 +0000 Subject: [PATCH 7/9] Route stdout command logs to stderr --- src/agtools/cli.py | 2 +- src/agtools/log_config.py | 9 ++++++--- tests/test_cli.py | 11 +++++++++++ tests/test_log_config_unit.py | 15 +++++++++++++++ 4 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/agtools/cli.py b/src/agtools/cli.py index f380119..aeca66f 100644 --- a/src/agtools/cli.py +++ b/src/agtools/cli.py @@ -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/log_config.py b/src/agtools/log_config.py index 024a86e..50339d6 100644 --- a/src/agtools/log_config.py +++ b/src/agtools/log_config.py @@ -17,16 +17,19 @@ __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, logs are written to that file instead so command output - can be cleanly redirected from stdout. + 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() 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") diff --git a/tests/test_cli.py b/tests/test_cli.py index cf7bfd4..f548737 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -298,3 +298,14 @@ def test_agtools_log_file_keeps_stdout_clean(runner, tmp_dir): 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_log_config_unit.py b/tests/test_log_config_unit.py index 3e95f17..9bbdbc2 100644 --- a/tests/test_log_config_unit.py +++ b/tests/test_log_config_unit.py @@ -38,3 +38,18 @@ def test_configure_logger_adds_file_sink_when_requested(monkeypatch, tmp_path): ("remove",), ("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")] From cd05c78c0105e3043b9347772f6c7f1678ed0db0 Mon Sep 17 00:00:00 2001 From: linsalrob Date: Wed, 17 Jun 2026 15:11:01 +0800 Subject: [PATCH 8/9] version bump --- src/agtools/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From cf4cb1a720a5936a511c8be9a7f68f96bffb8521 Mon Sep 17 00:00:00 2001 From: Vijini Mallawaarachchi Date: Wed, 17 Jun 2026 20:42:36 +0930 Subject: [PATCH 9/9] TST: add fix for CI run to pass --- tests/test_cli.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index f548737..6c3638b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 +import inspect import pathlib import pytest @@ -36,6 +37,12 @@ def runner(): """exportrc works correctly.""" return CliRunner() +def make_cli_runner(): + kwargs = {} + if "mix_stderr" in inspect.signature(CliRunner).parameters: + kwargs["mix_stderr"] = False + return CliRunner(**kwargs) + def test_agtools_stats(runner, tmp_dir): outpath = tmp_dir / "stats" / "graph_stats.txt" @@ -301,11 +308,11 @@ def test_agtools_log_file_keeps_stdout_clean(runner, tmp_dir): def test_agtools_stdout_output_routes_logs_to_stderr(tmp_dir): - runner = CliRunner(mix_stderr=False) + runner = make_cli_runner() 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 ",seg1,seg2" in r.stdout + assert "agtools:" not in r.stdout assert "agtools:" in r.stderr