• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

broadinstitute / viral-core / 20919343111

12 Jan 2026 12:27PM UTC coverage: 70.772% (+0.2%) from 70.6%
20919343111

Pull #160

github

web-flow
Merge f9b616da0 into dd823d605
Pull Request #160: Optimize align_and_fix runtime with sambamba and skip_realign option

16 of 18 new or added lines in 1 file covered. (88.89%)

4649 of 6569 relevant lines covered (70.77%)

0.71 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

77.32
/read_utils.py
1
#!/usr/bin/env python3
2
"""
3
Utilities for working with sequence reads, such as converting formats and
4
fixing mate pairs.
5
"""
6

7
__author__ = "irwin@broadinstitute.org, dpark@broadinstitute.org"
1✔
8
__commands__ = []
1✔
9

10
import argparse
1✔
11
import logging
1✔
12
import math
1✔
13
import os
1✔
14
import sqlite3
1✔
15
import subprocess
1✔
16
import tempfile
1✔
17
import time
1✔
18
import shutil
1✔
19
import sys
1✔
20
import concurrent.futures
1✔
21
from contextlib import contextmanager
1✔
22
import functools
1✔
23

24
from Bio import SeqIO
1✔
25
import pysam
1✔
26

27
import util.cmd
1✔
28
import util.file
1✔
29
import util.misc
1✔
30
from util.file import mkstempfname
1✔
31
import tools.bbmap
1✔
32
import tools.bwa
1✔
33
import tools.cdhit
1✔
34
import tools.picard
1✔
35
import tools.samtools
1✔
36
import tools.minimap2
1✔
37
import tools.mvicuna
1✔
38
import tools.prinseq
1✔
39
import tools.novoalign
1✔
40
import tools.gatk
1✔
41
import tools.sambamba
1✔
42

43
log = logging.getLogger(__name__)
1✔
44

45

46
# ===============================
47
# ***  index_fasta_samtools   ***
48
# ===============================
49

50

51
def parser_index_fasta_samtools(parser=argparse.ArgumentParser()):
1✔
52
    parser.add_argument('inFasta', help='Reference genome, FASTA format.')
1✔
53
    util.cmd.common_args(parser, (('loglevel', None), ('version', None)))
1✔
54
    util.cmd.attach_main(parser, main_index_fasta_samtools)
1✔
55
    return parser
1✔
56

57

58
def main_index_fasta_samtools(args):
1✔
59
    '''Index a reference genome for Samtools.'''
60
    tools.samtools.SamtoolsTool().faidx(args.inFasta, overwrite=True)
×
61
    return 0
×
62

63

64
__commands__.append(('index_fasta_samtools', parser_index_fasta_samtools))
1✔
65

66
# =============================
67
# ***  index_fasta_picard   ***
68
# =============================
69

70

71
def parser_index_fasta_picard(parser=argparse.ArgumentParser()):
1✔
72
    parser.add_argument('inFasta', help='Input reference genome, FASTA format.')
1✔
73
    parser.add_argument(
1✔
74
        '--JVMmemory',
75
        default=tools.picard.CreateSequenceDictionaryTool.jvmMemDefault,
76
        help='JVM virtual memory size (default: %(default)s)'
77
    )
78
    parser.add_argument(
1✔
79
        '--picardOptions',
80
        default=[],
81
        nargs='*',
82
        help='Optional arguments to Picard\'s CreateSequenceDictionary, OPTIONNAME=value ...'
83
    )
84
    util.cmd.common_args(parser, (('loglevel', None), ('version', None), ('tmp_dir', None)))
1✔
85
    util.cmd.attach_main(parser, main_index_fasta_picard)
1✔
86
    return parser
1✔
87

88

89
def main_index_fasta_picard(args):
1✔
90
    '''Create an index file for a reference genome suitable for Picard/GATK.'''
91
    tools.picard.CreateSequenceDictionaryTool().execute(
×
92
        args.inFasta, overwrite=True,
93
        picardOptions=args.picardOptions,
94
        JVMmemory=args.JVMmemory
95
    )
96
    return 0
×
97

98

99
__commands__.append(('index_fasta_picard', parser_index_fasta_picard))
1✔
100

101
# =============================
102
# ***  mkdup_picard   ***
103
# =============================
104

105

106
def parser_mkdup_picard(parser=argparse.ArgumentParser()):
1✔
107
    parser.add_argument('inBams', help='Input reads, BAM format.', nargs='+')
1✔
108
    parser.add_argument('outBam', help='Output reads, BAM format.')
1✔
109
    parser.add_argument('--outMetrics', help='Output metrics file. Default is to dump to a temp file.', default=None)
1✔
110
    parser.add_argument(
1✔
111
        "--remove",
112
        help="Instead of marking duplicates, remove them entirely (default: %(default)s)",
113
        default=False,
114
        action="store_true",
115
        dest="remove"
116
    )
117
    parser.add_argument(
1✔
118
        '--JVMmemory',
119
        default=tools.picard.MarkDuplicatesTool.jvmMemDefault,
120
        help='JVM virtual memory size (default: %(default)s)'
121
    )
122
    parser.add_argument(
1✔
123
        '--picardOptions',
124
        default=[],
125
        nargs='*',
126
        help='Optional arguments to Picard\'s MarkDuplicates, OPTIONNAME=value ...'
127
    )
128
    util.cmd.common_args(parser, (('loglevel', None), ('version', None), ('tmp_dir', None)))
1✔
129
    util.cmd.attach_main(parser, main_mkdup_picard)
1✔
130
    return parser
1✔
131

132

133
def main_mkdup_picard(args):
1✔
134
    '''Mark or remove duplicate reads from BAM file.'''
135
    opts = list(args.picardOptions)
×
136
    if args.remove:
×
137
        opts = ['REMOVE_DUPLICATES=true'] + opts
×
138
    tools.picard.MarkDuplicatesTool().execute(
×
139
        args.inBams, args.outBam,
140
        args.outMetrics, picardOptions=opts,
141
        JVMmemory=args.JVMmemory
142
    )
143
    return 0
×
144

145

146
__commands__.append(('mkdup_picard', parser_mkdup_picard))
1✔
147

148
# =============================
149
# ***  revert_bam_picard   ***
150
# =============================
151

152
def parser_revert_sam_common(parser=argparse.ArgumentParser()):
1✔
153
    parser.add_argument('--clearTags', dest='clear_tags', default=False, action='store_true', 
1✔
154
                        help='When supplying an aligned input file, clear the per-read attribute tags')
155
    parser.add_argument("--tagsToClear", type=str, nargs='+', dest="tags_to_clear", default=["XT", "X0", "X1", "XA", 
1✔
156
                        "AM", "SM", "BQ", "CT", "XN", "OC", "OP"], 
157
                        help='A space-separated list of tags to remove from all reads in the input bam file (default: %(default)s)')
158
    parser.add_argument(
1✔
159
        '--doNotSanitize',
160
        dest="do_not_sanitize",
161
        action='store_true',
162
        help="""When being reverted, picard's SANITIZE=true
163
                is set unless --doNotSanitize is given. 
164
                Sanitization is a destructive operation that removes reads so the bam file is consistent.
165
                From the picard documentation:
166
                    'Reads discarded include (but are not limited to) paired reads with missing mates, duplicated records, 
167
                    records with mismatches in length of bases and qualities.'
168
                For more information see:
169
                    https://broadinstitute.github.io/picard/command-line-overview.html#RevertSam
170
                """
171
    )
172
    try:
1✔
173
        parser.add_argument(
1✔
174
            '--JVMmemory',
175
            default=tools.picard.RevertSamTool.jvmMemDefault,
176
            help='JVM virtual memory size (default: %(default)s)'
177
        )
178
    except argparse.ArgumentError:
1✔
179
        # if --JVMmemory is already present in the parser, continue on...
180
        pass
1✔
181
    return parser
1✔
182

183
def parser_revert_bam_picard(parser=argparse.ArgumentParser()):
1✔
184
    parser.add_argument('inBam', help='Input reads, BAM format.')
1✔
185
    parser.add_argument('outBam', help='Output reads, BAM format.')
1✔
186
    parser.add_argument(
1✔
187
        '--JVMmemory',
188
        default=tools.picard.RevertSamTool.jvmMemDefault,
189
        help='JVM virtual memory size (default: %(default)s)'
190
    )
191
    parser.add_argument(
1✔
192
        '--picardOptions',
193
        default=[],
194
        nargs='*',
195
        help='Optional arguments to Picard\'s RevertSam, OPTIONNAME=value ...'
196
    )
197
    parser = parser_revert_sam_common(parser)
1✔
198
    util.cmd.common_args(parser, (('loglevel', None), ('version', None), ('tmp_dir', None)))
1✔
199
    util.cmd.attach_main(parser, main_revert_bam_picard, split_args=True)
1✔
200
    return parser
1✔
201

202

203
def main_revert_bam_picard(inBam, outBam, clear_tags=False, tags_to_clear=None, picardOptions=None, do_not_sanitize=False, JVMmemory=None):
1✔
204
    '''Revert BAM to raw reads'''
205
    picardOptions = picardOptions or []
×
206
    tags_to_clear = tags_to_clear or []
×
207

208
    if clear_tags:
×
209
        for tag in tags_to_clear:
×
210
            picardOptions.append("ATTRIBUTE_TO_CLEAR={}".format(tag))
×
211
            
212
            if not do_not_sanitize and not any(opt.startswith("SANITIZE") for opt in picardOptions):
×
213
                picardOptions.append('SANITIZE=true')
×
214

215
    tools.picard.RevertSamTool().execute(inBam, outBam, picardOptions=picardOptions, JVMmemory=JVMmemory)
×
216
    return 0
×
217
__commands__.append(('revert_bam_picard', parser_revert_bam_picard))
1✔
218

219
@contextmanager
1✔
220
def revert_bam_if_aligned(inBam, revert_bam=None, clear_tags=True, tags_to_clear=None, picardOptions=None, JVMmemory="4g", sanitize=False):
1✔
221
    '''
222
        revert the bam file if aligned. 
223
        If a revertBam file path is specified, it is used, otherwise a temp file is created.
224
    '''
225

226
    revertBamOut = revert_bam if revert_bam else util.file.mkstempfname('.bam')
×
227
    picardOptions = picardOptions or []
×
228
    tags_to_clear = tags_to_clear or []
×
229

230
    outBam = inBam
×
231
    with pysam.AlignmentFile(inBam, 'rb', check_sq=False) as bam:
×
232
        # if it looks like the bam is aligned, revert it
233
        if 'SQ' in bam.header and len(bam.header['SQ'])>0:
×
234
            if clear_tags:
×
235
                for tag in tags_to_clear:
×
236
                    picardOptions.append("ATTRIBUTE_TO_CLEAR={}".format(tag))
×
237

238
            if not any(opt.startswith("SORT_ORDER") for opt in picardOptions):
×
239
                picardOptions.append('SORT_ORDER=queryname')
×
240

241
            if sanitize and not any(opt.startswith("SANITIZE") for opt in picardOptions):
×
242
                picardOptions.append('SANITIZE=true')
×
243

244
            tools.picard.RevertSamTool().execute(
×
245
                inBam, revertBamOut, picardOptions=picardOptions 
246
            )
247
            outBam = revertBamOut
×
248
        else:
249
            # if we don't need to produce a revertBam file
250
            # but the user has specified one anyway
251
            # simply touch the output
252
            if revert_bam:
×
253
                log.warning("An output was specified for 'revertBam', but the input is unaligned, so RevertSam was not needed. Touching the output.")
×
254
                util.file.touch(revertBamOut)
×
255
                # TODO: error out? run RevertSam anyway?
256

257
    yield outBam
×
258

259
    if revert_bam == None:
×
260
        os.unlink(revertBamOut)
×
261

262
# =========================
263
# ***  generic picard   ***
264
# =========================
265

266

267
def parser_picard(parser=argparse.ArgumentParser()):
1✔
268
    parser.add_argument('command', help='picard command')
1✔
269
    parser.add_argument(
1✔
270
        '--JVMmemory',
271
        default=tools.picard.PicardTools.jvmMemDefault,
272
        help='JVM virtual memory size (default: %(default)s)'
273
    )
274
    parser.add_argument(
1✔
275
        '--picardOptions',
276
        default=[], nargs='*',
277
        help='Optional arguments to Picard, OPTIONNAME=value ...'
278
    )
279
    util.cmd.common_args(parser, (('loglevel', None), ('version', None), ('tmp_dir', None)))
1✔
280
    util.cmd.attach_main(parser, main_picard)
1✔
281
    return parser
1✔
282

283

284
def main_picard(args):
1✔
285
    '''Generic Picard runner.'''
286
    tools.picard.PicardTools().execute(args.command, picardOptions=args.picardOptions, JVMmemory=args.JVMmemory)
×
287
    return 0
×
288

289

290
__commands__.append(('picard', parser_picard))
1✔
291

292
# ===================
293
# ***  sort_bam   ***
294
# ===================
295

296

297
def parser_sort_bam(parser=argparse.ArgumentParser()):
1✔
298
    parser.add_argument('inBam', help='Input bam file.')
1✔
299
    parser.add_argument('outBam', help='Output bam file, sorted.')
1✔
300
    parser.add_argument(
1✔
301
        'sortOrder',
302
        help='How to sort the reads. [default: %(default)s]',
303
        choices=tools.picard.SortSamTool.valid_sort_orders,
304
        default=tools.picard.SortSamTool.default_sort_order
305
    )
306
    parser.add_argument(
1✔
307
        "--index",
308
        help="Index outBam (default: %(default)s)",
309
        default=False,
310
        action="store_true",
311
        dest="index"
312
    )
313
    parser.add_argument(
1✔
314
        "--md5",
315
        help="MD5 checksum outBam (default: %(default)s)",
316
        default=False,
317
        action="store_true",
318
        dest="md5"
319
    )
320
    parser.add_argument(
1✔
321
        '--JVMmemory',
322
        default=tools.picard.SortSamTool.jvmMemDefault,
323
        help='JVM virtual memory size (default: %(default)s)'
324
    )
325
    parser.add_argument(
1✔
326
        '--picardOptions',
327
        default=[],
328
        nargs='*',
329
        help='Optional arguments to Picard\'s SortSam, OPTIONNAME=value ...'
330
    )
331
    util.cmd.common_args(parser, (('loglevel', None), ('version', None), ('tmp_dir', None)))
1✔
332
    util.cmd.attach_main(parser, main_sort_bam)
1✔
333
    return parser
1✔
334

335

336
def main_sort_bam(args):
1✔
337
    '''Sort BAM file'''
338
    opts = list(args.picardOptions)
×
339
    if args.index:
×
340
        opts = ['CREATE_INDEX=true'] + opts
×
341
    if args.md5:
×
342
        opts = ['CREATE_MD5_FILE=true'] + opts
×
343
    tools.picard.SortSamTool().execute(
×
344
        args.inBam, args.outBam, args.sortOrder,
345
        picardOptions=opts, JVMmemory=args.JVMmemory
346
    )
347
    return 0
×
348

349

350
__commands__.append(('sort_bam', parser_sort_bam))
1✔
351

352
# ====================
353
# ***  downsample_bams  ***
354
# ====================
355

356
def parser_downsample_bams(parser=argparse.ArgumentParser()):
1✔
357
    parser.add_argument('in_bams', help='Input bam files.', nargs='+')
1✔
358
    parser.add_argument('--outPath', dest="out_path", type=str, help="""Output path. If not provided, 
1✔
359
                        downsampled bam files will be written to the same paths as each 
360
                        source bam file""")
361
    parser.add_argument('--readCount', dest="specified_read_count", type=int, help='The number of reads to downsample to.')
1✔
362
    group = parser.add_mutually_exclusive_group()
1✔
363
    group.add_argument('--deduplicateBefore', action='store_true', dest="deduplicate_before", help='de-duplicate reads before downsampling.')
1✔
364
    group.add_argument('--deduplicateAfter', action='store_true', dest="deduplicate_after", help='de-duplicate reads after downsampling.')
1✔
365
    parser.add_argument(
1✔
366
        '--JVMmemory',
367
        default=tools.picard.DownsampleSamTool.jvmMemDefault,
368
        help='JVM virtual memory size (default: %(default)s)'
369
    )
370
    parser.add_argument(
1✔
371
        '--picardOptions',
372
        default=[],
373
        nargs='*',
374
        help='Optional arguments to Picard\'s DownsampleSam, OPTIONNAME=value ...'
375
    )
376
    util.cmd.common_args(parser, (('threads', None), ('loglevel', None), ('version', None), ('tmp_dir', None)))
1✔
377
    util.cmd.attach_main(parser, main_downsample_bams, split_args=True)
1✔
378
    return parser
1✔
379

380

381
def main_downsample_bams(in_bams, out_path, specified_read_count=None, deduplicate_before=False, deduplicate_after=False, picardOptions=None, threads=None, JVMmemory=None):
1✔
382
    '''Downsample multiple bam files to the smallest read count in common, or to the specified count.'''
383
    if picardOptions is None:
1✔
384
        picardOptions = []
1✔
385

386
    opts = list(picardOptions) + []
1✔
387

388
    def get_read_counts(bams):
1✔
389
        # get read counts for bam files provided
390
        read_counts = {}
1✔
391
        with concurrent.futures.ProcessPoolExecutor(max_workers=util.misc.sanitize_thread_count(threads)) as executor:
1✔
392
            samtools = tools.samtools.SamtoolsTool()
1✔
393
            for x, result in zip(bams, executor.map(samtools.count, bams)):
1✔
394
                read_counts[x] = int(result)
1✔
395

396
        return read_counts
1✔
397

398
    def get_downsample_target_count(bams, readcount_requested):
1✔
399
        read_counts = get_read_counts(bams)
1✔
400
        downsample_target = 0
1✔
401
        if readcount_requested is not None:
1✔
402
            downsample_target = readcount_requested
1✔
403
            if downsample_target > min(read_counts.values()):
1✔
404
                raise ValueError("The smallest file has %s reads, which is less than the downsample target specified, %s. Please reduce the target count or omit it to use the read count of the smallest input file." % (min(read_counts.values()), downsample_target))
1✔
405
        else:
406
            downsample_target = min(read_counts.values())
1✔
407
        return downsample_target
1✔
408

409
    def downsample_bams(data_pairs, downsample_target, threads=None, JVMmemory=None):
1✔
410
        workers = min(util.misc.sanitize_thread_count(threads), len(data_pairs))
1✔
411
        JVMmemory = JVMmemory if JVMmemory else tools.picard.DownsampleSamTool.jvmMemDefault
