Skip to content

Commit 46a128a

Browse files
committed
Rextract: add FASTA support and filter for max score threshold
On branch master modified: README.md modified: recentrifuge/__init__.py modified: recentrifuge/fastq_io.py modified: rextract
1 parent 28e6ac3 commit 46a128a

4 files changed

Lines changed: 107 additions & 57 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66

77
____
8-
[![Retest](https://github.com/khyox/Recentrifuge/actions/workflows/retest.yaml/badge.svg?branch=v2.0.0)](https://github.com/khyox/recentrifuge/actions/workflows/retest.yaml)
8+
[![Retest](https://github.com/khyox/Recentrifuge/actions/workflows/retest.yaml/badge.svg?branch=v2.1.0)](https://github.com/khyox/recentrifuge/actions/workflows/retest.yaml)
99
[![](https://img.shields.io/maintenance/yes/2026.svg)](http://www.recentrifuge.org)
1010
[![](https://img.shields.io/github/languages/top/khyox/recentrifuge.svg)](https://pypi.org/project/recentrifuge/)
1111
[![](https://img.shields.io/pypi/pyversions/recentrifuge.svg)](https://pypi.org/project/recentrifuge/)

recentrifuge/__init__.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@
3333
__email__ = 'jse.mnl **AT** gmail.com'
3434
__maintainer__ = 'Jose Manuel Marti'
3535
__status__ = 'Production/Stable'
36-
__date__ = 'Jan 2026'
37-
__version__ = '2.0.0'
36+
__date__ = 'Feb 2026'
37+
__version__ = '2.1.0'
3838

3939
import sys
4040
from Bio import SeqIO
@@ -55,6 +55,8 @@
5555
SeqIO._FormatToIterator["lmat"] = lmat_io.lmat_out_iterator
5656
SeqIO._FormatToIterator["centrifuge"] = centrifuge_io.cfg_out_iterator
5757
SeqIO._FormatToIterator["quickfastq"] = fastq_io.quick_fastq_iterator
58+
SeqIO._FormatToIterator["quickfasta"] = fastq_io.quick_fasta_iterator
5859
SeqIO._FormatToWriter["lmat"] = lmat_io.LmatOutWriter
5960
SeqIO._FormatToWriter["quickfastq"] = fastq_io.QuickFastqWriter
61+
SeqIO._FormatToWriter["quickfasta"] = fastq_io.QuickFastaWriter
6062
# pylint: enable=protected-access

recentrifuge/fastq_io.py

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
"""Bio.SeqIO quick support for FASTQ files
1+
"""Bio.SeqIO quick support for FASTA and FASTQ files
22
33
You are expected to use this module via the Bio.SeqIO functions.
4-
This module is for reading and writing FASTQ output files as SeqRecord
5-
objects, but omitting some checks included in the Biopython method by Peter
6-
Cock. These checks were very useful in the "olden times" but, currently,
7-
with huge FASTQ files using standardized PHRED quality scores, they can be
8-
omitted, which improves the code performance.
4+
This module is for reading and writing FASTA and FASTQ output files as
5+
SeqRecord objects, but omitting some checks included in the Biopython method
6+
by Peter Cock. These checks were very useful in the "olden times" but,
7+
currently, with huge files using standardized formats, they can be omitted,
8+
which improves the code performance.
99
1010
"""
1111

@@ -15,6 +15,7 @@
1515
from Bio.SeqRecord import SeqRecord
1616
from Bio.SeqIO.Interfaces import SequenceWriter
1717
from Bio.SeqIO.QualityIO import FastqGeneralIterator
18+
from Bio.SeqIO.FastaIO import SimpleFastaParser
1819

1920
__docformat__ = "restructuredtext en"
2021

@@ -30,6 +31,15 @@ def quick_fastq_iterator(handle):
3031
annotations={'quality': quality})
3132

3233

34+
def quick_fasta_iterator(handle):
35+
"""Parse FASTA files quickly using SimpleFastaParser.
36+
"""
37+
for title, sequence in SimpleFastaParser(handle):
38+
first_word = title.split()[0]
39+
yield SeqRecord(Seq(sequence),
40+
id=first_word, name=first_word, description=title)
41+
42+
3343
class QuickFastqWriter(SequenceWriter):
3444
"""Class to write standard FASTQ format files with sequences
3545
previously read by QuickFastqIterator function.
@@ -61,3 +71,22 @@ def write_record(self, record: SeqRecord) -> None:
6171
handle = cast(TextIO, self.handle)
6272
handle.write(f'@{record.description}\n{str(record.seq)}\n+'
6373
f'\n{record.annotations["quality"]}\n')
74+
75+
76+
class QuickFastaWriter(SequenceWriter):
77+
"""Class to write standard FASTA format files.
78+
79+
Though you can use this class directly, you are strongly encouraged
80+
to use the Bio.SeqIO.write() function instead, via the format name
81+
"quickfasta".
82+
"""
83+
84+
@property
85+
def modes(self) -> str: # type: ignore[override]
86+
"""File modes (binary or text) that the writer can handle."""
87+
return "t"
88+
89+
def write_record(self, record: SeqRecord) -> None:
90+
"""Quickly write a single FASTA record to the file."""
91+
handle = cast(TextIO, self.handle)
92+
handle.write(f'>{record.description}\n{str(record.seq)}\n')

rextract

Lines changed: 67 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
#
1818
"""
1919
Selectively extract reads following Centrifuge/Kraken output.
20+
Support for both FASTQ and FASTA sequence files.
2021
"""
2122
# pylint: disable=no-name-in-module, not-an-iterable
2223
import argparse
@@ -69,7 +70,7 @@ def main():
6970
metavar='NUMBER',
7071
type=int,
7172
default=None,
72-
help=('limit of FASTQ reads to extract; '
73+
help=('limit of sequence reads to extract; '
7374
'default: no limit')
7475
)
7576
parser.add_argument(
@@ -78,7 +79,7 @@ def main():
7879
metavar='NUMBER',
7980
type=int,
8081
default=None,
81-
help=('maximum number of FASTQ reads to search for the taxa; '
82+
help=('maximum number of sequence reads to search for the taxa; '
8283
'default: no maximum')
8384
)
8485
parser.add_argument(
@@ -127,6 +128,15 @@ def main():
127128
help=('minimum score/confidence of the classification of a read '
128129
'to pass the quality filter; all pass by default')
129130
)
131+
parser.add_argument(
132+
'-z', '--maxscore',
133+
action='store',
134+
metavar='NUMBER',
135+
type=lambda txt: Score(float(txt)),
136+
default=None,
137+
help=('maximum score/confidence of the classification of a read '
138+
'to pass the quality filter; all pass by default')
139+
)
130140
parser_in = parser.add_argument_group(
131141
'input', 'Define Rextract input files')
132142
filein = parser_in.add_mutually_exclusive_group(required=True)
@@ -135,28 +145,34 @@ def main():
135145
action='store',
136146
metavar='FILE',
137147
default=None,
138-
help='single FASTQ file (no paired-ends), which may be gzipped'
148+
help='single sequence file (no paired-ends), which may be gzipped'
139149
)
140150
filein.add_argument(
141151
'-1', '--mate1',
142152
action='store',
143153
metavar='FILE',
144154
default=None,
145-
help='paired-ends FASTQ file (gzipped or not) for mate 1s '
155+
help='paired-ends sequence file (gzipped or not) for mate 1s '
146156
'(filename usually includes _1)'
147157
)
148158
parser_in.add_argument(
149159
'-2', '--mate2',
150160
action='store',
151161
metavar='FILE',
152162
default=None,
153-
help='paired-ends FASTQ file (gzipped or not) for mate 2s '
163+
help='paired-ends sequence file (gzipped or not) for mate 2s '
154164
'(filename usually includes _2)'
155165
)
156166
parser.add_argument(
157167
'-c', '--compress',
158168
action='store_true',
159-
help='any generated FASTQ file will be gzipped'
169+
help='any generated sequence file will be gzipped'
170+
)
171+
parser.add_argument(
172+
'-a', '--fasta',
173+
action='store_true',
174+
help='treat all the input and output sequence files as FASTA '
175+
'instead of the default FASTQ'
160176
)
161177
parser.add_argument(
162178
'-V', '--version',
@@ -190,12 +206,12 @@ def main():
190206
unclass: bool = args.unclassified
191207
excluding: Set[Id] = set(args.exclude)
192208
including: Set[Id] = set(args.include)
193-
fastq_1: Filename
194-
fastq_2: Filename = args.mate2
195-
if not fastq_2:
196-
fastq_1 = args.fastq
209+
fast_1: Filename
210+
fast_2: Filename = args.mate2
211+
if not fast_2:
212+
fast_1 = args.fastq
197213
else:
198-
fastq_1 = args.mate1
214+
fast_1 = args.mate1
199215

200216
check_debug()
201217

@@ -259,9 +275,10 @@ def main():
259275
continue
260276
score: Score = Score(record.annotations['score'])
261277
if args.minscore is not None and score < args.minscore:
262-
continue # Ignore read if low confidence
263-
else:
264-
records.append(record)
278+
continue # Ignore read if confidence below threshold
279+
if args.maxscore is not None and score > args.maxscore:
280+
continue # Ignore read if confidence above threshold
281+
records.append(record)
265282
except FileNotFoundError:
266283
raise Exception(red('ERROR!') + 'Cannot read "' +
267284
output_file + '"')
@@ -287,34 +304,36 @@ def main():
287304
except FileNotFoundError:
288305
return False
289306

290-
# FASTQ sequence dealing
307+
# FASTQ/FASTA sequence dealing
291308
records_ids: Set[str] = {record.id for record in records
292309
if record.id is not None}
293310
seqs1: List[SeqRecord.SeqRecord] = []
294311
seqs2: List[SeqRecord.SeqRecord] = []
295312
extracted: int = 0
296313
i: int = 0
297-
if fastq_2:
298-
print(gray('Loading FASTQ files'), fastq_1, gray('and'), fastq_2,
314+
seq_format = 'quickfasta' if args.fasta else 'quickfastq'
315+
seq_label = 'FASTA' if args.fasta else 'FASTQ'
316+
if fast_2:
317+
print(gray(f'Loading {seq_label} files'), fast_1, gray('and'), fast_2,
299318
gray('...\nMseqs: '), end='')
300319
sys.stdout.flush()
301320
mate1handler: Callable[..., TextIO] = open
302321
mate2handler: Callable[..., TextIO] = open
303-
if is_gzipped(fastq_1):
322+
if is_gzipped(fast_1):
304323
mate1handler = gzip.open
305324
else:
306325
mate1handler = open
307-
if is_gzipped(fastq_2):
326+
if is_gzipped(fast_2):
308327
mate2handler = gzip.open
309328
else:
310329
mate2handler = open
311330
try:
312-
with mate1handler(fastq_1, 'rt') as file1, \
313-
mate2handler(fastq_2, 'rt') as file2:
331+
with mate1handler(fast_1, 'rt') as file1, \
332+
mate2handler(fast_2, 'rt') as file2:
314333
for i, (rec1, rec2) in enumerate(zip(SeqIO.parse(file1,
315-
'quickfastq'),
316-
SeqIO.parse(file2,
317-
'quickfastq'))
334+
seq_format),
335+
SeqIO.parse(file2,
336+
seq_format))
318337
):
319338
if not records_ids:
320339
print(green(' [all records found]'), end='')
@@ -344,17 +363,17 @@ def main():
344363
extracted += 1
345364

346365
except FileNotFoundError:
347-
raise Exception('\n\033[91mERROR!\033[0m Cannot read FASTQ files')
366+
raise Exception(f'\n\033[91mERROR!\033[0m Cannot read {seq_label} files')
348367
else:
349-
print(gray('Loading FASTQ file'), f'{fastq_1}', gray('...\nMseqs: '),
368+
print(gray(f'Loading {seq_label} file'), f'{fast_1}', gray('...\nMseqs: '),
350369
end='')
351370
sys.stdout.flush()
352371
fq1handler: Callable[..., TextIO] = open
353-
if is_gzipped(fastq_1):
372+
if is_gzipped(fast_1):
354373
fq1handler = gzip.open
355374
try:
356-
with fq1handler(fastq_1, 'rt') as file1:
357-
for i, rec1 in enumerate(SeqIO.parse(file1, 'quickfastq')):
375+
with fq1handler(fast_1, 'rt') as file1:
376+
for i, rec1 in enumerate(SeqIO.parse(file1, seq_format)):
358377
if not records_ids:
359378
print(green(' [all records found]'), end='')
360379
break
@@ -376,7 +395,7 @@ def main():
376395
seqs1.append(rec1)
377396
extracted += 1
378397
except FileNotFoundError:
379-
raise Exception('\n\033[91mERROR!\033[0m Cannot read FASTQ file')
398+
raise Exception(f'\n\033[91mERROR!\033[0m Cannot read {seq_label} file')
380399
print(cyan(f' {i / 1e+6:.3g} Mseqs'), green('OK! '))
381400
missing: int = len(records_ids)
382401
if i > 0:
@@ -385,30 +404,30 @@ def main():
385404
else:
386405
print(extracted, gray('successfully extracted reads'))
387406
if not extracted:
388-
print(red('ERROR!'), 'No matching read(s) in the FASTQ file(s)!')
407+
print(red('ERROR!'), f'No matching read(s) in the {seq_label} file(s)!')
389408
sys.exit(2)
390409
if missing:
391410
print(yellow('WARNING!'), f'{missing} reads from Centrifuge output',
392-
'not found in the FASTQ file(s)!')
411+
f'not found in the {seq_label} file(s)!')
393412
sys.stdout.flush()
394413

395414

396-
def format_filename(fastq: Filename) -> Filename:
415+
def format_filename(fast: Filename) -> Filename:
397416
"""Auxiliary function to properly format the output filenames.
398417
399418
Args:
400-
fastq: Complete filename of the fastq input file (gzipped or not)
419+
fast: Complete filename of the sequence input file (gzipped or not)
401420
402-
Returns: Filename of the rextracted fastq output file
421+
Returns: Filename of the rextracted sequence output file
403422
"""
404423
# Get filename and extension, accounting for 2 extensions,
405-
# typically found in filename.fastq.gz
406-
fastq_filename, fastq_ext = os.path.splitext(fastq)
407-
if fastq_ext.casefold() == GZEXT:
408-
fastq_filename, fastq_ext = os.path.splitext(fastq_filename)
409-
if args.compress and not fastq_ext.casefold().endswith(GZEXT):
410-
fastq_ext += GZEXT
411-
output_list: List[str] = [fastq_filename, '_rxtr']
424+
# typically found in filename.fasta.gz or .fastq.gz
425+
fast_filename, fast_ext = os.path.splitext(fast)
426+
if fast_ext.casefold() == GZEXT:
427+
fast_filename, fast_ext = os.path.splitext(fast_filename)
428+
if args.compress and not fast_ext.casefold().endswith(GZEXT):
429+
fast_ext += GZEXT
430+
output_list: List[str] = [fast_filename, '_rxtr']
412431
if unclass:
413432
output_list.append('_unclass')
414433
else:
@@ -418,7 +437,7 @@ def main():
418437
if excluding:
419438
output_list.append('_excl')
420439
output_list.extend('_'.join(excluding))
421-
output_list.append(fastq_ext)
440+
output_list.append(fast_ext)
422441
return Filename(''.join(output_list))
423442

424443
def write_seqs(seqs: List[SeqRecord.SeqRecord], fname_in: Filename,
@@ -427,17 +446,17 @@ def main():
427446
fname_out: Filename = format_filename(fname_in)
428447
if gzipped:
429448
with gzip.open(fname_out, 'wt') as fgz:
430-
SeqIO.write(seqs, fgz, 'quickfastq')
449+
SeqIO.write(seqs, fgz, seq_format)
431450
print(gray('Compressed'), magenta(f'{len(seqs)}'),
432451
gray('reads in'), fname_out)
433452
else:
434-
SeqIO.write(seqs, fname_out, 'quickfastq')
453+
SeqIO.write(seqs, fname_out, seq_format)
435454
print(gray('Wrote'), magenta(f'{len(seqs)}'),
436455
gray('reads in'), fname_out)
437456

438-
write_seqs(seqs1, fastq_1, gzipped=args.compress)
439-
if fastq_2:
440-
write_seqs(seqs2, fastq_2, gzipped=args.compress)
457+
write_seqs(seqs1, fast_1, gzipped=args.compress)
458+
if fast_2:
459+
write_seqs(seqs2, fast_2, gzipped=args.compress)
441460

442461
# Timing results
443462
print(gray('Total elapsed time:'), time.strftime(

0 commit comments

Comments
 (0)