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
6 changes: 4 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,7 @@ dev = [
"click",
"flit",
"isort==5.13.2",
"agtools[test]"
]
"pytest",
"pytest-cov",
"pytest-xdist",
]
2 changes: 1 addition & 1 deletion src/agtools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
6 changes: 3 additions & 3 deletions src/agtools/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down
39 changes: 37 additions & 2 deletions src/agtools/commands/_output.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
6 changes: 2 additions & 4 deletions src/agtools/commands/asqg2gfa.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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():
Expand Down
6 changes: 2 additions & 4 deletions src/agtools/commands/concat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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]
Expand Down
5 changes: 2 additions & 3 deletions src/agtools/commands/fastg2gfa.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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")
Expand Down
6 changes: 3 additions & 3 deletions src/agtools/commands/gfa2adj.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
5 changes: 2 additions & 3 deletions src/agtools/commands/gfa2asqg.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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():
Expand Down
10 changes: 4 additions & 6 deletions src/agtools/commands/gfa2dot.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -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")

Expand Down Expand Up @@ -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


Expand Down
5 changes: 2 additions & 3 deletions src/agtools/commands/gfa2fasta.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
6 changes: 2 additions & 4 deletions src/agtools/commands/gfa2fastg.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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}"
Expand Down
28 changes: 17 additions & 11 deletions src/agtools/commands/rename.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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]
Expand All @@ -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:
Expand Down
6 changes: 2 additions & 4 deletions src/agtools/commands/stats.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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")
Expand Down
10 changes: 9 additions & 1 deletion src/agtools/core/gfa_filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
"""
Expand Down Expand Up @@ -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 == [""]:
Expand Down
12 changes: 8 additions & 4 deletions src/agtools/log_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading