diff --git a/HISTORY.md b/HISTORY.md index 4db9239..823914a 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -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) ------------------ diff --git a/pyproject.toml b/pyproject.toml index ff8e0ed..c8271c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" } diff --git a/src/plassembler/utils/concat.py b/src/plassembler/utils/concat.py index 07c3e8d..dba756c 100644 --- a/src/plassembler/utils/concat.py +++ b/src/plassembler/utils/concat.py @@ -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 @@ -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">") diff --git a/src/plassembler/utils/plass_class.py b/src/plassembler/utils/plass_class.py index b8e7318..d9d4d32 100644 --- a/src/plassembler/utils/plass_class.py +++ b/src/plassembler/utils/plass_class.py @@ -1,4 +1,5 @@ import os +import sys from pathlib import Path import pandas as pd @@ -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, @@ -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( @@ -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 diff --git a/tests/test_concat.py b/tests/test_concat.py new file mode 100644 index 0000000..05299c8 --- /dev/null +++ b/tests/test_concat.py @@ -0,0 +1,144 @@ +"""Tests for the streaming concatenation helpers in plassembler.utils.concat.""" + +import gzip + +import pytest +from Bio import SeqIO + +from src.plassembler.utils.concat import ( + concatenate_single_fasta, + concatenate_single_fastq, +) + +READS_A = "@r1\nACGT\n+\nIIII\n@r2\nTTTT\n+\nJJJJ\n" +READS_B = "@r3\nGGGG\n+\nKKKK\n" + + +def records(path): + return [ + (r.id, str(r.seq), tuple(r.letter_annotations["phred_quality"])) + for r in SeqIO.parse(path, "fastq") + ] + + +def test_concatenate_fastq_keeps_every_record(tmp_path): + a, b = tmp_path / "a.fastq", tmp_path / "b.fastq" + a.write_text(READS_A) + b.write_text(READS_B) + out = tmp_path / "out.fastq" + concatenate_single_fastq(a, b, out) + assert [r[0] for r in records(out)] == ["r1", "r2", "r3"] + assert out.read_text() == READS_A + READS_B + + +def test_concatenate_fastq_handles_gzipped_input(tmp_path): + """.gz inputs are decompressed, as the SeqIO version did.""" + a, b = tmp_path / "a.fastq.gz", tmp_path / "b.fastq" + with gzip.open(a, "wt") as fh: + fh.write(READS_A) + b.write_text(READS_B) + out = tmp_path / "out.fastq" + concatenate_single_fastq(a, b, out) + assert [r[0] for r in records(out)] == ["r1", "r2", "r3"] + + +def test_concatenate_fastq_with_empty_first_file(tmp_path): + """An empty input is normal (e.g. no unmapped reads) and contributes nothing.""" + a, b = tmp_path / "a.fastq", tmp_path / "b.fastq" + a.write_text("") + b.write_text(READS_B) + out = tmp_path / "out.fastq" + concatenate_single_fastq(a, b, out) + assert out.read_text() == READS_B + + +def test_concatenate_inserts_missing_newline_between_files(tmp_path): + """A source with no trailing newline must not run into the next file's header.""" + a, b = tmp_path / "a.fastq", tmp_path / "b.fastq" + a.write_text(READS_A.rstrip("\n")) + b.write_text(READS_B) + out = tmp_path / "out.fastq" + concatenate_single_fastq(a, b, out) + assert [r[0] for r in records(out)] == ["r1", "r2", "r3"] + + +def test_concatenate_fastq_rejects_a_fasta(tmp_path): + """Wrong-format input is still caught, as BioPython's parser used to.""" + a, b = tmp_path / "a.fasta", tmp_path / "b.fastq" + a.write_text(">contig1\nACGT\n") + b.write_text(READS_B) + with pytest.raises(ValueError): + concatenate_single_fastq(a, b, tmp_path / "out.fastq") + + +def test_concatenate_fasta_keeps_every_contig(tmp_path): + a, b = tmp_path / "a.fasta", tmp_path / "b.fasta" + a.write_text(">chromosome\nACGTACGT\n") + b.write_text(">1 circular=True\nGGGG\n>2\nTTTT\n") + out = tmp_path / "out.fasta" + concatenate_single_fasta(a, b, out) + parsed = list(SeqIO.parse(out, "fasta")) + assert [r.id for r in parsed] == ["chromosome", "1", "2"] + # descriptions must survive: get_contig_circularity looks for "circular" + assert "circular" in parsed[1].description + + +def test_concatenate_fasta_rejects_a_fastq(tmp_path): + a, b = tmp_path / "a.fastq", tmp_path / "b.fasta" + a.write_text(READS_A) + b.write_text(">1\nACGT\n") + with pytest.raises(ValueError): + concatenate_single_fasta(a, b, tmp_path / "out.fasta") + + +def test_concatenate_leaves_no_output_when_second_file_is_wrong_format(tmp_path): + """A failure must not leave a half-written file where the run expects a whole one. + + The block copy opens the output before it has seen the second input, so + without the .tmp-and-rename the first file's reads would be left behind as a + complete-looking FASTQ. + """ + a, b = tmp_path / "a.fastq", tmp_path / "b.fasta" + a.write_text(READS_A) + b.write_text(">contig1\nACGT\n") + out = tmp_path / "out.fastq" + with pytest.raises(ValueError): + concatenate_single_fastq(a, b, out) + assert not out.exists() + assert list(tmp_path.glob("*.tmp")) == [] + + +def test_concatenate_leaves_an_existing_output_untouched_on_failure(tmp_path): + """The previous output survives a failed re-run rather than being truncated.""" + a, b = tmp_path / "a.fastq", tmp_path / "b.fasta" + a.write_text(READS_A) + b.write_text(">contig1\nACGT\n") + out = tmp_path / "out.fastq" + out.write_text(READS_B) + with pytest.raises(ValueError): + concatenate_single_fastq(a, b, out) + assert out.read_text() == READS_B + + +def test_concatenate_leaves_no_output_when_gzip_is_truncated(tmp_path): + """A truncated .gz raises from the decompressor mid-copy, so nothing is kept.""" + a, b = tmp_path / "a.fastq.gz", tmp_path / "b.fastq" + with gzip.open(a, "wb") as fh: + fh.write((READS_A * 5000).encode()) + raw = a.read_bytes() + a.write_bytes(raw[: len(raw) // 2]) + b.write_text(READS_B) + out = tmp_path / "out.fastq" + with pytest.raises(EOFError): + concatenate_single_fastq(a, b, out) + assert not out.exists() + + +def test_concatenate_accepts_a_string_output_path(tmp_path): + """tests/test_plassembler.py passes os.path.join(...) rather than a Path.""" + a, b = tmp_path / "a.fastq", tmp_path / "b.fastq" + a.write_text(READS_A) + b.write_text(READS_B) + out = str(tmp_path / "out.fastq") + concatenate_single_fastq(a, b, out) + assert [r[0] for r in records(out)] == ["r1", "r2", "r3"] diff --git a/tests/test_plass_class.py b/tests/test_plass_class.py index 46b3b58..a33f613 100644 --- a/tests/test_plass_class.py +++ b/tests/test_plass_class.py @@ -125,6 +125,51 @@ def test_combine_input_fastas_good(self): assembly.combine_input_fastas(chrom_fasta, plasmid_fasta) self.assertEqual(expected_return, True) + def test_combine_input_fastas_empty_chromosome(self): + """An empty chromosome.fasta must stop the run, not be combined silently. + + list(SeqIO.parse(...))[0] used to raise IndexError here. Streaming the + records has no equivalent, so the check is explicit - otherwise every + depth and copy number would be computed against a combined.fasta with + no chromosome in it. + """ + assembly = Assembly() + with tempfile.TemporaryDirectory() as tmp: + workdir = Path(tmp) + assembly.outdir = workdir + chrom_fasta = workdir / "chromosome.fasta" + chrom_fasta.write_text("") + plasmid_fasta = workdir / "plasmid.fasta" + plasmid_fasta.write_text(">1 circular=true\nACGT\n") + with self.assertRaises(SystemExit): + assembly.combine_input_fastas(chrom_fasta, plasmid_fasta) + + def test_combine_input_fastas_headers(self): + """Pins the renaming, including the first plasmid losing its description. + + Clearing only plasmid 1's description is a long-standing quirk, and + get_contig_circularity() reads circularity out of that description. It + is asserted here so that fixing it has to be a deliberate change. + """ + assembly = Assembly() + with tempfile.TemporaryDirectory() as tmp: + workdir = Path(tmp) + assembly.outdir = workdir + chrom_fasta = workdir / "chromosome.fasta" + chrom_fasta.write_text(">contig_1 len=5000000\nACGTACGTAC\n") + plasmid_fasta = workdir / "plasmid.fasta" + plasmid_fasta.write_text( + ">1 circular=true\nGGGGGGGGGG\n>2 circular=true\nCCCCCCCCCC\n" + ) + assembly.combine_input_fastas(chrom_fasta, plasmid_fasta) + headers = [ + line.strip() + for line in (workdir / "combined.fasta").read_text().splitlines() + if line.startswith(">") + ] + self.assertEqual(headers, [">chromosome", ">1", ">2 circular=true"]) + self.assertEqual(assembly.chromosome_name, "contig_1") + @pytest.mark.slow def test_check_get_depth(self): expected = True