cutseq 0.0.2__tar.gz → 0.0.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: cutseq
3
- Version: 0.0.2
3
+ Version: 0.0.4
4
4
  Summary: Automatically cut adapter / barcode / UMI from NGS data
5
5
  Home-page: https://github.com/y9c/cutseq
6
6
  License: MIT
@@ -19,5 +19,20 @@ Requires-Dist: cutadapt (>=4.8,<5.0)
19
19
  Project-URL: Repository, https://github.com/y9c/cutseq
20
20
  Description-Content-Type: text/markdown
21
21
 
22
- # CutSeq
22
+ # ✂️ CutSeq
23
+
24
+ [![Pypi Releases](https://img.shields.io/pypi/v/cutseq.svg)](https://pypi.python.org/pypi/cutseq)
25
+ [![Downloads](https://pepy.tech/badge/cutseq)](https://pepy.tech/project/cutseq)
26
+
27
+ # How to install?
28
+
29
+ ```bash
30
+ pip install cutseq
31
+ ```
32
+
33
+ ## How to use?
34
+
35
+ ```bash
36
+ cutseq -a "ACACGACGCTCTTCCGATCTX<XXXAGATCGGAAGAGCACACGTC" test_R1.fq.gz test_R2.fq.gz
37
+ ```
23
38
 
cutseq-0.0.4/README.md ADDED
@@ -0,0 +1,16 @@
1
+ # ✂️ CutSeq
2
+
3
+ [![Pypi Releases](https://img.shields.io/pypi/v/cutseq.svg)](https://pypi.python.org/pypi/cutseq)
4
+ [![Downloads](https://pepy.tech/badge/cutseq)](https://pepy.tech/project/cutseq)
5
+
6
+ # How to install?
7
+
8
+ ```bash
9
+ pip install cutseq
10
+ ```
11
+
12
+ ## How to use?
13
+
14
+ ```bash
15
+ cutseq -a "ACACGACGCTCTTCCGATCTX<XXXAGATCGGAAGAGCACACGTC" test_R1.fq.gz test_R2.fq.gz
16
+ ```
@@ -0,0 +1,599 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ #
4
+ # Copyright © 2024 Ye Chang yech1990@gmail.com
5
+ # Distributed under terms of the GNU license.
6
+ #
7
+ # Created: 2024-04-19 18:57
8
+
9
+ import argparse
10
+ import logging
11
+ import re
12
+ import subprocess
13
+ import sys
14
+
15
+ from cutadapt.adapters import (
16
+ BackAdapter,
17
+ NonInternalBackAdapter,
18
+ NonInternalFrontAdapter,
19
+ PrefixAdapter,
20
+ RightmostFrontAdapter,
21
+ )
22
+ from cutadapt.files import InputPaths, OutputFiles
23
+ from cutadapt.modifiers import (
24
+ AdapterCutter,
25
+ PairedEndRenamer,
26
+ QualityTrimmer,
27
+ Renamer,
28
+ SuffixRemover,
29
+ UnconditionalCutter,
30
+ )
31
+ from cutadapt.pipeline import PairedEndPipeline, SingleEndPipeline
32
+ from cutadapt.predicates import IsUntrimmed, TooShort
33
+ from cutadapt.runners import make_runner
34
+ from cutadapt.steps import (
35
+ InfoFileWriter,
36
+ PairedEndFilter,
37
+ PairedEndSink,
38
+ PairedSingleEndStep,
39
+ SingleEndFilter,
40
+ SingleEndSink,
41
+ )
42
+ from cutadapt.utils import Progress
43
+
44
+ logging.basicConfig(
45
+ level=logging.INFO,
46
+ format="%(asctime)s - %(levelname)s - %(message)s",
47
+ )
48
+
49
+
50
+ def reverse_complement(b):
51
+ return "".join(
52
+ [dict(zip("ATGCNatgcn", "TACGNtacgn"))[x] for x in b[::-1] if x in "ATGCNatgcn"]
53
+ )
54
+
55
+
56
+ def remove_fq_suffix(f):
57
+ suffixes_base = ["_R1_001", "_R2_001", "_R1", "_R2", ""]
58
+ suffixes = [
59
+ y + "." + x for x in ["fastq.gz", "fq.gz", "fastq", "fq"] for y in suffixes_base
60
+ ]
61
+ print(suffixes)
62
+ for suffix in suffixes:
63
+ if f.endswith(suffix):
64
+ return f.removesuffix(suffix)
65
+ return f
66
+
67
+
68
+ class BarcodeSeq:
69
+ def __init__(self, seq):
70
+ self.fw = seq
71
+ self.rc = reverse_complement(seq)
72
+ self.len = len(seq)
73
+
74
+
75
+ class BarcodeConfig:
76
+ """
77
+ Adapter scheme:
78
+ (p5)(inline5)(umi5)(mask5)(strand)(mask3)(umi3)(inline3)(p7)
79
+ """
80
+
81
+ def __init__(self, adapter=None):
82
+ self.strand = None
83
+ self.p5 = BarcodeSeq("")
84
+ self.p7 = BarcodeSeq("")
85
+ self.inline5 = BarcodeSeq("")
86
+ self.inline3 = BarcodeSeq("")
87
+ self.umi5 = BarcodeSeq("")
88
+ self.umi3 = BarcodeSeq("")
89
+ self.mask5 = BarcodeSeq("")
90
+ self.mask3 = BarcodeSeq("")
91
+ if adapter is not None:
92
+ self._parse_barcode(adapter)
93
+
94
+ def _parse_barcode(self, b):
95
+ m = re.match(
96
+ r"(?P<p5>[ATGCatgc]+)(\((?P<inline5>[ATGCatgc]+)\))?(?P<umi5>N*)(?P<mask5>X*)(?P<strand>-|>|<)(?P<mask3>X*)(?P<umi3>N*)(\((?P<inline3>[ATGCatgc]+)\))?(?P<p7>[ATGCatgc]+)",
97
+ b,
98
+ )
99
+ if m is None:
100
+ logging.error(f"barcode {b} is not valid")
101
+ sys.exit(1)
102
+ d = m.groupdict()
103
+ if d["inline5"] is None:
104
+ d["inline5"] = ""
105
+ if d["inline3"] is None:
106
+ d["inline3"] = ""
107
+ self.strand = "+" if d["strand"] == ">" else "-" if d["strand"] == "<" else None
108
+ self.p5 = BarcodeSeq(d["p5"])
109
+ self.p7 = BarcodeSeq(d["p7"])
110
+ self.inline5 = BarcodeSeq(d["inline5"])
111
+ self.inline3 = BarcodeSeq(d["inline3"])
112
+ self.umi5 = BarcodeSeq(d["umi5"])
113
+ self.umi3 = BarcodeSeq(d["umi3"])
114
+ self.mask5 = BarcodeSeq(d["mask5"])
115
+ self.mask3 = BarcodeSeq(d["mask3"])
116
+
117
+
118
+ class CutadaptConfig:
119
+ def __init__(self):
120
+ self.rname_suffix = False
121
+ self.discarded_untrimmed = False
122
+ self.trim_polyA = False
123
+ self.min_length = 20
124
+ self.min_quality = 20
125
+ self.dry_run = False
126
+ self.threads = 1
127
+
128
+
129
+ def run_steps(steps, dry_run=False):
130
+ if dry_run:
131
+ print(
132
+ " |\\\n".join([(" " + s if i > 0 else s) for i, s in enumerate(steps)])
133
+ )
134
+ process = subprocess.run("true", shell=True, capture_output=True)
135
+ else:
136
+ cmd = " | ".join(steps)
137
+ process = subprocess.run(cmd, shell=True, capture_output=True)
138
+ return process.stdout.decode(), process.stderr.decode()
139
+
140
+
141
+ def pipeline_single(input1, output1, short1, untrimed1, barcode, settings):
142
+ modifiers = []
143
+ # step 1: remove suffix in the read name
144
+ modifiers.extend([SuffixRemover(".1"), SuffixRemover("/1")])
145
+ # step 2: remove adapter on the 5' end, artifact of template switching
146
+ modifiers.append(
147
+ AdapterCutter(
148
+ [
149
+ RightmostFrontAdapter(
150
+ sequence=barcode.p5.fw, max_errors=0.25, min_overlap=10
151
+ )
152
+ ],
153
+ times=1,
154
+ )
155
+ )
156
+ # step 3: remove adapter on the 3' end, read though in the sequencing
157
+ modifiers.append(
158
+ AdapterCutter(
159
+ [BackAdapter(sequence=barcode.p7.fw, max_errors=0.2, min_overlap=3)],
160
+ times=2,
161
+ ),
162
+ )
163
+ # step 4: trim inline barcode
164
+ if barcode.inline5.len > 0:
165
+ modifiers.append(
166
+ AdapterCutter(
167
+ [PrefixAdapter(sequence=barcode.inline5.fw, max_errors=0.2)],
168
+ times=1,
169
+ )
170
+ )
171
+ if barcode.inline3.len > 0:
172
+ modifiers.append(UnconditionalCutter(-barcode.inline3.len))
173
+
174
+ # step 5: extract UMI
175
+ if barcode.umi5.len > 0:
176
+ modifiers.append(UnconditionalCutter(barcode.umi5.len))
177
+ if barcode.umi3.len > 0:
178
+ modifiers.append(UnconditionalCutter(-barcode.umi3.len))
179
+ if barcode.umi5.len + barcode.umi3.len > 0:
180
+ modifiers.append(Renamer("{id}_{cut_prefix}{cut_suffix}"))
181
+ else:
182
+ modifiers.append(Renamer("{id}"))
183
+
184
+ # step 6: mask tail in the RNA, which might be artifact of RT
185
+ if barcode.mask5.len > 0:
186
+ modifiers.append(UnconditionalCutter(barcode.mask5.len))
187
+ if barcode.mask3.len > 0:
188
+ modifiers.append(UnconditionalCutter(-barcode.mask3.len))
189
+ # step 7: trim polyA
190
+ if settings.trim_polyA:
191
+ if barcode.strand == "+":
192
+ modifiers.append(
193
+ AdapterCutter(
194
+ [NonInternalBackAdapter(sequence="A" * 100, max_errors=0.15)]
195
+ )
196
+ )
197
+ elif barcode.strand == "-":
198
+ modifiers.append(
199
+ AdapterCutter(
200
+ [NonInternalFrontAdapter(sequence="T" * 100, max_errors=0.15)]
201
+ )
202
+ )
203
+ else:
204
+ logging.info("No strand information provided, skip polyA trimming.")
205
+ # step 8: quality control, remove short reads
206
+ modifiers.append(
207
+ QualityTrimmer(cutoff_front=0, cutoff_back=settings.min_quality),
208
+ )
209
+
210
+ inpaths = InputPaths(input1)
211
+
212
+ with make_runner(inpaths, cores=settings.threads) as runner:
213
+ outfiles = OutputFiles(
214
+ proxied=settings.threads > 1,
215
+ qualities=runner.input_file_format().has_qualities(),
216
+ interleaved=False,
217
+ )
218
+ steps = [
219
+ # --info-file=info.txt
220
+ # PairedSingleEndStep(InfoFileWriter(outfiles.open_text("info.txt"))),
221
+ # -m 10
222
+ SingleEndFilter(
223
+ TooShort(settings.min_length), outfiles.open_record_writer(short1)
224
+ ),
225
+ # TODO: --max-n=0 support
226
+ # --discard-untrimmed
227
+ # SingleEndFilter( IsUntrimmed(), IsUntrimmed(), pair_filter_mode="any"),
228
+ # -o
229
+ SingleEndSink(outfiles.open_record_writer(output1, interleaved=False)),
230
+ ]
231
+ pipeline = SingleEndPipeline(modifiers, steps)
232
+ _stats = runner.run(pipeline, Progress(), outfiles)
233
+ # _ = stats.as_json()
234
+ outfiles.close()
235
+
236
+
237
+ def pipeline_paired(
238
+ input1,
239
+ input2,
240
+ output1,
241
+ output2,
242
+ short1,
243
+ short2,
244
+ untrimed1,
245
+ untrimmed2,
246
+ barcode,
247
+ settings,
248
+ ):
249
+ modifiers = []
250
+ # step 1: remove suffix in the read name
251
+ modifiers.extend(
252
+ [
253
+ (SuffixRemover(".1"), SuffixRemover(".2")),
254
+ (SuffixRemover("/1"), SuffixRemover("/2")),
255
+ ]
256
+ )
257
+ # step 2: remove adapter on the 5' end, artifact of template switching
258
+ modifiers.append(
259
+ (
260
+ AdapterCutter(
261
+ [
262
+ RightmostFrontAdapter(
263
+ sequence=barcode.p5.fw, max_errors=0.25, min_overlap=10
264
+ )
265
+ ],
266
+ times=1,
267
+ ),
268
+ AdapterCutter(
269
+ [
270
+ RightmostFrontAdapter(
271
+ sequence=barcode.p7.rc, max_errors=0.25, min_overlap=10
272
+ )
273
+ ],
274
+ times=1,
275
+ ),
276
+ ),
277
+ )
278
+ # step 3: remove adapter on the 3' end, read though in the sequencing
279
+ modifiers.append(
280
+ (
281
+ AdapterCutter(
282
+ [BackAdapter(sequence=barcode.p7.fw, max_errors=0.2, min_overlap=3)],
283
+ times=2,
284
+ ),
285
+ AdapterCutter(
286
+ [BackAdapter(sequence=barcode.p5.rc, max_errors=0.2, min_overlap=3)],
287
+ times=2,
288
+ ),
289
+ ),
290
+ )
291
+ # step 4: trim inline barcode
292
+ if barcode.inline5.len > 0:
293
+ modifiers.append(
294
+ (
295
+ AdapterCutter(
296
+ [PrefixAdapter(sequence=barcode.inline5.fw, max_errors=0.2)],
297
+ times=1,
298
+ ),
299
+ UnconditionalCutter(-barcode.inline5.len),
300
+ )
301
+ )
302
+ if barcode.inline3.len > 0:
303
+ modifiers.append(
304
+ (
305
+ UnconditionalCutter(-barcode.inline3.len),
306
+ AdapterCutter(
307
+ [BackAdapter(sequence=barcode.inline3.rc, max_errors=0.2)],
308
+ times=1,
309
+ ),
310
+ )
311
+ )
312
+
313
+ # step 5: extract UMI
314
+ if barcode.umi5.len > 0:
315
+ modifiers.append(
316
+ (
317
+ UnconditionalCutter(barcode.umi5.len),
318
+ UnconditionalCutter(-barcode.umi5.len),
319
+ ),
320
+ )
321
+ if barcode.umi3.len > 0:
322
+ modifiers.append(
323
+ (
324
+ UnconditionalCutter(-barcode.umi3.len),
325
+ UnconditionalCutter(barcode.umi3.len),
326
+ )
327
+ )
328
+ if barcode.umi5.len + barcode.umi3.len > 0:
329
+ modifiers.append(PairedEndRenamer("{id}_{r1.cut_prefix}{r2.cut_prefix}"))
330
+ else:
331
+ modifiers.append(PairedEndRenamer("{id}"))
332
+
333
+ # step 6: mask tail in the RNA, which might be artifact of RT
334
+ if barcode.mask5.len > 0:
335
+ modifiers.append(
336
+ (
337
+ UnconditionalCutter(barcode.mask5.len),
338
+ UnconditionalCutter(-barcode.mask5.len),
339
+ )
340
+ )
341
+ if barcode.mask3.len > 0:
342
+ modifiers.append(
343
+ (
344
+ UnconditionalCutter(-barcode.mask3.len),
345
+ UnconditionalCutter(barcode.mask3.len),
346
+ )
347
+ )
348
+ # step 7: trim polyA
349
+ if settings.trim_polyA:
350
+ if barcode.strand == "+":
351
+ modifiers.append(
352
+ (
353
+ AdapterCutter(
354
+ [NonInternalBackAdapter(sequence="A" * 100, max_errors=0.15)]
355
+ ),
356
+ AdapterCutter(
357
+ [NonInternalFrontAdapter(sequence="T" * 100, max_errors=0.15)]
358
+ ),
359
+ )
360
+ )
361
+ elif barcode.strand == "-":
362
+ modifiers.append(
363
+ (
364
+ AdapterCutter(
365
+ [NonInternalFrontAdapter(sequence="T" * 100, max_errors=0.15)]
366
+ ),
367
+ AdapterCutter(
368
+ [NonInternalBackAdapter(sequence="A" * 100, max_errors=0.15)]
369
+ ),
370
+ )
371
+ )
372
+ else:
373
+ logging.info("No strand information provided, skip polyA trimming.")
374
+ # step 8: quality control, remove short reads
375
+ modifiers.append(
376
+ (
377
+ QualityTrimmer(cutoff_front=0, cutoff_back=settings.min_quality),
378
+ QualityTrimmer(cutoff_front=0, cutoff_back=settings.min_quality),
379
+ )
380
+ )
381
+
382
+ inpaths = InputPaths(input1, input2)
383
+
384
+ with make_runner(inpaths, cores=settings.threads) as runner:
385
+ outfiles = OutputFiles(
386
+ proxied=settings.threads > 1,
387
+ qualities=runner.input_file_format().has_qualities(),
388
+ interleaved=False,
389
+ )
390
+ steps = [
391
+ # --info-file=info.txt
392
+ # PairedSingleEndStep(InfoFileWriter(outfiles.open_text("info.txt"))),
393
+ # -m 10:10
394
+ PairedEndFilter(
395
+ TooShort(settings.min_length),
396
+ TooShort(settings.min_length),
397
+ outfiles.open_record_writer(short1, short2, interleaved=False),
398
+ ),
399
+ # TODO: --max-n=0 support
400
+ # --discard-untrimmed
401
+ # PairedEndFilter( IsUntrimmed(), IsUntrimmed(), pair_filter_mode="any"),
402
+ # -o ... -p ...
403
+ PairedEndSink(outfiles.open_record_writer(output1, output2)),
404
+ ]
405
+ pipeline = PairedEndPipeline(modifiers, steps)
406
+ _stats = runner.run(pipeline, Progress(), outfiles)
407
+ # _ = stats.as_json()
408
+ outfiles.close()
409
+
410
+
411
+ def run_cutseq(args):
412
+ barcode_config = BarcodeConfig(args.adapter_scheme.upper())
413
+ settings = CutadaptConfig()
414
+ if args.with_rname_suffix:
415
+ settings.rname_suffix = True
416
+ if args.untrimmed_file is None:
417
+ settings.discarded_untrimmed = True
418
+ if args.trim_polyA:
419
+ settings.trim_polyA = True
420
+ settings.threads = args.threads
421
+ settings.min_length = args.min_length
422
+ settings.dry_run = args.dry_run
423
+ if len(args.input_file) == 1:
424
+ pipeline_single(
425
+ args.input_file[0],
426
+ args.output_file[0],
427
+ args.short_file[0],
428
+ args.untrimmed_file[0],
429
+ barcode_config,
430
+ settings,
431
+ )
432
+ else:
433
+ pipeline_paired(
434
+ args.input_file[0],
435
+ args.input_file[1],
436
+ args.output_file[0],
437
+ args.output_file[1],
438
+ args.short_file[0],
439
+ args.short_file[1],
440
+ args.untrimmed_file[0],
441
+ args.untrimmed_file[1],
442
+ barcode_config,
443
+ settings,
444
+ )
445
+
446
+
447
+ def main():
448
+ parser = argparse.ArgumentParser(
449
+ description="Trim sequencing adapters from NGS data automatically."
450
+ )
451
+ # input file can be one or two for single or paired-end reads, but can not be more than two
452
+ parser.add_argument(
453
+ "input_file",
454
+ type=str,
455
+ nargs="+",
456
+ help="Input file path for NGS data, one or two files.",
457
+ )
458
+ # output file can be number of files matching the input files, if not provided it will generate based on the output prefix,
459
+ # if no output prefix provided it will generate based on the input file name
460
+ parser.add_argument(
461
+ "-a",
462
+ "--adapter-scheme",
463
+ type=str,
464
+ help="Adapter sequence configuration.",
465
+ )
466
+ parser.add_argument("-A", "--adapter-name", type=str, help="Built-in adapter name.")
467
+ parser.add_argument(
468
+ "-O",
469
+ "--output-prefix",
470
+ type=str,
471
+ help="Output file prefix for keep trimmed data.",
472
+ )
473
+ parser.add_argument(
474
+ "-o",
475
+ "--output-file",
476
+ type=str,
477
+ nargs="+",
478
+ help="Output file path for keep trimmed data.",
479
+ )
480
+
481
+ parser.add_argument(
482
+ "-q",
483
+ "--min-quality",
484
+ type=int,
485
+ default=20,
486
+ help="Minimum quality of the read tails in the reads to keep.",
487
+ )
488
+ # discard short reads
489
+ parser.add_argument(
490
+ "-S",
491
+ "--short-file",
492
+ type=str,
493
+ nargs="+",
494
+ help="Output file path for discarded too short data.",
495
+ )
496
+ parser.add_argument(
497
+ "-m",
498
+ "--min-length",
499
+ type=int,
500
+ default=20,
501
+ help="Minimum length of the reads to keep.",
502
+ )
503
+
504
+ parser.add_argument(
505
+ "--with-rname-suffix",
506
+ action="store_true",
507
+ help="R1 and R2 suffix cotains suffix. MGI platform.",
508
+ )
509
+ parser.add_argument(
510
+ "-U",
511
+ "--untrimmed-file",
512
+ type=str,
513
+ nargs="+",
514
+ help="Output file path for discarded reads without inline barcode.",
515
+ )
516
+
517
+ parser.add_argument("--trim-polyA", action="store_true", help="Trim polyA tail.")
518
+
519
+ parser.add_argument(
520
+ "-t",
521
+ "--threads",
522
+ type=int,
523
+ default=1,
524
+ help="Number of threads to use for trimming.",
525
+ )
526
+ parser.add_argument(
527
+ "-n",
528
+ "--dry-run",
529
+ action="store_true",
530
+ help="Print command instead of running it.",
531
+ )
532
+ args = parser.parse_args()
533
+
534
+ if args.adapter_name is not None:
535
+ if args.adapter_scheme is not None:
536
+ logging.info("Adapter scheme is provided, ignore adapter name.")
537
+ else:
538
+ if args.adapter_name.upper() == "TAKARAV2":
539
+ args.adapter_scheme = "ACACGACGCTCTTCCGATCTX<XXXAGATCGGAAGAGCACACGTC"
540
+ elif args.adapter_name.upper() == "STRANDED":
541
+ args.adapter_scheme = "ACACGACGCTCTTCCGATCTX<XXXAGATCGGAAGAGCACACGTC"
542
+ elif args.adapter_name.upper() == "TAKARAV3":
543
+ args.adapter_scheme = (
544
+ "ACACGACGCTCTTCCGATCTXXX<XXXXXXNNNNNNNNAGATCGGAAGAGCACACGTC"
545
+ )
546
+ else:
547
+ logging.error("Adapter name is not valid.")
548
+ sys.exit(1)
549
+ elif args.adapter_scheme is None:
550
+ logging.error("Adapter scheme or name is required.")
551
+ sys.exit(1)
552
+
553
+ if len(args.input_file) > 2:
554
+ logging.error("Input file can not be more than two.")
555
+ sys.exit(1)
556
+
557
+ def validate_output_file(output_files, input_files, output_prefix, output_suffix):
558
+ default_format = ".fastq.gz"
559
+ r1 = "_" + output_suffix + "_R1" + default_format
560
+ r2 = "_" + output_suffix + "_R2" + default_format
561
+ if output_files:
562
+ if len(output_files) != len(input_files):
563
+ logging.error("Output file should be same as input file.")
564
+ sys.exit(1)
565
+ return output_files
566
+ elif output_prefix is not None:
567
+ if len(input_files) == 1:
568
+ return [output_prefix + r1]
569
+ else:
570
+ return [output_prefix + r1, output_prefix + r2]
571
+ else:
572
+ if len(input_files) == 1:
573
+ return [remove_fq_suffix(input_files[0]) + r1]
574
+ else:
575
+ return [
576
+ remove_fq_suffix(input_files[0]) + r1,
577
+ remove_fq_suffix(input_files[1]) + r2,
578
+ ]
579
+ return output_files
580
+
581
+ args.output_file = validate_output_file(
582
+ args.output_file, args.input_file, args.output_prefix, "trimmed"
583
+ )
584
+ args.short_file = validate_output_file(
585
+ args.short_file, args.input_file, args.output_prefix, "short"
586
+ )
587
+ args.untrimmed_file = (
588
+ validate_output_file(
589
+ args.untrimmed_file, args.input_file, args.output_prefix, "untrimmed"
590
+ )
591
+ if args.untrimmed_file is not None
592
+ else [None] * len(args.input_file)
593
+ )
594
+
595
+ run_cutseq(args)
596
+
597
+
598
+ if __name__ == "__main__":
599
+ main()
@@ -40,23 +40,29 @@ def remove_fq_suffix(f):
40
40
  return f
41
41
 
42
42
 
43
+ class BarcodeSeq:
44
+ def __init__(self, seq):
45
+ self.fw = seq
46
+ self.rc = reverse_complement(seq)
47
+ self.len = len(seq)
48
+
49
+
43
50
  class BarcodeConfig:
51
+ """
52
+ Adapter scheme:
53
+ (p5)(inline5)(umi5)(mask5)(strand)(mask3)(umi3)(inline3)(p7)
54
+ """
55
+
44
56
  def __init__(self, adapter=None):
45
57
  self.strand = None
46
- self.p5_fw = ""
47
- self.p5_rc = ""
48
- self.p7_fw = ""
49
- self.p7_rc = ""
50
- self.inline5_fw = ""
51
- self.inline5_rc = ""
52
- self.inline5 = 0
53
- self.inline3_fw = ""
54
- self.inline3_rc = ""
55
- self.inline3 = 0
56
- self.umi5 = 0
57
- self.umi3 = 0
58
- self.mask5 = 0
59
- self.mask3 = 0
58
+ self.p5 = BarcodeSeq("")
59
+ self.p7 = BarcodeSeq("")
60
+ self.inline5 = BarcodeSeq("")
61
+ self.inline3 = BarcodeSeq("")
62
+ self.umi5 = BarcodeSeq("")
63
+ self.umi3 = BarcodeSeq("")
64
+ self.mask5 = BarcodeSeq("")
65
+ self.mask3 = BarcodeSeq("")
60
66
  if adapter is not None:
61
67
  self._parse_barcode(adapter)
62
68
 
@@ -74,20 +80,14 @@ class BarcodeConfig:
74
80
  if d["inline3"] is None:
75
81
  d["inline3"] = ""
76
82
  self.strand = "+" if d["strand"] == ">" else "-" if d["strand"] == "<" else None
77
- self.p5_fw = d["p5"]
78
- self.p5_rc = reverse_complement(d["p5"])
79
- self.p7_fw = d["p7"]
80
- self.p7_rc = reverse_complement(d["p7"])
81
- self.inline5_fw = d["inline5"] if d["inline5"] else ""
82
- self.inline5_rc = reverse_complement(d["inline5"]) if d["inline5"] else ""
83
- self.inline5 = len(d["inline5"])
84
- self.inline3_fw = d["inline3"] if d["inline3"] else ""
85
- self.inline3_rc = reverse_complement(d["inline3"]) if d["inline3"] else ""
86
- self.inline3 = len(d["inline3"])
87
- self.umi5 = len(d["umi5"])
88
- self.umi3 = len(d["umi3"])
89
- self.mask5 = len(d["mask5"])
90
- self.mask3 = len(d["mask3"])
83
+ self.p5 = BarcodeSeq(d["p5"])
84
+ self.p7 = BarcodeSeq(d["p7"])
85
+ self.inline5 = BarcodeSeq(d["inline5"])
86
+ self.inline3 = BarcodeSeq(d["inline3"])
87
+ self.umi5 = BarcodeSeq(d["umi5"])
88
+ self.umi3 = BarcodeSeq(d["umi3"])
89
+ self.mask5 = BarcodeSeq(d["mask5"])
90
+ self.mask3 = BarcodeSeq(d["mask3"])
91
91
 
92
92
 
93
93
  class CutadaptConfig:
@@ -112,19 +112,19 @@ def run_cutadapt_PE(
112
112
  else:
113
113
  config_rname = ""
114
114
  steps.append(
115
- f"{cutadapt}{config_rname} -e 0.25 -n 2 -O 10 -g '{barcode.p5_fw};rightmost' -G '{barcode.p7_rc};rightmost' --interleaved {input1} {input2}"
115
+ f"{cutadapt}{config_rname} -e 0.25 -n 2 -O 10 -g '{barcode.p5.fw};rightmost' -G '{barcode.p7.rc};rightmost' --interleaved {input1} {input2}"
116
116
  )
117
117
  # step 2: remove adapter on the 3' end, read though in the sequencing
118
118
  steps.append(
119
- f"{cutadapt} -e 0.2 -n 2 -O 3 -a '{barcode.p7_fw}' -A '{barcode.p5_rc}' --interleaved -"
119
+ f"{cutadapt} -e 0.2 -n 2 -O 3 -a '{barcode.p7.fw}' -A '{barcode.p5.rc}' --interleaved -"
120
120
  )
121
121
  # step 3: trim inline barcode
122
122
  config_inline_args = []
123
- if barcode.inline5 > 0:
124
- config_inline_args.append(f"-g ^{barcode.inline5_fw} -U -{barcode.inline5}")
125
- if barcode.inline3 > 0:
126
- config_inline_args.append(f"-G ^{barcode.inline3_rc} -u -{barcode.inline3}")
127
- if barcode.inline5 + barcode.inline3 > 0:
123
+ if barcode.inline5.len > 0:
124
+ config_inline_args.append(f"-g ^{barcode.inline5.fw} -U -{barcode.inline5.len}")
125
+ if barcode.inline3.len > 0:
126
+ config_inline_args.append(f"-G ^{barcode.inline3.rc} -u -{barcode.inline3.len}")
127
+ if barcode.inline5.len + barcode.inline3.len > 0:
128
128
  if settings.discarded_untrimmed:
129
129
  config_inline_args.append(
130
130
  f"--untrimmed-output={discard1} --untrimmed-paired-output={discard2}"
@@ -132,16 +132,16 @@ def run_cutadapt_PE(
132
132
  config_inline = " ".join(config_inline_args)
133
133
  steps.append(f"{cutadapt} {config_inline} --interleaved -")
134
134
  # step 4: extract UMI
135
- if barcode.umi5 + barcode.umi3 > 0:
135
+ if barcode.umi5.len + barcode.umi3.len > 0:
136
136
  steps.append(
137
- f"{cutadapt} -u {barcode.umi5} -u -{barcode.umi3} -U {barcode.umi3} -U -{barcode.umi5} --rename='{{id}}_{{r1.cut_prefix}}{{r2.cut_prefix}}' --interleaved -"
137
+ f"{cutadapt} -u {barcode.umi5.len} -u -{barcode.umi3.len} -U {barcode.umi3.len} -U -{barcode.umi5.len} --rename='{{id}}_{{r1.cut_prefix}}{{r2.cut_prefix}}' --interleaved -"
138
138
  )
139
139
  else:
140
140
  steps.append(f"{cutadapt} --rename='{{id}}' --interleaved -")
141
141
  # step 5: mask tail in the RNA, which might be artifact of RT
142
- if barcode.mask5 + barcode.mask3 > 0:
142
+ if barcode.mask5.len + barcode.mask3.len > 0:
143
143
  steps.append(
144
- f"{cutadapt} -u {barcode.mask5} -u -{barcode.mask3} -U {barcode.mask3} -U -{barcode.mask5} --interleaved -"
144
+ f"{cutadapt} -u {barcode.mask5.len} -u -{barcode.mask3.len} -U {barcode.mask3.len} -U -{barcode.mask5.len} --interleaved -"
145
145
  )
146
146
  # step 6: trim polyA
147
147
  if settings.trim_polyA:
@@ -159,16 +159,7 @@ def run_cutadapt_PE(
159
159
  steps.append(
160
160
  f"{cutadapt} -q {settings.min_quality} --max-n=0 -m {settings.min_length} --too-short-output={discard1} --too-short-paired-output={discard2} -o {output1} -p {output2} --interleaved -"
161
161
  )
162
-
163
- if settings.dry_run:
164
- print(
165
- " |\\\n".join([(" " + s if i > 0 else s) for i, s in enumerate(steps)])
166
- )
167
- process = subprocess.run("true", shell=True, capture_output=True)
168
- else:
169
- cmd = " | ".join(steps)
170
- process = subprocess.run(cmd, shell=True, capture_output=True)
171
- return process.stdout.decode(), process.stderr.decode()
162
+ return steps
172
163
 
173
164
 
174
165
  def run_cutadapt_SE(input1, output1, discard1, barcode, settings):
@@ -180,29 +171,29 @@ def run_cutadapt_SE(input1, output1, discard1, barcode, settings):
180
171
  else:
181
172
  config_rname = ""
182
173
  steps.append(
183
- f"{cutadapt}{config_rname} -e 0.25 -n 2 -O 10 -g '{barcode.p5_fw};rightmost' {input1}"
174
+ f"{cutadapt}{config_rname} -e 0.25 -n 2 -O 10 -g '{barcode.p5.fw};rightmost' {input1}"
184
175
  )
185
176
  # step 2: remove adapter on the 3' end, read though in the sequencing
186
- steps.append(f"{cutadapt} -e 0.2 -n 2 -O 3 -a '{barcode.p7_fw}' -")
177
+ steps.append(f"{cutadapt} -e 0.2 -n 2 -O 3 -a '{barcode.p7.fw}' -")
187
178
  # step 3: trim inline barcode
188
179
  config_inline_args = []
189
- if barcode.inline3 > 0:
190
- config_inline_args.append(f"-a {barcode.inline3_fw}$")
191
- if barcode.inline5 > 0:
192
- config_inline_args.append(f"-g ^{barcode.inline5_fw}")
193
- if barcode.inline5 + barcode.inline3 > 0:
180
+ if barcode.inline3.len > 0:
181
+ config_inline_args.append(f"-a {barcode.inline3.fw}$")
182
+ if barcode.inline5.len > 0:
183
+ config_inline_args.append(f"-g ^{barcode.inline5.fw}")
184
+ if barcode.inline5.len + barcode.inline3.len > 0:
194
185
  if settings.discarded_untrimmed:
195
186
  config_inline_args.append(f"--untrimmed-output={discard1}")
196
187
 
197
188
  config_inline = " ".join(config_inline_args)
198
189
  steps.append(f"{cutadapt} {config_inline} -")
199
190
  # step 4: extract UMI
200
- if barcode.umi5 + barcode.umi3 > 0:
191
+ if barcode.umi5.len + barcode.umi3.len > 0:
201
192
  steps.append(
202
- f"{cutadapt} -u {barcode.umi5} -u -{barcode.umi3} --rename='{{id}}_{{cut_prefix}}{{cut_suffix}}' -"
193
+ f"{cutadapt} -u {barcode.umi5.len} -u -{barcode.umi3.len} --rename='{{id}}_{{cut_prefix}}{{cut_suffix}}' -"
203
194
  )
204
195
  # step 5: mask tail in the RNA, which might be artifact of RT
205
- steps.append(f"{cutadapt} -u {barcode.mask5} -u -{barcode.mask3} -")
196
+ steps.append(f"{cutadapt} -u {barcode.mask5.len} -u -{barcode.mask3.len} -")
206
197
  # step 6: trim polyA
207
198
  if settings.trim_polyA:
208
199
  if barcode.strand == "+":
@@ -215,8 +206,11 @@ def run_cutadapt_SE(input1, output1, discard1, barcode, settings):
215
206
  steps.append(
216
207
  f"{cutadapt} -q {settings.min_quality} --max-n=0 -m {settings.min_length} --too-short-output={discard1} -o {output1} -"
217
208
  )
209
+ return steps
218
210
 
219
- if settings.dry_run:
211
+
212
+ def run_steps(steps, dry_run=False):
213
+ if dry_run:
220
214
  print(
221
215
  " |\\\n".join([(" " + s if i > 0 else s) for i, s in enumerate(steps)])
222
216
  )
@@ -228,7 +222,7 @@ def run_cutadapt_SE(input1, output1, discard1, barcode, settings):
228
222
 
229
223
 
230
224
  def run_cutseq(args):
231
- barcode_config = BarcodeConfig(args.adapter)
225
+ barcode_config = BarcodeConfig(args.adapter_scheme.upper())
232
226
  settings = CutadaptConfig()
233
227
  if args.with_rname_suffix:
234
228
  settings.rname_suffix = True
@@ -239,9 +233,8 @@ def run_cutseq(args):
239
233
  settings.threads = args.threads
240
234
  settings.min_length = args.min_length
241
235
  settings.dry_run = args.dry_run
242
- # Example command setup, you'll need to expand this based on your actual requirements
243
236
  if len(args.input_file) == 1:
244
- stdout, stderr = run_cutadapt_SE(
237
+ steps = run_cutadapt_SE(
245
238
  args.input_file[0],
246
239
  args.output_file[0],
247
240
  args.discard_file[0],
@@ -249,7 +242,7 @@ def run_cutseq(args):
249
242
  settings,
250
243
  )
251
244
  else:
252
- stdout, stderr = run_cutadapt_PE(
245
+ steps = run_cutadapt_PE(
253
246
  args.input_file[0],
254
247
  args.input_file[1],
255
248
  args.output_file[0],
@@ -259,6 +252,8 @@ def run_cutseq(args):
259
252
  barcode_config,
260
253
  settings,
261
254
  )
255
+ stdout, stderr = run_steps(steps, settings.dry_run)
256
+ print(stderr, file=sys.stderr)
262
257
  print(stdout)
263
258
 
264
259
 
@@ -277,11 +272,11 @@ def main():
277
272
  # if no output suffix provided it will generate based on the input file name
278
273
  parser.add_argument(
279
274
  "-a",
280
- "--adapter",
275
+ "--adapter-scheme",
281
276
  type=str,
282
- required=True,
283
277
  help="Adapter sequence configuration.",
284
278
  )
279
+ parser.add_argument("-A", "--adapter-name", type=str, help="Built-in adapter name.")
285
280
  parser.add_argument(
286
281
  "-O",
287
282
  "--output-suffix",
@@ -346,6 +341,25 @@ def main():
346
341
  )
347
342
  args = parser.parse_args()
348
343
 
344
+ if args.adapter_name is not None:
345
+ if args.adapter_scheme is not None:
346
+ logging.info("Adapter scheme is provided, ignore adapter name.")
347
+ else:
348
+ if args.adapter_name.upper() == "TAKARAV2":
349
+ args.adapter_scheme = "ACACGACGCTCTTCCGATCTX<XXXAGATCGGAAGAGCACACGTC"
350
+ elif args.adapter_name.upper() == "STRANDED":
351
+ args.adapter_scheme = "ACACGACGCTCTTCCGATCTX<XXXAGATCGGAAGAGCACACGTC"
352
+ elif args.adapter_name.upper() == "TAKARAV3":
353
+ args.adapter_scheme = (
354
+ "ACACGACGCTCTTCCGATCTXXX<XXXXXXNNNNNNNNAGATCGGAAGAGCACACGTC"
355
+ )
356
+ else:
357
+ logging.error("Adapter name is not valid.")
358
+ sys.exit(1)
359
+ elif args.adapter_scheme is None:
360
+ logging.error("Adapter scheme or name is required.")
361
+ sys.exit(1)
362
+
349
363
  if len(args.input_file) > 2:
350
364
  raise ValueError("Input file can not be more than two.")
351
365
 
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "cutseq"
3
- version = "0.0.2"
3
+ version = "0.0.4"
4
4
  description = "Automatically cut adapter / barcode / UMI from NGS data"
5
5
  authors = ["Ye Chang <yech1990@gmail.com>"]
6
6
  license = "MIT"
cutseq-0.0.2/README.md DELETED
@@ -1 +0,0 @@
1
- # CutSeq