1✔
412
        jvm_worker_memory_mb = str(int(util.misc.convert_size_str(JVMmemory,"m")[:-1])//workers)+"m"
1✔
413
        with concurrent.futures.ProcessPoolExecutor(max_workers=workers) as executor:
1✔
414
            downsamplesam = tools.picard.DownsampleSamTool()
1✔
415
            future_to_file = {executor.submit(downsamplesam.downsample_to_approx_count, *(list(fp)+[downsample_target]), JVMmemory=jvm_worker_memory_mb): fp[0] for fp in data_pairs}
1✔
416
            for future in concurrent.futures.as_completed(future_to_file):
1✔
417
                f = future_to_file[future]
1✔
418
                try:
1✔
419
                    data = future.result()
1✔
420
                except Exception as exc:
×
421
                    raise
×
422

423
    def dedup_bams(data_pairs, threads=None):
1✔
424
        workers = min(util.misc.sanitize_thread_count(threads), len(data_pairs))
1✔
425
        with concurrent.futures.ProcessPoolExecutor(max_workers=workers) as executor:
1✔
426
            future_to_file = {executor.submit(rmdup_mvicuna_bam, *fp): fp[0] for fp in data_pairs}
1✔
427
            for future in concurrent.futures.as_completed(future_to_file):
1✔
428
                f = future_to_file[future]
1✔
429
                try:
1✔
430
                    data = future.result()
1✔
431
                except Exception as exc:
×
432
                    raise
×
433

434
    data_pairs = []
1✔
435
    if deduplicate_before:
1✔
436
        with util.file.tempfnames(suffixes=[ '{}.dedup.bam'.format(os.path.splitext(os.path.basename(x))[0]) for x in in_bams]) as deduped_bams:
1✔
437
            data_pairs = list(zip(in_bams, deduped_bams))
1✔
438
            dedup_bams(data_pairs, threads=threads)
1✔
439
            downsample_target = get_downsample_target_count(deduped_bams, specified_read_count)
1✔
440
            
441
            if out_path:
1✔
442
                util.file.mkdir_p(out_path)
1✔
443
                data_pairs = list(zip(deduped_bams, [os.path.join(out_path, os.path.splitext(os.path.basename(x))[0]+".dedup.downsampled-{}.bam".format(downsample_target)) for x in in_bams]))
1✔
444
            else:
445
                data_pairs = list(zip(deduped_bams, [os.path.splitext(x)[0]+".dedup.downsampled-{}.bam".format(downsample_target) for x in in_bams]))
×
446
            downsample_bams(data_pairs, downsample_target, threads=threads, JVMmemory=JVMmemory)
1✔
447
    else:
448
        downsample_target = get_downsample_target_count(in_bams, specified_read_count)
1✔
449
        if deduplicate_after:
1✔
450
            log.warning("--deduplicateAfter has been specified. Read count of output files is not guaranteed.")
1✔
451
            with util.file.tempfnames(suffixes=[ '{}.downsampled-{}.bam'.format(os.path.splitext(os.path.basename(x))[0], downsample_target) for x in in_bams]) as downsampled_tmp_bams:
1✔
452
                data_pairs = list(zip(in_bams, downsampled_tmp_bams))
1✔
453
                downsample_bams(data_pairs, downsample_target, threads=threads, JVMmemory=JVMmemory)
1✔
454
                
455
                if out_path:
1✔
456
                    util.file.mkdir_p(out_path)
1✔
457
                    data_pairs = list(zip(downsampled_tmp_bams, [os.path.join(out_path, os.path.splitext(os.path.basename(x))[0]+".downsampled-{}.dedup.bam").format(downsample_target) for x in in_bams]))
1✔
458
                else:
459
                    data_pairs = list(zip(downsampled_tmp_bams, [os.path.splitext(x)[0]+".downsampled-{}.dedup.bam".format(downsample_target) for x in in_bams]))
×
460
                dedup_bams(data_pairs, threads=threads)
1✔
461
        else:
462
            if out_path:
1✔
463
                util.file.mkdir_p(out_path)
1✔
464
                data_pairs = list(zip(in_bams, [os.path.join(out_path, os.path.splitext(os.path.basename(x))[0]+".downsampled-{}.bam".format(downsample_target)) for x in in_bams]))
1✔
465
            else:
466
                data_pairs = list(zip(in_bams, [os.path.splitext(x)[0]+".downsampled-{}.bam".format(downsample_target) for x in in_bams]))
1✔
467
            downsample_bams(data_pairs, downsample_target, threads=threads, JVMmemory=JVMmemory)
1✔
468
    return 0
1✔
469

470
__commands__.append(('downsample_bams', parser_downsample_bams))
1✔
471

472
# ====================
473
# ***  merge_bams  ***
474
# ====================
475

476

477
def parser_merge_bams(parser=argparse.ArgumentParser()):
1✔
478
    parser.add_argument('inBams', help='Input bam files.', nargs='+')
1✔
479
    parser.add_argument('outBam', help='Output bam file.')
1✔
480
    parser.add_argument(
1✔
481
        '--JVMmemory',
482
        default=tools.picard.MergeSamFilesTool.jvmMemDefault,
483
        help='JVM virtual memory size (default: %(default)s)'
484
    )
485
    parser.add_argument(
1✔
486
        '--picardOptions',
487
        default=[],
488
        nargs='*',
489
        help='Optional arguments to Picard\'s MergeSamFiles, OPTIONNAME=value ...'
490
    )
491
    util.cmd.common_args(parser, (('loglevel', None), ('version', None), ('tmp_dir', None)))
1✔
492
    util.cmd.attach_main(parser, main_merge_bams)
1✔
493
    return parser
1✔
494

495

496
def main_merge_bams(args):
1✔
497
    '''Merge multiple BAMs into one'''
498
    opts = list(args.picardOptions) + ['USE_THREADING=true']
×
499
    tools.picard.MergeSamFilesTool().execute(args.inBams, args.outBam, picardOptions=opts, JVMmemory=args.JVMmemory)
×
500
    return 0
×
501

502

503
__commands__.append(('merge_bams', parser_merge_bams))
1✔
504

505
# ====================
506
# ***  filter_bam  ***
507
# ====================
508

509

510
def parser_filter_bam(parser=argparse.ArgumentParser()):
1✔
511
    parser.add_argument('inBam', help='Input bam file.')
1✔
512
    parser.add_argument('readList', help='Input file of read IDs.')
1✔
513
    parser.add_argument('outBam', help='Output bam file.')
1✔
514
    parser.add_argument(
1✔
515
        "--exclude",
516
        help="""If specified, readList is a list of reads to remove from input.
517
            Default behavior is to treat readList as an inclusion list (all unnamed
518
            reads are removed).""",
519
        default=False,
520
        action="store_true",
521
        dest="exclude"
522
    )
523
    util.cmd.common_args(parser, (('loglevel', None), ('version', None), ('tmp_dir', None)))
1✔
524
    util.cmd.attach_main(parser, main_filter_bam)
1✔
525
    return parser
1✔
526

527

528
def main_filter_bam(args):
1✔
529
    '''Filter BAM file by read name'''
530
    with util.file.tmp_dir(suffix='_filter_bam') as tmpdir:
×
531
        db_path = os.path.join(tmpdir, 'read_ids.db')
×
532
        with ReadIdStore(db_path) as store:
×
533
            store.add_from_readlist(args.readList)
×
534
            store.filter_bam_by_ids(args.inBam, args.outBam, include=not args.exclude)
×
535
    return 0
×
536

537

538
__commands__.append(('filter_bam', parser_filter_bam))
1✔
539

540

541

542
# =======================
543
# ***  fastq_to_bam   ***
544
# =======================
545

546

547
def fastq_to_bam(
1✔
548
    inFastq1,
549
    inFastq2,
550
    outBam,
551
    sampleName=None,
552
    header=None,
553
    JVMmemory=None,  # Kept for backwards compatibility, ignored (was for Picard)
554
    picardOptions=None,  # Kept for backwards compatibility, parsed for RG tags
555
    threads=None
556
):
557
    ''' Convert a pair of fastq paired-end read files and optional text header
558
        to a single bam file.
559

560
        Uses samtools import for multi-threaded FASTQ to BAM conversion.
561
        The JVMmemory parameter is kept for backwards compatibility but ignored.
562
        The picardOptions parameter is parsed for Picard-style RG tag options.
563
    '''
564
    picardOptions = picardOptions or []
1✔
565

566
    if header:
1✔
567
        fastqToSamOut = mkstempfname('.bam')
1✔
568
    else:
569
        fastqToSamOut = outBam
1✔
570
    if sampleName is None:
1✔
571
        sampleName = 'Dummy'    # Will get overwritten by rehead command
1✔
572

573
    # Parse picardOptions to extract RG tag values
574
    # Supports Picard-style options like LIBRARY_NAME=Alexandria
575
    opts_dict = {}
1✔
576
    for opt in picardOptions:
1✔
577
        if '=' in opt:
1✔
578
            key, value = opt.split('=', 1)
1✔
579
            opts_dict[key.upper()] = value
1✔
580

581
    # Map Picard option names to import_fastq parameters
582
    library_name = opts_dict.get('LIBRARY_NAME')
1✔
583
    platform = opts_dict.get('PLATFORM', 'illumina')
1✔
584
    platform_unit = opts_dict.get('PLATFORM_UNIT')
1✔
585
    sequencing_center = opts_dict.get('SEQUENCING_CENTER')
1✔
586
    run_date = opts_dict.get('RUN_DATE')
1✔
587
    read_group_id = opts_dict.get('READ_GROUP_NAME')
1✔
588

589
    tools.samtools.SamtoolsTool().import_fastq(
1✔
590
        inFastq1, inFastq2, fastqToSamOut,
591
        sample_name=sampleName,
592
        library_name=library_name,
593
        platform=platform,
594
        read_group_id=read_group_id,
595
        platform_unit=platform_unit,
596
        sequencing_center=sequencing_center,
597
        run_date=run_date,
598
        threads=threads
599
    )
600

601
    if header:
1✔
602
        tools.samtools.SamtoolsTool().reheader(fastqToSamOut, header, outBam)
1✔
603

604
    return 0
1✔
605

606

607
def parser_fastq_to_bam(parser=argparse.ArgumentParser()):
1✔
608
    parser.add_argument('inFastq1', help='Input fastq file; 1st end of paired-end reads.')
1✔
609
    parser.add_argument('inFastq2', help='Input fastq file; 2nd end of paired-end reads.')
1✔
610
    parser.add_argument('outBam', help='Output bam file.')
1✔
611
    headerGroup = parser.add_mutually_exclusive_group(required=True)
1✔
612
    headerGroup.add_argument('--sampleName', help='Sample name to insert into the read group header.')
1✔
613
    headerGroup.add_argument('--header', help='Optional text file containing header.')
1✔
614
    parser.add_argument(
1✔
615
        '--JVMmemory',
616
        default=None,
617
        help='Deprecated: kept for backwards compatibility, ignored. (Was for Picard)'
618
    )
619
    parser.add_argument(
1✔
620
        '--picardOptions',
621
        default=[],
622
        nargs='*',
623
        help='''Read group options in Picard format (OPTIONNAME=value).
624
                Supported: LIBRARY_NAME, PLATFORM, PLATFORM_UNIT, SEQUENCING_CENTER,
625
                RUN_DATE, READ_GROUP_NAME. Note that header-related options will be
626
                overwritten by HEADER if present.'''
627
    )
628
    parser.add_argument(
1✔
629
        '--threads',
630
        type=int,
631
        default=None,
632
        help='Number of threads for BAM compression (default: auto)'
633
    )
634
    util.cmd.common_args(parser, (('loglevel', None), ('version', None), ('tmp_dir', None)))
1✔
635
    util.cmd.attach_main(parser, fastq_to_bam, split_args=True)
1✔
636
    return parser
1✔
637

638

639
__commands__.append(('fastq_to_bam', parser_fastq_to_bam))
1✔
640

641

642
def join_paired_fastq(
1✔
643
        inFastqs,
644
        output,
645
        outFormat
646
):
647
    ''' Join paired fastq reads into single reads with Ns
648
    '''
649
    inFastqs = list(inFastqs)
×
650
    if output == '-':
×
651
        output = sys.stdout
×
652
    SeqIO.write(util.file.join_paired_fastq(inFastqs, output_format=outFormat), output, outFormat)
×
653
    return 0
×
654

655

656
def parser_join_paired_fastq(parser=argparse.ArgumentParser()):
1✔
657
    parser.add_argument('output', help='Output file.')
1✔
658
    parser.add_argument('inFastqs', nargs='+', help='Input fastq file (2 if paired, 1 if interleaved)')
1✔
659
    parser.add_argument('--outFormat', default='fastq', help='Output file format.')
1✔
660
    util.cmd.common_args(parser, (('loglevel', None), ('version', None), ('tmp_dir', None)))
1✔
661
    util.cmd.attach_main(parser, join_paired_fastq, split_args=True)
1✔
662
    return parser
1✔
663

664
__commands__.append(('join_paired_fastq', parser_join_paired_fastq))
1✔
665

666

667
# ======================
668
# ***  split_reads   ***
669
# ======================
670
defaultIndexLen = 2
1✔
671
defaultMaxReads = 1000
1✔
672
defaultFormat = 'fastq'
1✔
673

674

675
def split_bam(inBam, outBams):
1✔
676
    '''Split BAM file equally into several output BAM files. '''
677
    samtools = tools.samtools.SamtoolsTool()
×
678
    picard = tools.picard.PicardTools()
×
679

680
    # get totalReadCount and maxReads
681
    # maxReads = totalReadCount / num files, but round up to the nearest
682
    # even number in order to keep read pairs together (assuming the input
683
    # is sorted in query order and has no unmated reads, which can be
684
    # accomplished by Picard RevertSam with SANITIZE=true)
685
    totalReadCount = samtools.count(inBam)
×
686
    maxReads = int(math.ceil(float(totalReadCount) / len(outBams) / 2) * 2)
×
687
    log.info("splitting %d reads into %d files of %d reads each", totalReadCount, len(outBams), maxReads)
×
688

689
    # load BAM header into memory
690
    header = samtools.getHeader(inBam)
×
691
    if 'SO:queryname' not in header[0]:
×
692
        raise Exception('Input BAM file must be sorted in queryame order')
×
693

694
    # dump to bigsam
695
    bigsam = mkstempfname('.sam')
×
696
    samtools.view(['-@', '3'], inBam, bigsam)
×
697

698
    # split bigsam into little ones
699
    with util.file.open_or_gzopen(bigsam, 'rt') as inf:
×
700
        for outBam in outBams:
×
701
            log.info("preparing file " + outBam)
×
702
            tmp_sam_reads = mkstempfname('.sam')
×
703
            with open(tmp_sam_reads, 'wt') as outf:
×
704
                for row in header:
×
705
                    outf.write('\t'.join(row) + '\n')
×
706
                for _ in range(maxReads):
×
707
                    line = inf.readline()
×
708
                    if not line:
×
709
                        break
×
710
                    outf.write(line)
×
711
                if outBam == outBams[-1]:
×
712
                    for line in inf:
×
713
                        outf.write(line)
×
714
            picard.execute(
×
715
                "SamFormatConverter", [
716
                    'INPUT=' + tmp_sam_reads, 'OUTPUT=' + outBam, 'VERBOSITY=WARNING'
717
                ],
718
                JVMmemory='512m'
719
            )
720
            os.unlink(tmp_sam_reads)
×
721
    os.unlink(bigsam)
×
722

723

724
def parser_split_bam(parser=argparse.ArgumentParser()):
1✔
725
    parser.add_argument('inBam', help='Input BAM file.')
1✔
726
    parser.add_argument('outBams', nargs='+', help='Output BAM files')
1✔
727
    util.cmd.common_args(parser, (('loglevel', None), ('version', None), ('tmp_dir', None)))
1✔
728
    util.cmd.attach_main(parser, split_bam, split_args=True)
1✔
729
    return parser
1✔
730

731

732
__commands__.append(('split_bam', parser_split_bam))
1✔
733

734
# =======================
735
# ***  reheader_bam   ***
736
# =======================
737

738

739
def parser_reheader_bam(parser=argparse.ArgumentParser()):
1✔
740
    parser.add_argument('inBam', help='Input reads, BAM format.')
1✔
741
    parser.add_argument('rgMap', help='Tabular file containing three columns: field, old, new.')
1✔
742
    parser.add_argument('outBam', help='Output reads, BAM format.')
1✔
743
    util.cmd.common_args(parser, (('loglevel', None), ('version', None), ('tmp_dir', None)))
1✔
744
    util.cmd.attach_main(parser, main_reheader_bam)
1✔
745
    return parser
1✔
746

747

748
def main_reheader_bam(args):
1✔
749
    ''' Copy a BAM file (inBam to outBam) while renaming elements of the BAM header.
750
        The mapping file specifies which (key, old value, new value) mappings. For
751
        example:
752
            LB        lib1        lib_one
753
            SM        sample1        Sample_1
754
            SM        sample2        Sample_2
755
            SM        sample3        Sample_3
756
            CN        broad        BI
757
    '''
758
    # read mapping file
759
    mapper = dict((a + ':' + b, a + ':' + c) for a, b, c in util.file.read_tabfile(args.rgMap))
×
760
    # read and convert bam header
761
    header_file = mkstempfname('.sam')
×
762
    with open(header_file, 'wt') as outf:
×
763
        for row in tools.samtools.SamtoolsTool().getHeader(args.inBam):
×
764
            if row[0] == '@RG':
×
765
                row = [mapper.get(x, x) for x in row]
×
766
            outf.write('\t'.join(row) + '\n')
×
767
    # write new bam with new header
768
    tools.samtools.SamtoolsTool().reheader(args.inBam, header_file, args.outBam)
×
769
    os.unlink(header_file)
×
770
    return 0
×
771

772

773
__commands__.append(('reheader_bam', parser_reheader_bam))
1✔
774

775

776
def parser_reheader_bams(parser=argparse.ArgumentParser()):
1✔
777
    parser.add_argument('rgMap', help='Tabular file containing three columns: field, old, new.')
1✔
778
    util.cmd.common_args(parser, (('loglevel', None), ('version', None), ('tmp_dir', None)))
1✔
779
    util.cmd.attach_main(parser, main_reheader_bams)
1✔
780
    return parser
1✔
781

782

783
def main_reheader_bams(args):
1✔
784
    ''' Copy BAM files while renaming elements of the BAM header.
785
        The mapping file specifies which (key, old value, new value) mappings. For
786
        example:
787
            LB  lib1  lib_one
788
            SM  sample1 Sample_1
789
            SM  sample2 Sample_2
790
            SM  sample3 Sample_3
791
            CN  broad   BI
792
            FN  in1.bam out1.bam
793
            FN  in2.bam out2.bam
794
    '''
795
    # read mapping file
796
    mapper = dict((a + ':' + b, a + ':' + c) for a, b, c in util.file.read_tabfile(args.rgMap) if a != 'FN')
×
797
    files = list((b, c) for a, b, c in util.file.read_tabfile(args.rgMap) if a == 'FN')
×
798
    header_file = mkstempfname('.sam')
×
799
    # read and convert bam headers
800
    for inBam, outBam in files:
×
801
        if os.path.isfile(inBam):
×
802
            with open(header_file, 'wt') as outf:
×
803
                for row in tools.samtools.SamtoolsTool().getHeader(inBam):
×
804
                    if row[0] == '@RG':
×
805
                        row = [mapper.get(x, x) for x in row]
×
806
                    outf.write('\t'.join(row) + '\n')
×
807
            # write new bam with new header
808
            tools.samtools.SamtoolsTool().reheader(inBam, header_file, outBam)
×
809
    os.unlink(header_file)
×
810
    return 0
×
811

812

813
__commands__.append(('reheader_bams', parser_reheader_bams))
1✔
814

815
# ============================
816
# ***  dup_remove_mvicuna  ***
817
# ============================
818

819

820
class ReadIdStore:
1✔
821
    """
822
    SQLite-backed store for read IDs with O(1) memory footprint.
823

824
    Uses SQLite for disk-based storage with automatic deduplication via
825
    UNIQUE constraint. Supports random sampling for downsampling operations.
826
    """
827

828
    def __init__(self, db_path):
1✔
829
        """
830
        Initialize the read ID store.
831

832
        Args:
833
            db_path: Path to SQLite database file (typically in tmpdir)
834
        """
835
        self.db_path = db_path
1✔
836
        self.conn = sqlite3.connect(db_path)
1✔
837
        # Performance pragmas (safe since db_path is in tmpdir)
838
        self.conn.execute('PRAGMA synchronous = OFF')
1✔
839
        self.conn.execute('PRAGMA journal_mode = OFF')
1✔
840
        self.conn.execute('PRAGMA cache_size = -64000')  # 64MB cache (negative = KiB)
1✔
841
        self.conn.execute('PRAGMA temp_store = MEMORY')
1✔
842
        self.conn.execute('''
1✔
843
            CREATE TABLE IF NOT EXISTS read_ids (
844
                id INTEGER PRIMARY KEY AUTOINCREMENT,
845
                read_id TEXT UNIQUE NOT NULL
846
            )
847
        ''')
848
        self.conn.commit()
1✔
849

850
    def add_from_fastq(self, fastq_path):
1✔
851
        """
852
        Extract read IDs from a FASTQ file and add to the store.
853

854
        Handles paired-end reads by stripping /1 or /2 suffixes.
855
        Duplicates are automatically ignored via INSERT OR IGNORE.
856

857
        Args:
858
            fastq_path: Path to FASTQ file (can be gzipped)
859

860
        Returns:
861
            Number of new read IDs added
862
        """
863
        cursor = self.conn.cursor()
1✔
864
        count_before = len(self)
1✔
865

866
        with util.file.open_or_gzopen(fastq_path, 'rt') as inf:
1✔
867
            line_num = 0
1✔
868
            batch = []
1✔
869
            for line in inf:
1✔
870
                if (line_num % 4) == 0:
1✔
871
                    idVal = line.rstrip('\n')[1:]
1✔
872
                    # Remove /1 or /2 suffix if present (paired-end reads)
873
                    if idVal.endswith('/1') or idVal.endswith('/2'):
1✔
874
                        idVal = idVal[:-2]
1✔
875
                    batch.append((idVal,))
1✔
876

877
                    # Batch inserts for performance
878
                    if len(batch) >= 10000:
1✔
879
                        cursor.executemany(
1✔
880
                            'INSERT OR IGNORE INTO read_ids (read_id) VALUES (?)',
881
                            batch
882
                        )
883
                        batch = []
1✔
884
                line_num += 1
1✔
885

886
            # Insert remaining batch
887
            if batch:
1✔
888
                cursor.executemany(
1✔
889
                    'INSERT OR IGNORE INTO read_ids (read_id) VALUES (?)',
890
                    batch
891
                )
892

893
        self.conn.commit()
1✔
894
        return len(self) - count_before
1✔
895

896
    def add_from_readlist(self, readlist_path):
1✔
897
        """
898
        Add read IDs from a text file (one ID per line).
899

900
        Args:
901
            readlist_path: Path to text file with one read ID per line.
902
                           Empty lines and whitespace are stripped.
903
        """
904
        with util.file.open_or_gzopen(readlist_path, 'rt') as f:
×
905
            self.extend(line.strip() for line in f if line.strip())
×
906

907
    def __len__(self):
1✔
908
        """Return the number of unique read IDs in the store."""
909
        cursor = self.conn.execute('SELECT COUNT(*) FROM read_ids')
1✔
910
        return cursor.fetchone()[0]
1✔
911

912
    def __iter__(self):
1✔
913
        """Iterate over all read IDs in insertion order. O(1) memory."""
914
        cursor = self.conn.execute('SELECT read_id FROM read_ids ORDER BY id')
1✔
915
        for row in cursor:
1✔
916
            yield row[0]
1✔
917

918
    def __contains__(self, read_id):
1✔
919
        """Check if a read ID exists in the store."""
920
        cursor = self.conn.execute(
1✔
921
            'SELECT 1 FROM read_ids WHERE read_id = ? LIMIT 1',
922
            (read_id,)
923
        )
924
        return cursor.fetchone() is not None
1✔
925

926
    def contains_batch(self, read_ids, batch_size=10000):
1✔
927
        """
928
        Check multiple read IDs for membership in batched queries.
929

930
        Args:
931
            read_ids: List of read ID strings to check
932
            batch_size: Number of IDs to query at once (default 10000)
933

934
        Returns:
935
            Set of read IDs that exist in the store
936
        """
937
        found = set()
1✔
938
        batch = []
1✔
939
        for read_id in read_ids:
1✔
940
            batch.append(read_id)
1✔
941
            if len(batch) >= batch_size:
1✔
942
                placeholders = ','.join('?' * len(batch))
1✔
943
                cursor = self.conn.execute(
1✔
944
                    f'SELECT read_id FROM read_ids WHERE read_id IN ({placeholders})',
945
                    batch
946
                )
947
                found.update(row[0] for row in cursor)
1✔
948
                batch = []
1✔
949
        if batch:
1✔
950
            placeholders = ','.join('?' * len(batch))
1✔
951
            cursor = self.conn.execute(
1✔
952
                f'SELECT read_id FROM read_ids WHERE read_id IN ({placeholders})',
953
                batch
954
            )
955
            found.update(row[0] for row in cursor)
1✔
956
        return found
1✔
957

958
    def add(self, read_id):
1✔
959
        """
960
        Add a single read ID to the store.
961

962
        Args:
963
            read_id: The read ID string to add
964

965
        Returns:
966
            True if the read ID was added, False if it already existed
967
        """
968
        cursor = self.conn.execute(
1✔
969
            'INSERT OR IGNORE INTO read_ids (read_id) VALUES (?)',
970
            (read_id,)
971
        )
972
        self.conn.commit()
1✔
973
        return cursor.rowcount > 0
1✔
974

975
    def extend(self, read_ids):
1✔
976
        """
977
        Add multiple read IDs to the store efficiently.
978

979
        Uses executemany for batch insertion. O(1) memory - processes
980
        the input iterable in chunks.
981

982
        Args:
983
            read_ids: Iterable of read ID strings
984

985
        Returns:
986
            Number of new read IDs added
987
        """
988
        count_before = len(self)
1✔
989
        cursor = self.conn.cursor()
1✔
990

991
        # Process in batches for memory efficiency
992
        batch = []
1✔
993
        for read_id in read_ids:
1✔
994
            batch.append((read_id,))
1✔
995
            if len(batch) >= 10000:
1✔
996
                cursor.executemany(
×
997
                    'INSERT OR IGNORE INTO read_ids (read_id) VALUES (?)',
998
                    batch
999
                )
1000
                batch = []
×
1001

1002
        # Insert remaining batch
1003
        if batch:
1✔
1004
            cursor.executemany(
1✔
1005
                'INSERT OR IGNORE INTO read_ids (read_id) VALUES (?)',
1006
                batch
1007
            )
1008

1009
        self.conn.commit()
1✔
1010
        return len(self) - count_before
1✔
1011

1012
    def __delitem__(self, read_id):
1✔
1013
        """
1014
        Delete a read ID from the store.
1015

1016
        Args:
1017
            read_id: The read ID string to delete
1018

1019
        Raises:
1020
            KeyError: If the read ID does not exist
1021
        """
1022
        cursor = self.conn.execute(
1✔
1023
            'DELETE FROM read_ids WHERE read_id = ?',
1024
            (read_id,)
1025
        )
1026
        self.conn.commit()
1✔
1027
        if cursor.rowcount == 0:
1✔
1028
            raise KeyError(read_id)
1✔
1029

1030
    def discard(self, read_id):
1✔
1031
        """
1032
        Remove a read ID if it exists, without raising an error if absent.
1033

1034
        Args:
1035
            read_id: The read ID string to remove
1036
        """
1037
        self.conn.execute(
1✔
1038
            'DELETE FROM read_ids WHERE read_id = ?',
1039
            (read_id,)
1040
        )
1041
        self.conn.commit()
1✔
1042

1043
    def write_to_file(self, out_path, max_reads=None):
1✔
1044
        """
1045
        Write read IDs to a file.
1046

1047
        Args:
1048
            out_path: Output file path
1049
            max_reads: If specified, randomly sample this many read IDs
1050

1051
        Returns:
1052
            Number of read IDs written
1053
        """
1054
        if max_reads is not None and max_reads < len(self):
1✔
1055
            # Random sampling using ORDER BY RANDOM()
1056
            query = 'SELECT read_id FROM read_ids ORDER BY RANDOM() LIMIT ?'
1✔
1057
            cursor = self.conn.execute(query, (max_reads,))
1✔
1058
        else:
1059
            # Return all in insertion order
1060
            query = 'SELECT read_id FROM read_ids ORDER BY id'
1✔
1061
            cursor = self.conn.execute(query)
1✔
1062

1063
        count = 0
1✔
1064
        with open(out_path, 'wt') as outf:
1✔
1065
            for row in cursor:
1✔
1066
                outf.write(row[0] + '\n')
1✔
1067
                count += 1
1✔
1068

1069
        return count
1✔
1070

1071
    def shrink_to_subsample(self, n):
1✔
1072
        """
1073
        Destructively shrink the store to a random subsample of n elements.
1074

1075
        This modifies the store in-place, keeping exactly n randomly selected
1076
        read IDs and deleting all others. If the store has n or fewer elements,
1077
        no changes are made.
1078

1079
        Args:
1080
            n: Number of read IDs to keep
1081

1082
        Note:
1083
            Uses SQLite's ORDER BY RANDOM() for random selection. The operation
1084
            is O(N log N) where N is the current store size due to random sorting.
1085
        """
1086
        current_size = len(self)
1✔
1087
        if current_size <= n:
1✔
1088
            return  # Nothing to do
1✔
1089

1090
        # Delete all rows except n randomly selected ones
1091
        self.conn.execute('''
1✔
1092
            DELETE FROM read_ids
1093
            WHERE id NOT IN (
1094
                SELECT id FROM read_ids
1095
                ORDER BY RANDOM()
1096
                LIMIT ?
1097
            )
1098
        ''', (n,))
1099
        self.conn.commit()
1✔
1100

1101
    def filter_bam_by_ids(self, inBam, outBam, include=True):
1✔
1102
        """
1103
        Filter a BAM file to reads matching (or not matching) IDs in this store.
1104

1105
        This is a faster, O(1) memory replacement for picard.FilterSamReadsTool.
1106
        Headers are fully preserved from input to output.
1107

1108
        Args:
1109
            inBam: Input BAM file path
1110
            outBam: Output BAM file path
1111
            include: If True, keep only reads with IDs in the store (inclusion list).
1112
                     If False, keep only reads with IDs NOT in the store (exclusion list).
1113

1114
        Note:
1115
            For paired-end reads, both mates share the same QNAME so both will be
1116
            kept or excluded together based on whether the QNAME is in the store.
1117
        """
1118
        # Handle empty input BAM
1119
        samtools = tools.samtools.SamtoolsTool()
1✔
1120
        if samtools.isEmpty(inBam):
1✔
1121
            shutil.copyfile(inBam, outBam)
1✔
1122
            return
1✔
1123

1124
        # Handle empty read ID store
1125
        if len(self) == 0:
1✔
1126
            if include:
1✔
1127
                # Include mode with empty list = empty output (keep header only)
1128
                with pysam.AlignmentFile(inBam, 'rb', check_sq=False) as inb:
1✔
1129
                    with pysam.AlignmentFile(outBam, 'wb', header=inb.header) as outf:
1✔
1130
                        pass  # Write header only, no reads
1✔
1131
            else:
1132
                # Exclude mode with empty list = keep all reads
1133
                shutil.copyfile(inBam, outBam)
1✔
1134
            return
1✔
1135

1136
        # Hybrid approach: samtools subprocess pipes + batched SQLite queries
1137
        start = time.time()
1✔
1138
        read_count = 0
1✔
1139
        write_count = 0
1✔
1140
        lookup_time = 0.0
1✔
1141
        batch_size = 50000
1✔
1142

1143
        samtools_path = samtools.install_and_get_path()
1✔
1144

1145
        # Read: samtools view -h --no-PG input.bam -> SAM text (with header, no PG added)
1146
        # Write: samtools view -b --no-PG -o output.bam - (converts SAM to BAM, no PG added)
1147
        read_proc = subprocess.Popen(
1✔
1148
            [samtools_path, 'view', '-h', '--no-PG', inBam],
1149
            stdout=subprocess.PIPE)
1150
        write_proc = subprocess.Popen(
1✔
1151
            [samtools_path, 'view', '-b', '-@', '2', '--no-PG', '-o', outBam, '-'],
1152
            stdin=subprocess.PIPE)
1153

1154
        try:
1✔
1155
            batch = []  # [(line, qname), ...]
1✔
1156
            for line in read_proc.stdout:
1✔
1157
                # Header lines (start with @) are passed through unchanged
1158
                if line.startswith(b'@'):
1✔
1159
                    write_proc.stdin.write(line)
1✔
1160
                    continue
1✔
1161

1162
                read_count += 1
1✔
1163

1164
                # Parse only QNAME (first field before tab)
1165
                tab_pos = line.find(b'\t')
1✔
1166
                qname = line[:tab_pos].decode()
1✔
1167
                batch.append((line, qname))
1✔
1168

1169
                if len(batch) >= batch_size:
1✔
1170
                    t0 = time.time()
×
1171
                    found = self.contains_batch([q for _, q in batch])
×
1172
                    lookup_time += time.time() - t0
×
1173

1174
                    for ln, q in batch:
×
1175
                        if (include and q in found) or (not include and q not in found):
×
1176
                            write_proc.stdin.write(ln)
×
1177
                            write_count += 1
×
1178
                    batch = []
×
1179

1180
            # Process remaining batch
1181
            if batch:
1✔
1182
                t0 = time.time()
1✔
1183
                found = self.contains_batch([q for _, q in batch])
1✔
1184
                lookup_time += time.time() - t0
1✔
1185

1186
                for ln, q in batch:
1✔
1187
                    if (include and q in found) or (not include and q not in found):
1✔
1188
                        write_proc.stdin.write(ln)
1✔
1189
                        write_count += 1
1✔
1190
        finally:
1191
            write_proc.stdin.close()
1✔
1192
            write_proc.wait()
1✔
1193
            read_proc.wait()
1✔
1194

1195
        elapsed = time.time() - start
1✔
1196
        log.debug(f"PERF: filter_time={elapsed:.2f}s lookup_time={lookup_time:.2f}s "
1✔
1197
                  f"reads={read_count} written={write_count} batch_size={batch_size}")
1198

1199
    def close(self):
1✔
1200
        """Close the database connection."""
1201
        self.conn.close()
1✔
1202

1203
    def __enter__(self):
1✔
1204
        return self
1✔
1205

1206
    def __exit__(self, exc_type, exc_val, exc_tb):
1✔
1207
        self.close()
1✔
1208
        return False
1✔
1209

1210

1211
def mvicuna_fastqs_to_readlist(inFastq1, inFastq2, readList):
1✔
1212
    # Run M-Vicuna on FASTQ files
1213
    outFastq1 = mkstempfname('.1.fastq')
×
1214
    outFastq2 = mkstempfname('.2.fastq')
×
1215
    if inFastq2 is None or os.path.getsize(inFastq2) < 10:
×
1216
        tools.mvicuna.MvicunaTool().rmdup_single(inFastq1, outFastq1)
×
1217
    else:
1218
        tools.mvicuna.MvicunaTool().rmdup((inFastq1, inFastq2), (outFastq1, outFastq2), None)
×
1219

1220
    # Make a list of reads to keep
1221
    with open(readList, 'at') as outf:
×
1222
        for fq in (outFastq1, outFastq2):
×
1223
            with util.file.open_or_gzopen(fq, 'rt') as inf:
×
1224
                line_num = 0
×
1225
                for line in inf:
×
1226
                    if (line_num % 4) == 0:
×
1227
                        idVal = line.rstrip('\n')[1:]
×
1228
                        if idVal.endswith('/1'):
×
1229
                            outf.write(idVal[:-2] + '\n')
×
1230
                        # single-end reads do not have /1 /2 mate suffix
1231
                        # so pass through their IDs
1232
                        if not (idVal.endswith('/1') or idVal.endswith('/2')):
×
1233
                            outf.write(idVal + '\n')
×
1234
                    line_num += 1
×
1235
    os.unlink(outFastq1)
×
1236
    os.unlink(outFastq2)
×
1237

1238

1239
def rmdup_cdhit_bam(inBam, outBam, max_mismatches=None, jvm_memory=None):
1✔
1240
    ''' Remove duplicate reads from BAM file using cd-hit-dup.
1241
    '''
1242
    max_mismatches = max_mismatches or 4
1✔
1243
    tmp_dir = tempfile.mkdtemp()
1✔
1244

1245
    tools.picard.SplitSamByLibraryTool().execute(inBam, tmp_dir)
1✔
1246

1247
    s2fq_tool = tools.picard.SamToFastqTool()
1✔
1248
    cdhit = tools.cdhit.CdHit()
1✔
1249
    out_bams = []
1✔
1250
    for f in os.listdir(tmp_dir):
1✔
1251
        out_bam = mkstempfname('.bam')
1✔
1252
        out_bams.append(out_bam)
1✔
1253
        library_sam = os.path.join(tmp_dir, f)
1✔
1254

1255
        in_fastqs = mkstempfname('.1.fastq'), mkstempfname('.2.fastq')
1✔
1256

1257
        s2fq_tool.execute(library_sam, in_fastqs[0], in_fastqs[1])
1✔
1258
        if not os.path.getsize(in_fastqs[0]) > 0 and not os.path.getsize(in_fastqs[1]) > 0:
1✔
1259
            continue
1✔
1260

1261
        out_fastqs = mkstempfname('.1.fastq'), mkstempfname('.2.fastq')
1✔
1262
        options = {
1✔
1263
            '-e': max_mismatches,
1264
        }
1265
        if in_fastqs[1] is not None and os.path.getsize(in_fastqs[1]) > 10:
1✔
1266
            options['-i2'] = in_fastqs[1]
1✔
1267
            options['-o2'] = out_fastqs[1]
1✔
1268

1269
        log.info("executing cd-hit-dup on library " + library_sam)
1✔
1270
        # cd-hit-dup cannot operate on piped fastq input because it reads twice
1271
        # Run cd-hit-dup synchronously (not in background) to ensure output files are complete
1272
        # before FastqToSamTool tries to read them
1273
        cdhit.execute('cd-hit-dup', in_fastqs[0], out_fastqs[0], options=options, background=False)
1✔
1274

1275
        tools.samtools.SamtoolsTool().import_fastq(
1✔
1276
            out_fastqs[0], out_fastqs[1], out_bam,
1277
            sample_name=f,
1278
        )
1279
        for fn in in_fastqs:
1✔
1280
            os.unlink(fn)
1✔
1281

1282
    with util.file.fifo(name='merged.sam') as merged_bam:
1✔
1283
        merge_opts = ['SORT_ORDER=queryname']
1✔
1284
        tools.picard.MergeSamFilesTool().execute(out_bams, merged_bam, picardOptions=merge_opts, JVMmemory=jvm_memory, background=True)
1✔
1285
        tools.picard.ReplaceSamHeaderTool().execute(merged_bam, inBam, outBam, JVMmemory=jvm_memory)
1✔
1286

1287

1288
def parser_rmdup_cdhit_bam(parser=argparse.ArgumentParser()):
1✔
1289
    parser.add_argument('inBam', help='Input reads, BAM format.')
1✔
1290
    parser.add_argument('outBam', help='Output reads, BAM format.')
1✔
1291
    parser.add_argument(
1✔
1292
        '--JVMmemory',
1293
        default=tools.picard.FilterSamReadsTool.jvmMemDefault,
1294
        help='JVM virtual memory size (default: %(default)s)',
1295
        dest='jvm_memory'
1296
    )
1297
    util.cmd.common_args(parser, (('loglevel', None), ('version', None), ('tmp_dir', None)))
1✔
1298
    util.cmd.attach_main(parser, rmdup_cdhit_bam, split_args=True)
1✔
1299
    return parser
1✔
1300

1301

1302
__commands__.append(('rmdup_cdhit_bam', parser_rmdup_cdhit_bam))
1✔
1303

1304
def _merge_fastqs_and_mvicuna(lb, files):
1✔
1305
    readList = mkstempfname('.keep_reads.txt')
×
1306
    log.info("executing M-Vicuna DupRm on library " + lb)
×
1307

1308
    # create merged FASTQs per library
1309
    infastqs = (mkstempfname('.1.fastq'), mkstempfname('.2.fastq'))
×
1310
    for d in range(2):
×
1311
        with open(infastqs[d], 'wt') as outf:
×
1312
            for fprefix in files:
×
1313
                fn = '%s_%d.fastq' % (fprefix, d + 1)
×
1314

1315
                if os.path.isfile(fn):
×
1316
                    with open(fn, 'rt') as inf:
×
1317
                        for line in inf:
×
1318
                            outf.write(line)
×
1319
                    os.unlink(fn)
×
1320
                else:
1321
                    log.warning(
×
1322
                        """no reads found in %s,
1323
                                assuming that's because there's no reads in that read group""", fn
1324
                    )
1325

1326
    # M-Vicuna DupRm to see what we should keep (append IDs to running file)
1327
    if os.path.getsize(infastqs[0]) > 0 or os.path.getsize(infastqs[1]) > 0:
×
1328
        mvicuna_fastqs_to_readlist(infastqs[0], infastqs[1], readList)
×
1329
    for fn in infastqs:
×
1330
        os.unlink(fn)
×
1331

1332
    return readList
×
1333

1334
def rmdup_mvicuna_bam(inBam, outBam, threads=None):
1✔
1335
    ''' Remove duplicate reads from BAM file using M-Vicuna. The
1336
        primary advantage to this approach over Picard's MarkDuplicates tool
1337
        is that Picard requires that input reads are aligned to a reference,
1338
        and M-Vicuna can operate on unaligned reads.
1339
    '''
1340

1341
    # Convert BAM -> FASTQ pairs per read group and load all read groups
1342
    tempDir = tempfile.mkdtemp()
1✔
1343
    tools.picard.SamToFastqTool().per_read_group(inBam, tempDir, picardOptions=['VALIDATION_STRINGENCY=LENIENT'])
1✔
1344
    read_groups = [x[1:] for x in tools.samtools.SamtoolsTool().getHeader(inBam) if x[0] == '@RG']
1✔
1345
    read_groups = [dict(pair.split(':', 1) for pair in rg) for rg in read_groups]
1✔
1346

1347
    # Collect FASTQ pairs for each library
1348
    lb_to_files = {}
1✔
1349
    for rg in read_groups:
1✔
1350
        lb_to_files.setdefault(rg.get('LB', 'none'), set())
1✔
1351
        fname = rg['ID']
1✔
1352
        lb_to_files[rg.get('LB', 'none')].add(os.path.join(tempDir, fname))
1✔
1353
    log.info("found %d distinct libraries and %d read groups", len(lb_to_files), len(read_groups))
1✔
1354

1355
    # Create ReadIdStore and collect read IDs from all libraries
1356
    with util.file.tmp_dir(suffix='_mvicuna_filter') as filter_tmpdir:
1✔
1357
        db_path = os.path.join(filter_tmpdir, 'read_ids.db')
1✔
1358
        with ReadIdStore(db_path) as store:
1✔
1359
            # For each library, merge FASTQs and run M-Vicuna, collecting read IDs
1360
            with concurrent.futures.ProcessPoolExecutor(
1✔
1361
                    max_workers=threads or util.misc.available_cpu_count()) as executor:
1362
                futures = [executor.submit(_merge_fastqs_and_mvicuna, lb, files)
1✔
1363
                           for lb, files in lb_to_files.items()]
1364
                for future in concurrent.futures.as_completed(futures):
1✔
1365
                    log.info("mvicuna finished processing library")
1✔
1366
                    try:
1✔
1367
                        readList = future.result()
1✔
1368
                        # Stream read IDs directly into store (no intermediate concat)
1369
                        with open(readList, 'rt') as f:
1✔
1370
                            store.extend(line.strip() for line in f if line.strip())
1✔
1371
                        os.unlink(readList)  # Clean up per-library file immediately
1✔
1372
                    except Exception as exc:
×
1373
                        log.error('mvicuna process call generated an exception: %s' % (exc))
×
1374
                        raise
×
1375

1376
            # Filter original input BAM against keep-list
1377
            store.filter_bam_by_ids(inBam, outBam, include=True)
1✔
1378

1379
    return 0
1✔
1380

1381

1382
def parser_rmdup_mvicuna_bam(parser=argparse.ArgumentParser()):
1✔
1383
    parser.add_argument('inBam', help='Input reads, BAM format.')
1✔
1384
    parser.add_argument('outBam', help='Output reads, BAM format.')
1✔
1385
    util.cmd.common_args(parser, (('threads',None), ('loglevel', None), ('version', None), ('tmp_dir', None)))
1✔
1386
    util.cmd.attach_main(parser, rmdup_mvicuna_bam, split_args=True)
1✔
1387
    return parser
1✔
1388

1389

1390
__commands__.append(('rmdup_mvicuna_bam', parser_rmdup_mvicuna_bam))
1✔
1391

1392

1393
def rmdup_bbnorm_bam(inBam, outBam,
1✔
1394
                      target=None, k=None, passes=None,
1395
                      memory=None, threads=None,
1396
                      min_input_reads=None, max_output_reads=None):
1397
    """
1398
    Remove duplicate/normalize reads from BAM file using BBNorm.
1399

1400
    Convert BAM to interleaved FASTQ, run bbnorm, extract kept read IDs,
1401
    and filter original BAM to those IDs using pysam (O(1) memory).
1402

1403
    Args:
1404
        inBam: Input BAM file
1405
        outBam: Output BAM file
1406
        target: BBNorm target normalization depth (default: bbnorm default of 100)
1407
        k: Kmer length (default: bbnorm default of 31)
1408
        passes: Number of passes (default: bbnorm default of 2)
1409
        memory: Java memory for bbnorm (e.g., "4g")
1410
        threads: Number of threads for bbnorm
1411
        min_input_reads: Skip processing if input has fewer reads (copy input to output)
1412
        max_output_reads: Randomly downsample keep-list if larger than this
1413
    """
1414
    samtools = tools.samtools.SamtoolsTool()
1✔
1415

1416
    # Count input reads
1417
    input_read_count = samtools.count(inBam)
1✔
1418
    log.info("Input BAM has %d reads", input_read_count)
1✔
1419

1420
    # Skip processing if empty or below min_input_reads threshold
1421
    min_threshold = min_input_reads if min_input_reads is not None else 1
1✔
1422
    if input_read_count < min_threshold:
1✔
1423
        if min_input_reads is not None:
1✔
1424
            log.info("Input read count %d below min_input_reads %d, copying input to output",
1✔
1425
                     input_read_count, min_input_reads)
1426
        else:
1427
            log.info("Input BAM is empty, copying to output")
1✔
1428
        shutil.copyfile(inBam, outBam)
1✔
1429
        return 0
1✔
1430

1431
    with util.file.tmp_dir(suffix='_bbnorm') as tmpdir:
1✔
1432
        # Convert BAM to interleaved FASTQ using samtools bam2fq
1433
        inFastq = os.path.join(tmpdir, 'input.fastq')
1✔
1434
        samtools.bam2fq(inBam, inFastq)  # Single file = interleaved output
1✔
1435

1436
        # Run bbnorm
1437
        outFastq = os.path.join(tmpdir, 'output.fastq')
1✔
1438
        tools.bbmap.BBMapTool().bbnorm(
1✔
1439
            inFastq, outFastq,
1440
            tmpdir=tmpdir, target=target, k=k, passes=passes,
1441
            mindepth=0, threads=threads, memory=memory
1442
        )
1443

1444
        # Extract read IDs from bbnorm output FASTQ using SQLite for O(1) memory
1445
        db_path = os.path.join(tmpdir, 'read_ids.db')
1✔
1446

1447
        with ReadIdStore(db_path) as store:
1✔
1448
            store.add_from_fastq(outFastq)
1✔
1449
            num_ids = len(store)
1✔
1450
            log.info("BBNorm retained %d read IDs", num_ids)
1✔
1451

1452
            # Downsample if needed (modifies store in-place via SQL)
1453
            if max_output_reads is not None and num_ids > max_output_reads:
1✔
1454
                log.info("Downsampling from %d to %d read IDs", num_ids, max_output_reads)
1✔
1455
                store.shrink_to_subsample(max_output_reads)
1✔
1456

1457
            # Filter original BAM directly using ReadIdStore
1458
            store.filter_bam_by_ids(inBam, outBam, include=True)
1✔
1459

1460
    # Count output reads
1461
    output_read_count = samtools.count(outBam)
1✔
1462
    log.info("Output BAM has %d reads (%.1f%% of input)",
1✔
1463
             output_read_count, 100.0 * output_read_count / max(input_read_count, 1))
1464

1465
    return 0
1✔
1466

1467

1468
def parser_rmdup_bbnorm_bam(parser=argparse.ArgumentParser()):
1✔
1469
    parser.add_argument('inBam', help='Input reads, BAM format.')
1✔
1470
    parser.add_argument('outBam', help='Output reads, BAM format.')
1✔
1471
    parser.add_argument(
1✔
1472
        '--target',
1473
        type=int,
1474
        default=None,
1475
        help='BBNorm target normalization depth (default: bbnorm default of 100)'
1476
    )
1477
    parser.add_argument(
1✔
1478
        '--kmerLength',
1479
        dest='k',
1480
        type=int,
1481
        default=None,
1482
        help='Kmer length for bbnorm (default: bbnorm default of 31)'
1483
    )
1484
    parser.add_argument(
1✔
1485
        '--passes',
1486
        type=int,
1487
        default=None,
1488
        help='Number of bbnorm passes (default: bbnorm default of 2)'
1489
    )
1490
    parser.add_argument(
1✔
1491
        '--memory',
1492
        default=None,
1493
        help='Java memory for bbnorm (e.g., "4g", "8g")'
1494
    )
1495
    parser.add_argument(
1✔
1496
        '--minInputReads',
1497
        dest='min_input_reads',
1498
        type=int,
1499
        default=None,
1500
        help='Skip processing if input has fewer than this many reads'
1501
    )
1502
    parser.add_argument(
1✔
1503
        '--maxOutputReads',
1504
        dest='max_output_reads',
1505
        type=int,
1506
        default=None,
1507
        help='Randomly downsample output to at most this many read IDs'
1508
    )
1509
    util.cmd.common_args(parser, (('threads', None), ('loglevel', None), ('version', None), ('tmp_dir', None)))
1✔
1510
    util.cmd.attach_main(parser, rmdup_bbnorm_bam, split_args=True)
1✔
1511
    return parser
1✔
1512

1513

1514
__commands__.append(('rmdup_bbnorm_bam', parser_rmdup_bbnorm_bam))
1✔
1515

1516

1517
def parser_rmdup_prinseq_fastq(parser=argparse.ArgumentParser()):
1✔
1518
    parser.add_argument('inFastq1', help='Input fastq file; 1st end of paired-end reads.')
1✔
1519
    parser.add_argument('inFastq2', help='Input fastq file; 2nd end of paired-end reads.')
1✔
1520
    parser.add_argument('outFastq1', help='Output fastq file; 1st end of paired-end reads.')
1✔
1521
    parser.add_argument('outFastq2', help='Output fastq file; 2nd end of paired-end reads.')
1✔
1522
    parser.add_argument(
1✔
1523
        "--includeUnmated",
1524
        help="Include unmated reads in the main output fastq files (default: %(default)s)",
1525
        default=False,
1526
        action="store_true"
1527
    )
1528
    parser.add_argument('--unpairedOutFastq1', default=None, help='File name of output unpaired reads from 1st end of paired-end reads (independent of --includeUnmated)')
1✔
1529
    parser.add_argument('--unpairedOutFastq2', default=None, help='File name of output unpaired reads from 2nd end of paired-end reads (independent of --includeUnmated)')
1✔
1530
    util.cmd.common_args(parser, (('loglevel', None), ('version', None), ('tmp_dir', None)))
1✔
1531
    util.cmd.attach_main(parser, main_rmdup_prinseq_fastq)
1✔
1532
    return parser
1✔
1533

1534

1535
def main_rmdup_prinseq_fastq(args):
1✔
1536
    ''' Run prinseq-lite's duplicate removal operation on paired-end
1537
        reads.  Also removes reads with more than one N.
1538
    '''
1539
    prinseq = tools.prinseq.PrinseqTool()
×
1540
    prinseq.rmdup_fastq_paired(args.inFastq1, args.inFastq2, args.outFastq1, args.outFastq2, args.includeUnmated, args.unpairedOutFastq1, args.unpairedOutFastq2)
×
1541
    return 0
×
1542

1543

1544
__commands__.append(('rmdup_prinseq_fastq', parser_rmdup_prinseq_fastq))
1✔
1545

1546

1547
def filter_bam_mapped_only(inBam, outBam):
1✔
1548
    ''' Samtools to reduce a BAM file to only reads that are
1549
        aligned (-F 4) with a non-zero mapping quality (-q 1)
1550
        and are not marked as a PCR/optical duplicate (-F 1024).
1551
    '''
1552
    tools.samtools.SamtoolsTool().view(['-b', '-q', '1', '-F', '1028'], inBam, outBam)
×
1553
    tools.picard.BuildBamIndexTool().execute(outBam)
×
1554
    return 0
×
1555

1556

1557
def parser_filter_bam_mapped_only(parser=argparse.ArgumentParser()):
1✔
1558
    parser.add_argument('inBam', help='Input aligned reads, BAM format.')
1✔
1559
    parser.add_argument('outBam', help='Output sorted indexed reads, filtered to aligned-only, BAM format.')
1✔
1560
    util.cmd.common_args(parser, (('loglevel', None), ('version', None), ('tmp_dir', None)))
1✔
1561
    util.cmd.attach_main(parser, filter_bam_mapped_only, split_args=True)
1✔
1562
    return parser
1✔
1563

1564

1565
__commands__.append(('filter_bam_mapped_only', parser_filter_bam_mapped_only))
1✔
1566

1567
# ======= Novoalign ========
1568

1569

1570
def parser_novoalign(parser=argparse.ArgumentParser()):
1✔
1571
    parser.add_argument('inBam', help='Input reads, BAM format.')
1✔
1572
    parser.add_argument('refFasta', help='Reference genome, FASTA format, pre-indexed by Novoindex.')
1✔
1573
    parser.add_argument('outBam', help='Output reads, BAM format (aligned).')
1✔
1574
    parser.add_argument('--options', default='-r Random', help='Novoalign options (default: %(default)s)')
1✔
1575
    parser.add_argument('--min_qual', default=0, help='Filter outBam to minimum mapping quality (default: %(default)s)')
1✔
1576
    parser.add_argument(
1✔
1577
        '--JVMmemory',
1578
        default=tools.picard.SortSamTool.jvmMemDefault,
1579
        help='JVM virtual memory size (default: %(default)s)'
1580
    )
1581
    parser.add_argument(
1✔
1582
        '--NOVOALIGN_LICENSE_PATH',
1583
        default=None,
1584
        dest="novoalign_license_path",
1585
        help='A path to the novoalign.lic file. This overrides the NOVOALIGN_LICENSE_PATH environment variable. (default: %(default)s)'
1586
    )
1587
    util.cmd.common_args(parser, (('loglevel', None), ('version', None), ('tmp_dir', None)))
1✔
1588
    util.cmd.attach_main(parser, main_novoalign)
1✔
1589
    return parser
1✔
1590

1591

1592
def main_novoalign(args):
1✔
1593
    '''Align reads with Novoalign. Sort and index BAM output.'''
1594
    tools.novoalign.NovoalignTool(license_path=args.novoalign_license_path).execute(
×
1595
        args.inBam,
1596
        args.refFasta,
1597
        args.outBam,
1598
        options=args.options.split(),
1599
        min_qual=args.min_qual,
1600
        JVMmemory=args.JVMmemory
1601
    )
1602
    return 0
×
1603

1604

1605
__commands__.append(('novoalign', parser_novoalign))
1✔
1606

1607

1608
def parser_novoindex(parser=argparse.ArgumentParser()):
1✔
1609
    parser.add_argument('refFasta', help='Reference genome, FASTA format.')
1✔
1610
    parser.add_argument(
1✔
1611
        '--NOVOALIGN_LICENSE_PATH',
1612
        default=None,
1613
        dest="novoalign_license_path",
1614
        help='A path to the novoalign.lic file. This overrides the NOVOALIGN_LICENSE_PATH environment variable. (default: %(default)s)'
1615
    )
1616
    util.cmd.common_args(parser, (('loglevel', None), ('version', None)))
1✔
1617
    util.cmd.attach_main(parser, main_novoindex)
1✔
1618
    return parser
1✔
1619

1620
def main_novoindex(args):
1✔
1621
    tools.novoalign.NovoalignTool(license_path=args.novoalign_license_path).index_fasta(args.refFasta)
×
1622
    return 0
×
1623

1624

1625
__commands__.append(('novoindex', parser_novoindex))
1✔
1626

1627
# ========= GATK ==========
1628

1629

1630
def parser_gatk_ug(parser=argparse.ArgumentParser()):
1✔
1631
    parser.add_argument('inBam', help='Input reads, BAM format.')
1✔
1632
    parser.add_argument('refFasta', help='Reference genome, FASTA format, pre-indexed by Picard.')
1✔
1633
    parser.add_argument(
1✔
1634
        'outVcf',
1635
        help='''Output calls in VCF format. If this filename ends with .gz,
1636
        GATK will BGZIP compress the output and produce a Tabix index file as well.'''
1637
    )
1638
    parser.add_argument(
1✔
1639
        '--options',
1640
        default='--min_base_quality_score 15 -ploidy 4',
1641
        help='UnifiedGenotyper options (default: %(default)s)'
1642
    )
1643
    parser.add_argument(
1✔
1644
        '--JVMmemory',
1645
        default=tools.gatk.GATKTool.jvmMemDefault,
1646
        help='JVM virtual memory size (default: %(default)s)'
1647
    )
1648
    parser.add_argument(
1✔
1649
        '--GATK_PATH',
1650
        default=None,
1651
        help='A path containing the GATK jar file. This overrides the GATK_ENV environment variable or the GATK conda package. (default: %(default)s)'
1652
    )
1653
    util.cmd.common_args(parser, (('loglevel', None), ('version', None), ('tmp_dir', None)))
1✔
1654
    util.cmd.attach_main(parser, main_gatk_ug)
1✔
1655
    return parser
1✔
1656

1657

1658
def main_gatk_ug(args):
1✔
1659
    '''Call genotypes using the GATK UnifiedGenotyper.'''
1660
    tools.gatk.GATKTool(path=args.GATK_PATH).ug(
×
1661
        args.inBam, args.refFasta,
1662
        args.outVcf, options=args.options.split(),
1663
        JVMmemory=args.JVMmemory
1664
    )
1665
    return 0
×
1666

1667

1668
__commands__.append(('gatk_ug', parser_gatk_ug))
1✔
1669

1670

1671
def parser_gatk_realign(parser=argparse.ArgumentParser()):
1✔
1672
    parser.add_argument('inBam', help='Input reads, BAM format, aligned to refFasta.')
1✔
1673
    parser.add_argument('refFasta', help='Reference genome, FASTA format, pre-indexed by Picard.')
1✔
1674
    parser.add_argument('outBam', help='Realigned reads.')
1✔
1675
    parser.add_argument(
1✔
1676
        '--JVMmemory',
1677
        default=tools.gatk.GATKTool.jvmMemDefault,
1678
        help='JVM virtual memory size (default: %(default)s)'
1679
    )
1680
    parser.add_argument(
1✔
1681
        '--GATK_PATH',
1682
        default=None,
1683
        help='A path containing the GATK jar file. This overrides the GATK_ENV environment variable or the GATK conda package. (default: %(default)s)'
1684
    )
1685
    util.cmd.common_args(parser, (('loglevel', None), ('version', None), ('tmp_dir', None)))
1✔
1686
    util.cmd.attach_main(parser, main_gatk_realign)
1✔
1687
    parser.add_argument('--threads', type=int, default=None, help='Number of threads (default: all available cores)')
1✔
1688
    return parser
1✔
1689

1690

1691
def main_gatk_realign(args):
1✔
1692
    '''Local realignment of BAM files with GATK IndelRealigner.'''
1693
    tools.gatk.GATKTool(path=args.GATK_PATH).local_realign(
×
1694
        args.inBam, args.refFasta,
1695
        args.outBam, JVMmemory=args.JVMmemory,
1696
        threads=args.threads,
1697
    )
1698
    return 0
×
1699

1700

1701
__commands__.append(('gatk_realign', parser_gatk_realign))
1✔
1702

1703
# =========================
1704

1705

1706
def align_and_fix(
1✔
1707
    inBam, refFasta,
1708
    outBamAll=None,
1709
    outBamFiltered=None,
1710
    aligner_options='',
1711
    aligner="novoalign",
1712
    bwa_min_score=None,
1713
    novoalign_amplicons_bed=None,
1714
    amplicon_window=4,
1715
    JVMmemory=None,
1716
    threads=None,
1717
    skip_mark_dupes=False,
1718
    skip_realign=False,
1719
    dup_marker='sambamba',
1720
    gatk_path=None,
1721
    novoalign_license_path=None
1722
):
1723
    ''' Take reads, align to reference with Novoalign, minimap2, or BWA-MEM.
1724
        Optionally mark duplicates with Picard or sambamba, optionally realign
1725
        indels with GATK, and optionally filter final file to mapped/non-dupe reads.
1726
    '''
1727
    if not (outBamAll or outBamFiltered):
1✔
1728
        log.warning("are you sure you meant to do nothing?")
×
1729
        return
×
1730

1731
    assert aligner in ["novoalign", "bwa", "minimap2"]
1✔
1732
    samtools = tools.samtools.SamtoolsTool()
1✔
1733

1734
    refFastaCopy = mkstempfname('.ref_copy.fasta')
1✔
1735
    with util.file.fastas_with_sanitized_ids(refFasta, use_tmp=True) as sanitized_fastas:
1✔
1736
        shutil.copyfile(sanitized_fastas[0], refFastaCopy)
1✔
1737

1738
    tools.picard.CreateSequenceDictionaryTool().execute(refFastaCopy, overwrite=True, JVMmemory=JVMmemory)
1✔
1739
    samtools.faidx(refFastaCopy, overwrite=True)
1✔
1740

1741
    if aligner_options is None:
1✔
1742
        if aligner=="novoalign":
1✔
1743
            aligner_options = '-r Random'
1✔
1744
        else:
1745
            aligner_options = '' # use defaults
1✔
1746

1747
    if samtools.isEmpty(inBam):
1✔
1748
        log.warning("zero reads present in input")
1✔
1749

1750
    bam_aligned = mkstempfname('.aligned.bam')
1✔
1751
    if aligner=="novoalign":
1✔
1752
        if novoalign_amplicons_bed is not None:
1✔
1753
            aligner_options += ' --amplicons {} {}'.format(novoalign_amplicons_bed, amplicon_window)
×
1754

1755
        tools.novoalign.NovoalignTool(license_path=novoalign_license_path).index_fasta(refFastaCopy)
1✔
1756

1757
        tools.novoalign.NovoalignTool(license_path=novoalign_license_path).execute(
1✔
1758
            inBam, refFastaCopy, bam_aligned,
1759
            options=aligner_options.split(),
1760
            JVMmemory=JVMmemory
1761
        )
1762

1763
    elif aligner=='bwa':
1✔
1764
        bwa = tools.bwa.Bwa()
1✔
1765
        bwa.index(refFastaCopy)
1✔
1766

1767
        opts = aligner_options.split()
1✔
1768

1769
        bwa.align_mem_bam(inBam, refFastaCopy, bam_aligned, min_score_to_filter=bwa_min_score, threads=threads, options=opts)
1✔
1770

1771
    elif aligner=='minimap2':
1✔
1772
        mm2 = tools.minimap2.Minimap2()
1✔
1773
        mm2.align_bam(inBam, refFastaCopy, bam_aligned, threads=threads, options=aligner_options.split())
1✔
1774

1775
    # Mark duplicates with sambamba (default) or Picard
1776
    if skip_mark_dupes:
1✔
1777
        bam_marked = bam_aligned
1✔
1778
    else:
1779
        bam_marked = mkstempfname('.mkdup.bam')
1✔
1780
        if dup_marker == 'sambamba':
1✔
1781
            tools.sambamba.SambambaTool().markdup(
1✔
1782
                bam_aligned, bam_marked, threads=threads
1783
            )
1784
        elif dup_marker == 'picard':
1✔
1785
            tools.picard.MarkDuplicatesTool().execute(
1✔
1786
                [bam_aligned], bam_marked, picardOptions=['CREATE_INDEX=true'],
1787
                JVMmemory=JVMmemory
1788
            )
1789
        else:
NEW
1790
            raise ValueError(f"Unknown dup_marker: {dup_marker}. Must be 'sambamba' or 'picard'")
×
1791
        os.unlink(bam_aligned)
1✔
1792

1793
    # Index the marked BAM
1794
    if dup_marker == 'sambamba' and not skip_mark_dupes:
1✔
1795
        tools.sambamba.SambambaTool().index(bam_marked, threads=threads)
1✔
1796
    else:
1797
        samtools.index(bam_marked)
1✔
1798

1799
    # Local realignment with GATK (optional)
1800
    if skip_realign or samtools.isEmpty(bam_marked):
1✔
1801
        bam_realigned = bam_marked
1✔
1802
    else:
1803
        bam_realigned = mkstempfname('.realigned.bam')
1✔
1804
        tools.gatk.GATKTool(path=gatk_path).local_realign(bam_marked, refFastaCopy, bam_realigned, JVMmemory=JVMmemory, threads=threads)
1✔
1805
        os.unlink(bam_marked)
1✔
1806

1807
    # Generate output files
1808
    if outBamAll:
1✔
1809
        shutil.copyfile(bam_realigned, outBamAll)
1✔
1810
        if dup_marker == 'sambamba':
1✔
1811
            tools.sambamba.SambambaTool().index(outBamAll, threads=threads)
1✔
1812
        else:
1813
            tools.picard.BuildBamIndexTool().execute(outBamAll, JVMmemory=JVMmemory)
1✔
1814
    if outBamFiltered:
1✔
1815
        filtered_any_mapq = mkstempfname('.filtered_any_mapq.bam')
1✔
1816
        # filter based on read flags
1817
        samtools.filter_to_proper_primary_mapped_reads(bam_realigned, filtered_any_mapq)
1✔
1818
        # remove reads with MAPQ <1
1819
        samtools.view(['-b', '-q', '1'], filtered_any_mapq, outBamFiltered)
1✔
1820
        os.unlink(filtered_any_mapq)
1✔
1821
        if dup_marker == 'sambamba':
1✔
1822
            tools.sambamba.SambambaTool().index(outBamFiltered, threads=threads)
1✔
1823
        else:
NEW
1824
            tools.picard.BuildBamIndexTool().execute(outBamFiltered, JVMmemory=JVMmemory)
×
1825
    os.unlink(bam_realigned)
1✔
1826

1827

1828
def parser_align_and_fix(parser=argparse.ArgumentParser()):
1✔
1829
    parser.add_argument('inBam', help='Input unaligned reads, BAM format.')
1✔
1830
    parser.add_argument('refFasta', help='Reference genome, FASTA format; will be indexed by Picard and Novoalign.')
1✔
1831
    parser.add_argument(
1✔
1832
        '--outBamAll',
1833
        default=None,
1834
        help='''Aligned, sorted, and indexed reads.  Unmapped and duplicate reads are
1835
                retained. By default, duplicate reads are marked. If "--skipMarkDupes"
1836
                is specified duplicate reads are included in outout without being marked.'''
1837
    )
1838
    parser.add_argument(
1✔
1839
        '--outBamFiltered',
1840
        default=None,
1841
        help='''Aligned, sorted, and indexed reads.  Unmapped reads are removed from this file,
1842
                as well as any marked duplicate reads. Note that if "--skipMarkDupes" is provided,
1843
                duplicates will be not be marked and will be included in the output.'''
1844
    )
1845
    parser.add_argument('--aligner_options', default=None, help='aligner options (default for novoalign: "-r Random", bwa: "-T 30"')
1✔
1846
    parser.add_argument('--aligner', choices=['novoalign', 'minimap2', 'bwa'], default='novoalign', help='aligner (default: %(default)s)')
1✔
1847
    parser.add_argument('--bwa_min_score', type=int, default=None, help='BWA mem on paired reads ignores the -T parameter. Set a value here (e.g. 30) to invoke a custom post-alignment filter (default: no filtration)')
1✔
1848
    parser.add_argument('--novoalign_amplicons_bed', default=None, help='Novoalign only: amplicon primer file (BED format) to soft clip')
1✔
1849
    parser.add_argument('--amplicon_window', type=int, default=4, help='Novoalign only: amplicon primer window size (default: %(default)s)')
1✔
1850
    parser.add_argument('--JVMmemory', default='4g', help='JVM virtual memory size (default: %(default)s)')
1✔
1851
    parser.add_argument('--threads', type=int, default=None, help='Number of threads (default: all available cores)')
1✔
1852
    parser.add_argument('--skipMarkDupes',
1✔
1853
                        help='If specified, duplicate reads will not be marked in the resulting output file.',
1854
                        dest="skip_mark_dupes",
1855
                        action='store_true')
1856
    parser.add_argument('--skipRealign',
1✔
1857
                        help='If specified, GATK local realignment will be skipped. '
1858
                             'Recommended for viral genomes where indel realignment provides minimal benefit.',
1859
                        dest="skip_realign",
1860
                        action='store_true')
1861
    parser.add_argument('--dupMarker',
1✔
1862
                        choices=['sambamba', 'picard'],
1863
                        default='sambamba',
1864
                        dest="dup_marker",
1865
                        help='Tool to use for marking duplicates. Sambamba is multi-threaded and faster. '
1866
                             'Picard is the legacy option. (default: %(default)s)')
1867
    parser.add_argument(
1✔
1868
        '--GATK_PATH',
1869
        default=None,
1870
        dest="gatk_path",
1871
        help='A path containing the GATK jar file. This overrides the GATK_ENV environment variable or the GATK conda package. (default: %(default)s)'
1872
    )
1873
    parser.add_argument(
1✔
1874
        '--NOVOALIGN_LICENSE_PATH',
1875
        default=None,
1876
        dest="novoalign_license_path",
1877
        help='A path to the novoalign.lic file. This overrides the NOVOALIGN_LICENSE_PATH environment variable. (default: %(default)s)'
1878
    )
1879

1880
    util.cmd.common_args(parser, (('loglevel', None), ('version', None), ('tmp_dir', None)))
1✔
1881
    util.cmd.attach_main(parser, align_and_fix, split_args=True)
1✔
1882
    return parser
1✔
1883

1884

1885
__commands__.append(('align_and_fix', parser_align_and_fix))
1✔
1886

1887
# =========================
1888

1889
def filter_bam_to_proper_primary_mapped_reads(inBam, outBam, doNotRequirePairsToBeProper=False, keepSingletons=False, keepDuplicates=False):
1✔
1890
    ''' Take a BAM file and filter to only reads that are properly
1891
        paired and mapped. Optionally reject singletons, and 
1892
        optionally require reads to be properly paired and mapped.
1893

1894
        Output includes reads that are:
1895
            - not flagged as duplicates
1896
            - not secondary or supplementary (split/chimeric reads)
1897
            - For paired-end reads:
1898
            -   marked as proper pair (if require_pairs_to_be_proper=True) OR
1899
                both not unmapped (if require_pairs_to_be_proper=False) OR
1900
                not a member of a pair with a singleton (if reject_singletons=True)
1901
            - For single-end reads:
1902
                mapped
1903
    '''
1904
    samtools = tools.samtools.SamtoolsTool()
×
1905
    samtools.filter_to_proper_primary_mapped_reads(inBam, 
×
1906
                                                   outBam,
1907
                                                   require_pairs_to_be_proper=not doNotRequirePairsToBeProper,
1908
                                                   reject_singletons=not keepSingletons,
1909
                                                   reject_duplicates=not keepDuplicates)
1910
    return 0
×
1911

1912
def parser_filter_bam_to_proper_primary_mapped_reads(parser=argparse.ArgumentParser()):
1✔
1913
    parser.add_argument('inBam', help='Input aligned reads, BAM format.')
×
1914
    parser.add_argument('outBam', help='Output reads, BAM format.')
×
1915
    parser.add_argument(
×
1916
        '--doNotRequirePairsToBeProper',
1917
        help='Do not require reads to be properly paired when filtering (default: %(default)s)',
1918
        action='store_true'
1919
    )
1920
    parser.add_argument(
×
1921
        '--keepSingletons',
1922
        help='Keep singleton reads when filtering (default: %(default)s)',
1923
        action='store_true'
1924
    )
1925
    parser.add_argument(
×
1926
        '--keepDuplicates',
1927
        help='When filtering, do not exclude reads due to being flagged as duplicates (default: %(default)s)',
1928
        action='store_true'
1929
    )
1930

1931
    util.cmd.common_args(parser, (('loglevel', None), ('version', None), ('tmp_dir', None)))
×
1932
    util.cmd.attach_main(parser, filter_bam_to_proper_primary_mapped_reads, split_args=True)
×
1933
    return parser
×
1934

1935
# =========================
1936

1937

1938
def minimap2_idxstats(inBam, refFasta, outStats, outReadlist=None, threads=None):
1✔
1939
    ''' Align reads to reference with minimap2 and produce idxstats-like counts.
1940

1941
        This uses PAF output format (no SAM/BAM generation) for faster alignment
1942
        and streams the output directly without intermediate files.
1943

1944
        Args:
1945
            inBam: Input reads (BAM format)
1946
            refFasta: Reference genome (FASTA format)
1947
            outStats: Output file in samtools idxstats format
1948
            outReadlist: Optional output file with read IDs that mapped (or None to skip)
1949
            threads: Number of threads for alignment (default: auto-detect)
1950
    '''
1951
    mm2 = tools.minimap2.Minimap2()
1✔
1952
    mm2.idxstats(inBam, refFasta, outStats, outReadlist=outReadlist, threads=threads)
1✔
1953

1954
    
1955

1956
def parser_minimap2_idxstats(parser=argparse.ArgumentParser()):
1✔
1957
    parser.add_argument('inBam', help='Input unaligned reads, BAM format.')
1✔
1958
    parser.add_argument('refFasta', help='Reference genome, FASTA format.')
1✔
1959
    parser.add_argument('outStats', help='Output idxstats file (tab-separated: ref_name, ref_length, mapped_count, 0).')
1✔
1960
    parser.add_argument('--outReadlist', help='Optional output file listing read IDs that mapped.', default=None)
1✔
1961

1962
    util.cmd.common_args(parser, (('threads', None), ('loglevel', None), ('version', None), ('tmp_dir', None)))
1✔
1963
    util.cmd.attach_main(parser, minimap2_idxstats, split_args=True)
1✔
1964
    return parser
1✔
1965

1966
__commands__.append(('minimap2_idxstats', parser_minimap2_idxstats))
1✔
1967

1968

1969
def bwamem_idxstats(inBam, refFasta, outBam=None, outStats=None,
1✔
1970
        min_score_to_filter=None, aligner_options=None,
1971
        filterReadsAfterAlignment=False, doNotRequirePairsToBeProper=False, keepSingletons=False, keepDuplicates=False):
1972
    ''' Take reads, align to reference with BWA-MEM and perform samtools idxstats.
1973
        Optionally filter reads after alignment, prior to reporting idxstats, to include only those flagged as properly paired.
1974
    '''
1975

1976
    assert outBam or outStats, "Either outBam or outStats must be specified"
1✔
1977

1978
    bam_aligned = util.file.mkstempfname('.aligned.bam')
1✔
1979
    if outBam is None:
1✔
1980
        bam_filtered = mkstempfname('.filtered.bam')
1✔
1981
    else:
1982
        bam_filtered = outBam
1✔
1983

1984
    samtools = tools.samtools.SamtoolsTool()
1✔
1985
    bwa = tools.bwa.Bwa()
1✔
1986

1987
    ref_indexed = util.file.mkstempfname('.reference.fasta')
1✔
1988
    shutil.copyfile(refFasta, ref_indexed)
1✔
1989
    bwa.index(ref_indexed)
1✔
1990

1991
    bwa_opts = [] if aligner_options is None else aligner_options.split()
1✔
1992
    bwa.mem(inBam, ref_indexed, bam_aligned, options=bwa_opts,
1✔
1993
            min_score_to_filter=min_score_to_filter)
1994
    
1995
    if filterReadsAfterAlignment:
1✔
1996
        samtools.filter_to_proper_primary_mapped_reads(bam_aligned, 
1✔
1997
                                                       bam_filtered, 
1998
                                                       require_pairs_to_be_proper=not doNotRequirePairsToBeProper, 
1999
                                                       reject_singletons=not keepSingletons,
2000
                                                       reject_duplicates=not keepDuplicates)
2001
        os.unlink(bam_aligned)
1✔
2002
    else:
2003
        shutil.move(bam_aligned, bam_filtered)
1✔
2004

2005
    if outStats is not None:
1✔
2006
        samtools.idxstats(bam_filtered, outStats)
1✔
2007

2008
    if outBam is None:
1✔
2009
        os.unlink(bam_filtered)
1✔
2010

2011

2012
def parser_bwamem_idxstats(parser=argparse.ArgumentParser()):
1✔
2013
    parser.add_argument('inBam', help='Input unaligned reads, BAM format.')
1✔
2014
    parser.add_argument('refFasta', help='Reference genome, FASTA format, pre-indexed by Picard and Novoalign.')
1✔
2015
    parser.add_argument('--outBam', help='Output aligned, indexed BAM file', default=None)
1✔
2016
    parser.add_argument('--outStats', help='Output idxstats file', default=None)
1✔
2017
    parser.add_argument(
1✔
2018
        '--minScoreToFilter',
2019
        dest="min_score_to_filter",
2020
        type=int,
2021
        help=("Filter bwa alignments using this value as the minimum allowed "
2022
              "alignment score. Specifically, sum the alignment scores across "
2023
              "all alignments for each query (including reads in a pair, "
2024
              "supplementary and secondary alignments) and then only include, "
2025
              "in the output, queries whose summed alignment score is at least "
2026
              "this value. This is only applied when the aligner is 'bwa'. "
2027
              "The filtering on a summed alignment score is sensible for reads "
2028
              "in a pair and supplementary alignments, but may not be "
2029
              "reasonable if bwa outputs secondary alignments (i.e., if '-a' "
2030
              "is in the aligner options). (default: not set - i.e., do not "
2031
              "filter bwa's output)")
2032
    )
2033
    parser.add_argument(
1✔
2034
        '--alignerOptions',
2035
        dest="aligner_options",
2036
        help="bwa options (default: bwa defaults)")
2037
    parser.add_argument(
1✔
2038
        '--filterReadsAfterAlignment',
2039
        help=("If specified, reads till be filtered after alignment to include only those flagged as properly paired."
2040
                "This excludes secondary and supplementary alignments."),
2041
        action='store_true'
2042
    )
2043
    parser.add_argument(
1✔
2044
        '--doNotRequirePairsToBeProper',
2045
        help='Do not require reads to be properly paired when filtering (default: %(default)s)',
2046
        action='store_true'
2047
    )
2048
    parser.add_argument(
1✔
2049
        '--keepSingletons',
2050
        help='Keep singleton reads when filtering (default: %(default)s)',
2051
        action='store_true'
2052
    )
2053
    parser.add_argument(
1✔
2054
        '--keepDuplicates',
2055
        help='When filtering, do not exclude reads due to being flagged as duplicates (default: %(default)s)',
2056
        action='store_true'
2057
    )
2058

2059
    util.cmd.common_args(parser, (('loglevel', None), ('version', None), ('tmp_dir', None)))
1✔
2060
    util.cmd.attach_main(parser, bwamem_idxstats, split_args=True)
1✔
2061
    return parser
1✔
2062

2063
__commands__.append(('bwamem_idxstats', parser_bwamem_idxstats))
1✔
2064

2065

2066
def parser_extract_tarball(parser=argparse.ArgumentParser()):
1✔
2067
    parser.add_argument('tarfile', help='Input tar file. May be "-" for stdin.')
1✔
2068
    parser.add_argument('out_dir', help='Output directory')
1✔
2069
    parser.add_argument('--compression',
1✔
2070
        help='Compression type (default: %(default)s). Auto-detect is incompatible with stdin input unless pipe_hint is specified.',
2071
        choices=('gz', 'bz2', 'lz4', 'zip', 'none', 'auto'),
2072
        default='auto')
2073
    parser.add_argument('--pipe_hint',
1✔
2074
        help='If tarfile is stdin, you can provide a file-like URI string for pipe_hint which ends with a common compression file extension if you want to use compression=auto.',
2075
        default=None)
2076
    util.cmd.common_args(parser, (('threads', None), ('loglevel', None), ('version', None), ('tmp_dir', None)))
1✔
2077
    util.cmd.attach_main(parser, main_extract_tarball, split_args=True)
1✔
2078
    return parser
1✔
2079
def main_extract_tarball(*args, **kwargs):
1✔
2080
    ''' Extract an input .tar, .tgz, .tar.gz, .tar.bz2, .tar.lz4, or .zip file
2081
        to a given directory (or we will choose one on our own). Emit the
2082
        resulting directory path to stdout.
2083
    '''
2084
    print(util.file.extract_tarball(*args, **kwargs))
×
2085
__commands__.append(('extract_tarball', parser_extract_tarball))
1✔
2086

2087
# =========================
2088

2089
def fasta_read_names(in_fasta, out_read_names):
1✔
2090
    """Save the read names of reads in a .fasta file to a text file"""
2091
    with util.file.open_or_gzopen(in_fasta) as in_fasta_f, open(out_read_names, 'wt') as out_read_names_f:
×
2092
        last_read_name = None
×
2093
        for line in in_fasta_f:
×
2094
            if line.startswith('>'):
×
2095
                read_name = line[1:].strip()
×
2096
                if read_name.endswith('/1') or read_name.endswith('/2'):
×
2097
                    read_name = read_name[:-2]
×
2098
                if read_name != last_read_name:
×
2099
                    out_read_names_f.write(read_name+'\n')
×
2100
                last_read_name = read_name
×
2101

2102

2103
def read_names(in_reads, out_read_names, threads=None):
1✔
2104
    """Extract read names from a sequence file"""
2105
    _in_reads = in_reads
×
2106
    with util.file.tmp_dir(suffix='_read_names.txt') as t_dir:
×
2107
        if in_reads.endswith('.bam'):
×
2108
            _in_reads = os.path.join(t_dir, 'reads.fasta')
×
2109
            tools.samtools.SamtoolsTool().bam2fa(in_reads, _in_reads)
×
2110
        fasta_read_names(_in_reads, out_read_names)
×
2111

2112
def parser_read_names(parser=argparse.ArgumentParser()):
1✔
2113
    parser.add_argument('in_reads', help='the input reads ([compressed] fasta or bam)')
1✔
2114
    parser.add_argument('out_read_names', help='the read names')
1✔
2115
    util.cmd.common_args(parser, (('threads', None), ('loglevel', None), ('version', None), ('tmp_dir', None)))
1✔
2116
    util.cmd.attach_main(parser, read_names, split_args=True)
1✔
2117
    return parser
1✔
2118

2119
__commands__.append(('read_names', parser_read_names))
1✔
2120

2121

2122
# =========================
2123

2124
def full_parser():
1✔
2125
    return util.cmd.make_parser(__commands__, __doc__)
×
2126

2127

2128
if __name__ == '__main__':
1✔
2129
    util.cmd.main_argparse(__commands__, __doc__)
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc