Skip to content
Open
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
8 changes: 8 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# History

1.8.5 (2026-09-07)
------------------

* Concatenates reads and contigs by block copy rather than round-tripping every record through BioPython. `concatenate_single_fastq` parsed both inputs into a list of `SeqRecord`s and wrote them out again, which costs roughly 5-10x the file size in RAM; on 250 MiB of short reads that was 27s and 1.8 GB of peak RSS, and is now 0.7s and 46 MB. Only the non-chromosomal short reads pass through here, so the saving is a transient GB or two rather than the whole run - but it landed immediately before Unicycler, which needs the memory itself. `Plass.get_depth_long` no longer builds its own list of records either. Thanks @[sanjaynagi-eit](https://github.com/sanjaynagi-eit) ([#90](https://github.com/gbouras13/plassembler/pull/90))
* A wrong-format input is now caught by checking the first byte against the format's record marker (`@` or `>`) instead of by parsing every record, keeping that check at O(1) instead of O(file). One consequence: a truncated *uncompressed* FASTQ is no longer rejected, where full parsing used to raise. Truncated `.gz` input still fails, because the decompressor raises before the copy finishes
* Concatenation now writes to a `.tmp` sibling and renames it into place. A wrong-format second input, a truncated gzip or a full disk can no longer leave a half-written FASTQ where the run expects a complete one, and a failed re-run leaves any previous output intact
* An empty `chromosome.fasta` reaching `Assembly.combine_input_fastas` is now a fatal error with a message explaining it. `list(SeqIO.parse(...))[0]` used to raise `IndexError` there; streaming the records has no equivalent, and without an explicit check every depth and copy number would have been computed against a `combined.fasta` containing no chromosome

1.8.4 (2026-08-18)
------------------

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "plassembler"
version = "1.8.4"
version = "1.8.5"
description = "Quickly and accurately assemble plasmids in hybrid sequenced bacterial isolates"
authors = [
{ name = "George Bouras", email = "george.bouras@adelaide.edu.au" }
Expand Down
122 changes: 85 additions & 37 deletions src/plassembler/utils/concat.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import gzip
import os
from pathlib import Path

from Bio import SeqIO
from loguru import logger

# copied between files in 1 MiB blocks
COPY_BLOCK = 1024 * 1024


def concatenate_short_fastqs(out_dir):
"""moves and copies files
Expand Down Expand Up @@ -32,49 +35,94 @@ def concatenate_short_fastqs(out_dir):
logger.error("Error with concatenate_fastqs\n")


def _append_file(source: Path, out_handle, record_marker: bytes):
"""Append source to an open binary handle, decompressing it if gzipped.

Copies in fixed blocks, so memory does not depend on file size, and ensures
the appended block ends with a newline: without that, a source whose final
line is unterminated would run into the next file's first header.

A non-empty file must start with record_marker (b"@" for fastq, b">" for
fasta). Parsing every record through BioPython used to catch a wrong-format
input; this keeps that check at O(1) instead of O(file).

:param source: file to append
:param out_handle: destination, opened in binary mode
:param record_marker: first byte every record of this format starts with
:raises ValueError: if source is non-empty and does not start with the marker
"""
opener = gzip.open if Path(source).suffix == ".gz" else open
last_byte = b""
first = True
with opener(source, "rb") as in_handle:
while True:
block = in_handle.read(COPY_BLOCK)
if not block:
break
if first:
if block[:1] != record_marker:
raise ValueError(
f"{source} does not look like a "
f"{record_marker.decode()}-delimited file: it starts with "
f"{block[:1]!r}"
)
first = False
out_handle.write(block)
last_byte = block[-1:]
# an empty file contributes nothing, which is normal (e.g. no unmapped reads)
if last_byte and last_byte != b"\n":
out_handle.write(b"\n")


def _concatenate(sources, out_path, record_marker: bytes):
"""Block-copy sources into out_path, leaving no output behind on failure.

The copy goes to a sibling .tmp and is renamed into place only once every
source has been read, so a wrong-format second input, a truncated gzip or a
full disk cannot leave a half-written FASTQ/FASTA where the run expects a
complete one. The SeqIO version got this for free by parsing everything
before it opened the output.

:param sources: files to copy, in order
:param out_path: destination path
:param record_marker: first byte every record of this format starts with
"""
out_path = Path(out_path)
tmp_path = out_path.with_name(f"{out_path.name}.tmp")
try:
with open(tmp_path, "wb") as out_handle:
for source in sources:
_append_file(source, out_handle, record_marker)
except BaseException:
tmp_path.unlink(missing_ok=True)
raise
# same directory, so this is an atomic replace rather than a copy
os.replace(tmp_path, out_path)


def concatenate_single_fastq(fastq_in1: Path, fastq_in2: Path, fastq_out: Path):
"""concatenates 2 fastq files

Concatenating reads needs no parsing. Round-tripping them through
SeqIO.parse into a list of SeqRecords cost roughly 5-10x the file size in
RAM. Only the non-chromosomal short reads pass through here, so that is a
transient spike of a GB or two on a typical isolate rather than the whole
run - but it landed immediately before Unicycler, which needs the memory
itself. A block copy is constant-memory and far faster.

:param fastq_in1: fastq_in1 input fastq 1
:param fastq_in2: fastq_in1 input fastq 2
:param fastq_out: fastq_out output fastq 2
:param logger: logger
:return:
"""

records = []

# Read and append records from the first FASTQ file
if fastq_in1.suffix == ".gz":
with gzip.open(fastq_in1, "rt") as handle:
records.extend(SeqIO.parse(handle, "fastq"))
else:
with open(fastq_in1, "r") as handle:
records.extend(SeqIO.parse(handle, "fastq"))

# Read and append records from the second FASTQ file
if fastq_in2.suffix == ".gz":
with gzip.open(fastq_in2, "rt") as handle:
records.extend(SeqIO.parse(handle, "fastq"))
else:
with open(fastq_in2, "r") as handle:
records.extend(SeqIO.parse(handle, "fastq"))

# Write the concatenated records to the output FASTQ file
with open(fastq_out, "w") as handle:
SeqIO.write(records, handle, "fastq")
_concatenate([fastq_in1, fastq_in2], fastq_out, b"@")


def concatenate_single_fasta(file1: Path, file2: Path, output_file: Path):
sequences = []

# Read sequences from the first file
with open(file1, "r") as f1:
sequences.extend(SeqIO.parse(f1, "fasta"))

# Read sequences from the second file
with open(file2, "r") as f2:
sequences.extend(SeqIO.parse(f2, "fasta"))

# Write concatenated sequences to the output file
with open(output_file, "w") as output:
SeqIO.write(sequences, output, "fasta")
"""concatenates 2 fasta files
:param file1: input fasta 1
:param file2: input fasta 2
:param output_file: output fasta
:return:
"""
_concatenate([file1, file2], output_file, b">")
71 changes: 39 additions & 32 deletions src/plassembler/utils/plass_class.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
import sys
from pathlib import Path

import pandas as pd
Expand All @@ -7,6 +8,7 @@
from loguru import logger

from plassembler.utils.bam import sam_to_sorted_bam
from plassembler.utils.concat import concatenate_single_fasta
from plassembler.utils.depth import (
collate_depths,
combine_depth_dfs,
Expand Down Expand Up @@ -466,21 +468,9 @@ def get_depth_long(self, logdir, pacbio_model, threads, plas_fasta):
sam_file: Path = Path(outdir) / "combined_long.sam"
sorted_bam: Path = Path(outdir) / "combined_sorted_long.bam"

# # write to combined fasta

# Create a list to hold the combined sequences
combined_sequences = []

# Read and append sequences from the first FASTA file
for record in SeqIO.parse(chromosome, "fasta"):
combined_sequences.append(record)

# Read and append sequences from the second FASTA file
for record in SeqIO.parse(plas_fasta, "fasta"):
combined_sequences.append(record)

# Write the combined sequences to the output file
SeqIO.write(combined_sequences, combined_fasta, "fasta")
# write to combined fasta. No parsing needed - the contigs are already
# named as they should be, so this is a plain concatenation
concatenate_single_fasta(chromosome, plas_fasta, combined_fasta)

# map
minimap_long_reads(
Expand Down Expand Up @@ -929,32 +919,49 @@ def combine_input_fastas(self, chromosome_fasta: Path, plasmids_fasta: Path):
"""
# combined input fasta
combined_fasta = Path(self.outdir) / "combined.fasta"
chromosome_name = ""
# None, not "", so that a record with an empty header still counts as found
chromosome_name = None

# rename the first contig as chromosome
# rename the first contig as chromosome. Records are streamed rather than
# collected into a list first, so an assembly is never held whole in RAM
with open(chromosome_fasta, "r") as f_in, open(combined_fasta, "w") as f_out:
# Parse the input FASTA file
records = list(SeqIO.parse(f_in, "fasta"))
# keep chromosome name
chromosome_name = records[0].id
# Rename the first record
records[0].id = "chromosome"
records[0].description = ""
# Write the modified records to the output FASTA file
SeqIO.write(records, f_out, "fasta")
for index, record in enumerate(SeqIO.parse(f_in, "fasta")):
if index == 0:
# keep chromosome name
chromosome_name = record.id
record.id = "chromosome"
record.description = ""
SeqIO.write(record, f_out, "fasta")

if chromosome_name is None:
# streaming has no equivalent of list(SeqIO.parse(...))[0] raising
# IndexError, so an empty or non-FASTA chromosome would otherwise
# sail through and leave every depth and copy number computed
# against a combined.fasta with no chromosome in it
logger.error(
f"No contigs were found in {chromosome_fasta}. "
"Please check the chromosome assembly."
)
# logger.error exits under the CLI's ERROR sink, but not when
# plass_class is driven as a library
sys.exit(1)

plasmid_names = []

with open(plasmids_fasta, "r") as f_in, open(combined_fasta, "a") as f_out:
records = list(SeqIO.parse(f_in, "fasta"))
i = 0
for record in records:
i += 1
for i, record in enumerate(SeqIO.parse(f_in, "fasta"), start=1):
# keep plasmid name
plasmid_names.append(record.id)
record.id = str(i)
records[0].description = ""
# Write the records to the output FASTA file
if i == 1:
# NOTE: preserved as-is. The original cleared
# records[0].description on every iteration, so only the
# *first* plasmid ever lost its description while the rest
# kept theirs. That looks like a copy-paste slip, and it
# matters because get_contig_circularity() looks for
# "circular" in the description - but changing it would
# change results, so it is left for a separate fix.
record.description = ""
SeqIO.write(record, f_out, "fasta")

self.chromosome_name = chromosome_name
Expand Down
Loading
Loading