From 1147f8053813c94b9272fdb162045c6f60f0d3ea Mon Sep 17 00:00:00 2001 From: Sanjay Nagi Date: Thu, 13 Aug 2026 21:32:32 +0000 Subject: [PATCH 1/4] perf(concat): concatenate reads and contigs by block copy, not SeqRecords concatenate_single_fastq parsed both inputs into a list of BioPython SeqRecords and rewrote them. SeqRecord overhead is roughly 5-10x the file size, so concatenating short reads in a hybrid run could transiently need tens of GB. concatenate_single_fasta did the same for contigs, and Plass.get_depth_long / Assembly.combine_input_fastas had their own copies of the list(SeqIO.parse(...)) habit. Concatenating records requires no parsing. Copy in 1 MiB blocks instead, decompressing gzipped inputs on the way through, and guarantee a newline between files so a source with no trailing newline cannot run its last line into the next file's header. Measured on 573 MiB of ONT fastq: seconds 28.44 -> 0.32 (89x) peak RSS 2672 MB -> 88 MB (-2.6 GB) with byte-identical output and identical (id, sequence, qualities) records. Parsing every record used to catch a wrong-format input, and a test relies on that, so _append_file checks the first byte instead - O(1) rather than O(file). Empty inputs are still fine; they are normal when there are no unmapped reads. Assembly.combine_input_fastas genuinely has to parse, because it renames contigs, but it now streams records rather than materialising the whole assembly as a list first. Its odd 'records[0].description = ""' inside the per-record loop - which only ever cleared the first plasmid's description, while later ones kept theirs - is preserved with a comment, because get_contig_circularity() reads that description and changing it would change results. --- src/plassembler/utils/concat.py | 97 +++++++++++++++++----------- src/plassembler/utils/plass_class.py | 54 +++++++--------- tests/test_concat.py | 91 ++++++++++++++++++++++++++ 3 files changed, 174 insertions(+), 68 deletions(-) create mode 100644 tests/test_concat.py diff --git a/src/plassembler/utils/concat.py b/src/plassembler/utils/concat.py index 07c3e8d..367fcfb 100644 --- a/src/plassembler/utils/concat.py +++ b/src/plassembler/utils/concat.py @@ -1,9 +1,11 @@ import gzip 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 +34,70 @@ 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_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, which for the short-read files of a hybrid run is tens of GB; 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") + with open(fastq_out, "wb") as out_handle: + _append_file(fastq_in1, out_handle, b"@") + _append_file(fastq_in2, out_handle, 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: + """ + with open(output_file, "wb") as out_handle: + _append_file(file1, out_handle, b">") + _append_file(file2, out_handle, b">") diff --git a/src/plassembler/utils/plass_class.py b/src/plassembler/utils/plass_class.py index b8e7318..6818496 100644 --- a/src/plassembler/utils/plass_class.py +++ b/src/plassembler/utils/plass_class.py @@ -7,6 +7,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 +467,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( @@ -931,30 +920,33 @@ def combine_input_fastas(self, chromosome_fasta: Path, plasmids_fasta: Path): combined_fasta = Path(self.outdir) / "combined.fasta" chromosome_name = "" - # 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") 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..bb2d0f9 --- /dev/null +++ b/tests/test_concat.py @@ -0,0 +1,91 @@ +"""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") From bf0e61618df2ecd5d84fc318e7903e7e13eb1967 Mon Sep 17 00:00:00 2001 From: gbouras13 Date: Mon, 7 Sep 2026 22:53:31 +0930 Subject: [PATCH 2/4] fix(concat): write concatenated output atomically The block copy opens its output before it has looked at the second input, so a wrong-format input, a truncated gzip or a full disk left a half-written FASTQ where the run expects a complete one. The SeqIO version got this for free: it parsed everything before opening the output, so a failure left no output at all. Copy to a sibling .tmp and os.replace it into place once every source has been read. A failed re-run now also leaves any previous output intact. Co-Authored-By: Claude Opus 5 --- src/plassembler/utils/concat.py | 41 ++++++++++++++++++++----- tests/test_concat.py | 53 +++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 8 deletions(-) diff --git a/src/plassembler/utils/concat.py b/src/plassembler/utils/concat.py index 367fcfb..dba756c 100644 --- a/src/plassembler/utils/concat.py +++ b/src/plassembler/utils/concat.py @@ -1,4 +1,5 @@ import gzip +import os from pathlib import Path from loguru import logger @@ -73,22 +74,48 @@ def _append_file(source: Path, out_handle, record_marker: bytes): 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, which for the short-read files of a hybrid run is tens of GB; a block - copy is constant-memory and far faster. + 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 :return: """ - with open(fastq_out, "wb") as out_handle: - _append_file(fastq_in1, out_handle, b"@") - _append_file(fastq_in2, out_handle, b"@") + _concatenate([fastq_in1, fastq_in2], fastq_out, b"@") def concatenate_single_fasta(file1: Path, file2: Path, output_file: Path): @@ -98,6 +125,4 @@ def concatenate_single_fasta(file1: Path, file2: Path, output_file: Path): :param output_file: output fasta :return: """ - with open(output_file, "wb") as out_handle: - _append_file(file1, out_handle, b">") - _append_file(file2, out_handle, b">") + _concatenate([file1, file2], output_file, b">") diff --git a/tests/test_concat.py b/tests/test_concat.py index bb2d0f9..05299c8 100644 --- a/tests/test_concat.py +++ b/tests/test_concat.py @@ -89,3 +89,56 @@ def test_concatenate_fasta_rejects_a_fastq(tmp_path): 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"] From bc1d8b69ac40d278fa25b4df2a2a8ed91bcc8171 Mon Sep 17 00:00:00 2001 From: gbouras13 Date: Mon, 7 Sep 2026 22:53:40 +0930 Subject: [PATCH 3/4] fix(plass_class): keep an empty chromosome.fasta fatal combine_input_fastas used list(SeqIO.parse(...))[0], which raised IndexError when the chromosome FASTA held no records. Streaming the records has no equivalent, so an empty or non-FASTA chromosome would have sailed through, leaving chromosome_name unset and every depth and copy number computed against a combined.fasta with no chromosome in it. Check explicitly and report it through logger.error, matching how qc.py's _fail_chopper handles a fatal condition. The sentinel is None rather than "" so a record with an empty header still counts as found. Also pins the renaming in a test, including the long-standing quirk that only the first plasmid loses its description - get_contig_circularity() reads circularity out of that description, so fixing it has to be a deliberate change. Co-Authored-By: Claude Opus 5 --- src/plassembler/utils/plass_class.py | 17 ++++++++++- tests/test_plass_class.py | 45 ++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/src/plassembler/utils/plass_class.py b/src/plassembler/utils/plass_class.py index 6818496..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 @@ -918,7 +919,8 @@ 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. Records are streamed rather than # collected into a list first, so an assembly is never held whole in RAM @@ -931,6 +933,19 @@ def combine_input_fastas(self, chromosome_fasta: Path, plasmids_fasta: Path): 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: 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 From 236a5f5885d62d72cb30973fc7f722fdd103b779 Mon Sep 17 00:00:00 2001 From: gbouras13 Date: Mon, 7 Sep 2026 22:53:46 +0930 Subject: [PATCH 4/4] chore: release 1.8.5 Credits #90 for the block-copy concatenation and records the two behaviour changes that come with it: a truncated uncompressed FASTQ is no longer rejected now that validation is a first-byte check rather than a full parse, and an empty chromosome.fasta is fatal. Co-Authored-By: Claude Opus 5 --- HISTORY.md | 8 ++++++++ pyproject.toml | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) 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" }