deamtools 0.1.1__py3-none-any.whl

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.
deamtools/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from deamtools.cli.main import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
@@ -0,0 +1,6 @@
1
+ """Deamination-aware alignment using a bwa-meth-style strategy."""
2
+
3
+ from deamtools.align.align import run_align
4
+ from deamtools.align.index import run_index
5
+
6
+ __all__ = ["run_align", "run_index"]
@@ -0,0 +1,368 @@
1
+ """Deamination-aware alignment with a dual-conversion, take-best read converter.
2
+
3
+ FASTQ(s) --[convert x2]--> bwa mem -C --[group + pick best]--> <out_name>.sam
4
+ --[samtools sort]--> coordinate-sorted <out_name>.bam (+ .bai)
5
+
6
+ Because the ACCESS-ATAC deaminase edits cytosines on **both** strands, a single
7
+ read can carry both ``C->T`` and ``G->A`` deamination events, so converting it
8
+ in only one direction leaves the other as mismatches. Instead, every read is
9
+ emitted in **two** converted forms and bwa maps both; the better-scoring one is
10
+ kept:
11
+
12
+ * Single-end — two candidates per read: ``C->T`` (``YC:Z:ct``) and ``G->A``
13
+ (``YC:Z:ga``).
14
+ * Paired-end — two fragment orientations, with a consistent direction for the
15
+ whole pair: ``f`` = (read1 ``C->T``, read2 ``G->A``) and ``r`` =
16
+ (read1 ``G->A``, read2 ``C->T``).
17
+
18
+ Both candidates of a read/fragment share the original read name; the candidate
19
+ is marked with a ``YC:Z:`` tag and the original sequence stashed in ``YS:Z:``
20
+ (both carried through ``bwa mem -C``). In post-processing, records are grouped
21
+ by read name and the candidate with the higher primary alignment score (sum of
22
+ the mates' ``AS`` for pairs) is kept; the original SEQ is restored from ``YS``,
23
+ the ``f``/``r`` prefix is stripped from RNAME/RNEXT, and the ``YS``/``YC`` tags
24
+ are dropped. The restored SAM is written to ``<out_name>.sam`` and converted to
25
+ a coordinate-sorted, indexed BAM with samtools.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import io
31
+ import logging
32
+ import os
33
+ import re
34
+ import shutil
35
+ import subprocess
36
+ import threading
37
+
38
+ import pysam
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+ CT_TABLE = str.maketrans("Cc", "Tt")
43
+ GA_TABLE = str.maketrans("Gg", "Aa")
44
+ RC_TABLE = str.maketrans("ACGTNacgtn", "TGCANtgcan")
45
+ CIGAR_OP_RE = re.compile(r"(\d+)([MIDNSHP=X])")
46
+
47
+ # BWA-MEM options, matching bwa-meth 0.2.9 (`bwameth.py:bwa_mem`). A three-letter
48
+ # alignment leaves residual mismatches wherever the conversion runs the wrong way
49
+ # — for a dsDNA deaminase that is the minority-direction editing every read
50
+ # carries — so the defaults are miscalibrated for this data:
51
+ # -T 40 raise the minimum output score (with -B 2 a good alignment scores high)
52
+ # -B 2 halve the mismatch penalty; residual edits should not sink a read
53
+ # -L 10 raise the clipping penalty, so bwa resolves rather than clips them
54
+ # -M mark shorter split hits as secondary
55
+ # -C carry the YS/YC comment through into the SAM (deamtools relies on it)
56
+ # -U 100 (paired) heavily penalise unpaired placement
57
+ # Keeping these identical to bwa-meth's also makes the two directly comparable:
58
+ # any difference is then the read-conversion strategy, not parameter tuning.
59
+ BWA_MEM_OPTS = ("-T", "40", "-B", "2", "-L", "10", "-C", "-M")
60
+ BWA_MEM_PAIRED_OPTS = ("-U", "100")
61
+
62
+
63
+ def _check_executable(name: str) -> None:
64
+ if shutil.which(name) is None:
65
+ raise RuntimeError(f"'{name}' was not found in PATH.")
66
+
67
+
68
+ def _revcomp(seq: str) -> str:
69
+ return seq.translate(RC_TABLE)[::-1]
70
+
71
+
72
+ def _hard_clip_offsets(cigar: str) -> tuple[int, int]:
73
+ if "H" not in cigar:
74
+ return 0, 0
75
+ ops = CIGAR_OP_RE.findall(cigar)
76
+ left = int(ops[0][0]) if ops and ops[0][1] == "H" else 0
77
+ right = int(ops[-1][0]) if ops and ops[-1][1] == "H" else 0
78
+ return left, right
79
+
80
+
81
+ def _write_record(
82
+ out: io.TextIOBase, name: str, seq: str, qual: str, candidate: str, original: str
83
+ ) -> None:
84
+ """Write one converted FASTQ record.
85
+
86
+ The ``YS`` (original SEQ) and ``YC`` (candidate) tags are emitted as a
87
+ tab-separated comment so ``bwa mem -C`` copies each as its own SAM tag.
88
+ """
89
+ out.write(f"@{name}\tYS:Z:{original}\tYC:Z:{candidate}\n{seq}\n+\n{qual}\n")
90
+
91
+
92
+ def _feed_converted(
93
+ read1: str,
94
+ read2: str | None,
95
+ out: io.TextIOBase,
96
+ ) -> None:
97
+ """Stream both converted candidates of every read/fragment to ``out``."""
98
+ try:
99
+ if read2 is None:
100
+ with pysam.FastxFile(read1) as fq:
101
+ for r in fq:
102
+ seq = r.sequence
103
+ qual = r.quality if r.quality is not None else "I" * len(seq)
104
+ _write_record(out, r.name, seq.translate(CT_TABLE), qual, "ct", seq)
105
+ _write_record(out, r.name, seq.translate(GA_TABLE), qual, "ga", seq)
106
+ else:
107
+ with pysam.FastxFile(read1) as fq1, pysam.FastxFile(read2) as fq2:
108
+ for r1, r2 in zip(fq1, fq2, strict=True):
109
+ s1, s2 = r1.sequence, r2.sequence
110
+ q1 = r1.quality if r1.quality is not None else "I" * len(s1)
111
+ q2 = r2.quality if r2.quality is not None else "I" * len(s2)
112
+ # Orientation f: read1 C->T, read2 G->A (interleaved pair).
113
+ _write_record(out, r1.name, s1.translate(CT_TABLE), q1, "f", s1)
114
+ _write_record(out, r2.name, s2.translate(GA_TABLE), q2, "f", s2)
115
+ # Orientation r: read1 G->A, read2 C->T.
116
+ _write_record(out, r1.name, s1.translate(GA_TABLE), q1, "r", s1)
117
+ _write_record(out, r2.name, s2.translate(CT_TABLE), q2, "r", s2)
118
+ finally:
119
+ out.close()
120
+
121
+
122
+ def _emit_clean_header(fasta_path: str, out: io.TextIOBase) -> None:
123
+ """Write a fresh @HD + @SQ block from the original FASTA's .fai."""
124
+ out.write("@HD\tVN:1.6\tSO:coordinate\n")
125
+ with open(fasta_path + ".fai") as f:
126
+ for line in f:
127
+ chrom, length = line.split("\t")[:2]
128
+ out.write(f"@SQ\tSN:{chrom}\tLN:{length}\n")
129
+
130
+
131
+ def _restore_alignment(line: str) -> str:
132
+ fields = line.rstrip("\n").split("\t")
133
+ if len(fields) < 11:
134
+ return line
135
+
136
+ rname = fields[2]
137
+ if rname not in ("", "*") and rname[0] in ("f", "r"):
138
+ fields[2] = rname[1:]
139
+ rnext = fields[6]
140
+ if rnext not in ("", "*", "=") and rnext[0] in ("f", "r"):
141
+ fields[6] = rnext[1:]
142
+
143
+ flag = int(fields[1])
144
+ cigar = fields[5]
145
+ seq_field = fields[9]
146
+
147
+ orig: str | None = None
148
+ kept_tags: list[str] = []
149
+ for tag in fields[11:]:
150
+ if tag.startswith("YS:Z:"):
151
+ orig = tag[5:]
152
+ elif tag.startswith("YC:Z:"):
153
+ continue # candidate marker; internal only
154
+ else:
155
+ kept_tags.append(tag)
156
+
157
+ if orig is not None and seq_field != "*":
158
+ if flag & 16:
159
+ orig = _revcomp(orig)
160
+ left, right = _hard_clip_offsets(cigar)
161
+ if left or right:
162
+ orig = orig[left : len(orig) - right]
163
+ if len(orig) == len(seq_field):
164
+ fields[9] = orig
165
+
166
+ fields = fields[:11] + kept_tags
167
+ return "\t".join(fields) + "\n"
168
+
169
+
170
+ def _tag_value(fields: list[str], prefix: str) -> str | None:
171
+ """Value of a SAM tag (e.g. ``"YC:Z:"`` or ``"AS:i:"``) in ``fields[11:]``."""
172
+ for tag in fields[11:]:
173
+ if tag.startswith(prefix):
174
+ return tag[len(prefix):]
175
+ return None
176
+
177
+
178
+ def _primary_score(lines: list[str]) -> int:
179
+ """Sum of ``AS`` over a candidate's primary records (mates of a pair).
180
+
181
+ Secondary (0x100) and supplementary (0x800) records are ignored; an
182
+ unmapped primary contributes -1 so any mapped placement outranks it.
183
+ """
184
+ total = 0
185
+ for line in lines:
186
+ fields = line.rstrip("\n").split("\t")
187
+ flag = int(fields[1])
188
+ if flag & 0x100 or flag & 0x800:
189
+ continue
190
+ if flag & 0x4:
191
+ total += -1
192
+ continue
193
+ as_val = _tag_value(fields, "AS:i:")
194
+ total += int(as_val) if as_val is not None else 0
195
+ return total
196
+
197
+
198
+ def _flush_group(lines: list[str], out: io.TextIOBase) -> None:
199
+ """Pick the best candidate among ``lines`` (one read name) and emit it.
200
+
201
+ Records are partitioned by their ``YC`` candidate tag; the candidate with
202
+ the highest primary alignment score is restored and written, the others are
203
+ dropped. On a tie the first-seen candidate wins (``ct``/``f``).
204
+ """
205
+ by_candidate: dict[str, list[str]] = {}
206
+ for line in lines:
207
+ key = _tag_value(line.rstrip("\n").split("\t"), "YC:Z:") or ""
208
+ by_candidate.setdefault(key, []).append(line)
209
+
210
+ best = max(by_candidate, key=lambda k: _primary_score(by_candidate[k]))
211
+ for line in by_candidate[best]:
212
+ out.write(_restore_alignment(line))
213
+
214
+
215
+ def _process_sam(
216
+ bwa_stdout: io.TextIOBase,
217
+ sort_stdin: io.TextIOBase,
218
+ ) -> None:
219
+ """Group bwa output by read name and write the best candidate of each.
220
+
221
+ bwa-mem preserves input order, so a read's two converted candidates (which
222
+ share the read name) are emitted consecutively; records are buffered until
223
+ the read name changes, then the best candidate is chosen and restored.
224
+ """
225
+ group: list[str] = []
226
+ group_qname: str | None = None
227
+ for line in bwa_stdout:
228
+ if line.startswith("@"):
229
+ # @HD and @SQ are emitted from the FASTA index; pass through
230
+ # everything else (@PG, @RG, @CO).
231
+ if line.startswith(("@HD", "@SQ")):
232
+ continue
233
+ sort_stdin.write(line)
234
+ continue
235
+ tab = line.find("\t")
236
+ qname = line[:tab] if tab != -1 else line.rstrip("\n")
237
+ if group and qname != group_qname:
238
+ _flush_group(group, sort_stdin)
239
+ group = []
240
+ group_qname = qname
241
+ group.append(line)
242
+ if group:
243
+ _flush_group(group, sort_stdin)
244
+
245
+
246
+ def run_align(
247
+ fasta_path: str,
248
+ read1: str,
249
+ out_dir: str,
250
+ out_name: str,
251
+ read2: str | None = None,
252
+ threads: int = 1,
253
+ read_group: str | None = None,
254
+ index_path: str | None = None,
255
+ ) -> None:
256
+ """Align deaminated reads and write a sorted, indexed BAM.
257
+
258
+ The BAM is written to ``<out_dir>/<out_name>.bam`` (with a companion
259
+ ``.bai`` index). The reference must already have been prepared with
260
+ :func:`deamtools.align.index.run_index`.
261
+
262
+ Parameters
263
+ ----------
264
+ fasta_path : str
265
+ Reference FASTA previously indexed with ``deamtools index``.
266
+ read1 : str
267
+ FASTQ for read 1 (or the only FASTQ for single-end input). Plain or
268
+ gzipped.
269
+ out_dir : str
270
+ Output directory. Created if it does not exist.
271
+ out_name : str
272
+ Base name (without extension) for the output; writes
273
+ ``<out_dir>/<out_name>.bam``.
274
+ read2 : str, optional
275
+ FASTQ for read 2 (paired-end). Omit for single-end alignment.
276
+ threads : int, default 1
277
+ Total threads, split between ``bwa mem`` and ``samtools sort``.
278
+ read_group : str, optional
279
+ Read-group line passed to ``bwa mem -R``.
280
+ index_path : str, optional
281
+ Path to the converted reference built by ``deamtools index``
282
+ (``<out_dir>/<out_name>.deamtools.c2t``). Use this when the index was
283
+ built with a custom ``--out_dir`` / ``--out_name``. Defaults to
284
+ ``<fasta>.deamtools.c2t`` (next to the FASTA).
285
+ """
286
+ converted_path = (
287
+ index_path if index_path is not None else fasta_path + ".deamtools.c2t"
288
+ )
289
+ if not os.path.exists(converted_path + ".bwt"):
290
+ raise FileNotFoundError(
291
+ f"BWA index not found at {converted_path}.bwt — run "
292
+ f"'deamtools index --fasta {fasta_path}' first, and pass its "
293
+ f"--out_dir/--out_name location here via --index if it was custom."
294
+ )
295
+ if not os.path.exists(fasta_path + ".fai"):
296
+ raise FileNotFoundError(
297
+ f"FASTA index not found at {fasta_path}.fai — "
298
+ f"run 'deamtools index --fasta {fasta_path}' first."
299
+ )
300
+ if not os.path.exists(read1):
301
+ raise FileNotFoundError(f"FASTQ not found: {read1}")
302
+ if read2 is not None and not os.path.exists(read2):
303
+ raise FileNotFoundError(f"FASTQ not found: {read2}")
304
+ _check_executable("bwa")
305
+ _check_executable("samtools")
306
+
307
+ output_bam = os.path.join(out_dir, f"{out_name}.bam")
308
+ sam_path = os.path.join(out_dir, f"{out_name}.sam")
309
+ paired = read2 is not None
310
+ logger.info(f"Aligning {'paired' if paired else 'single'}-end reads")
311
+ logger.info(f" R1: {read1}")
312
+ if paired:
313
+ logger.info(f" R2: {read2}")
314
+ logger.info(f" Index: {converted_path}")
315
+ logger.info(f" Out: {output_bam}")
316
+
317
+ os.makedirs(out_dir, exist_ok=True)
318
+
319
+ bwa_cmd = ["bwa", "mem", *BWA_MEM_OPTS, "-t", str(threads)]
320
+ if paired:
321
+ bwa_cmd += [*BWA_MEM_PAIRED_OPTS, "-p"]
322
+ if read_group is not None:
323
+ bwa_cmd += ["-R", read_group]
324
+ bwa_cmd += [converted_path, "-"]
325
+
326
+ # Step 1: bwa mem -> restore original sequences/names -> <out_name>.sam.
327
+ logger.info(f"bwa mem ({threads}t), dual conversion + take-best -> {sam_path}")
328
+ bwa_proc = subprocess.Popen(
329
+ bwa_cmd,
330
+ stdin=subprocess.PIPE,
331
+ stdout=subprocess.PIPE,
332
+ text=True,
333
+ bufsize=1 << 20,
334
+ )
335
+
336
+ feeder_exc: list[BaseException] = []
337
+
338
+ def _feed():
339
+ try:
340
+ _feed_converted(read1, read2, bwa_proc.stdin)
341
+ except BaseException as e:
342
+ feeder_exc.append(e)
343
+
344
+ feeder = threading.Thread(target=_feed, daemon=True)
345
+ feeder.start()
346
+
347
+ try:
348
+ with open(sam_path, "w") as sam:
349
+ _emit_clean_header(fasta_path, sam)
350
+ _process_sam(bwa_proc.stdout, sam)
351
+ finally:
352
+ feeder.join()
353
+
354
+ bwa_rc = bwa_proc.wait()
355
+ if feeder_exc:
356
+ raise feeder_exc[0]
357
+ if bwa_rc != 0:
358
+ raise subprocess.CalledProcessError(bwa_rc, bwa_cmd)
359
+
360
+ # Step 2: convert the SAM to a coordinate-sorted, indexed BAM.
361
+ logger.info(f"samtools sort ({threads}t) {sam_path} -> {output_bam}")
362
+ subprocess.run(
363
+ ["samtools", "sort", "-@", str(threads), "-o", output_bam, sam_path],
364
+ check=True,
365
+ )
366
+ logger.info("samtools index ...")
367
+ subprocess.run(["samtools", "index", output_bam], check=True)
368
+ logger.info("Done")
@@ -0,0 +1,144 @@
1
+ """Build a deamination-aware BWA index for a reference FASTA.
2
+
3
+ Following the bwa-meth strategy, the index is built on a *doubly converted*
4
+ copy of the reference: every chromosome appears twice — once with all
5
+ cytosines converted to thymine (``C->T``, prefixed ``f``) and once with all
6
+ guanines converted to adenine (``G->A``, prefixed ``r``). Both are conversions
7
+ of the *forward* sequence; the ``f``/``r`` prefixes denote which deaminated
8
+ read population maps there (top-strand-derived ``C->T`` reads to ``f``,
9
+ bottom-strand-derived ``G->A`` reads to ``r``). Reducing the alphabet this way
10
+ lets BWA-MEM map heavily deaminated reads, and both mates of a pair land on the
11
+ same converted contig so proper pairing is preserved. The ``f``/``r`` prefix is
12
+ stripped from the chromosome name during ``deamtools align``.
13
+
14
+ Outputs:
15
+
16
+ - ``<fasta>.fai`` — samtools faidx of the original
17
+ (always written next to the FASTA; required by the pysam-based subcommands)
18
+ - ``<out_dir>/<out_name>.deamtools.c2t`` — doubly-converted reference
19
+ - ``<out_dir>/<out_name>.deamtools.c2t.{amb,ann,bwt,pac,sa}`` — BWA-MEM index files
20
+
21
+ ``out_dir`` / ``out_name`` default to the FASTA's own directory and file name.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ import os
28
+ import shutil
29
+ import subprocess
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ CT_TABLE = str.maketrans("Cc", "Tt")
34
+ GA_TABLE = str.maketrans("Gg", "Aa")
35
+ LINE_WIDTH = 80
36
+
37
+
38
+ def _check_executable(name: str) -> None:
39
+ if shutil.which(name) is None:
40
+ raise RuntimeError(
41
+ f"'{name}' was not found in PATH. Install it before running 'deamtools index'."
42
+ )
43
+
44
+
45
+ def _convert_fasta(fasta_path: str, output_path: str) -> None:
46
+ """Stream FASTA, writing both ``f`` (C->T) and ``r`` (G->A) entries per chromosome."""
47
+
48
+ def flush(header: str | None, seq_parts: list[str], out) -> None:
49
+ if header is None:
50
+ return
51
+ seq = "".join(seq_parts)
52
+ out.write(f">f{header}\n")
53
+ for i in range(0, len(seq), LINE_WIDTH):
54
+ out.write(seq[i : i + LINE_WIDTH].translate(CT_TABLE) + "\n")
55
+ out.write(f">r{header}\n")
56
+ for i in range(0, len(seq), LINE_WIDTH):
57
+ out.write(seq[i : i + LINE_WIDTH].translate(GA_TABLE) + "\n")
58
+
59
+ header: str | None = None
60
+ seq_parts: list[str] = []
61
+
62
+ with open(fasta_path) as fin, open(output_path, "w") as fout:
63
+ for line in fin:
64
+ if line.startswith(">"):
65
+ flush(header, seq_parts, fout)
66
+ header = line[1:].split()[0].strip()
67
+ seq_parts = []
68
+ else:
69
+ seq_parts.append(line.strip())
70
+ flush(header, seq_parts, fout)
71
+
72
+
73
+ def run_index(
74
+ fasta_path: str,
75
+ out_dir: str | None = None,
76
+ out_name: str | None = None,
77
+ force: bool = False,
78
+ ) -> None:
79
+ """Build the deamtools BWA index for ``fasta_path``.
80
+
81
+ The converted reference and its BWA-MEM index are written to
82
+ ``<out_dir>/<out_name>.deamtools.c2t*``. When ``out_dir`` / ``out_name`` are
83
+ omitted they default to the FASTA's own directory and file name, so the
84
+ index lands next to the FASTA — the location :func:`deamtools.align.run_align`
85
+ looks in by default.
86
+
87
+ The standard FASTA index (``<fasta>.fai``) is always written next to the
88
+ original FASTA regardless of ``out_dir`` / ``out_name``, because the
89
+ pysam-based subcommands (``bam2bw``, ``bam2fragment``, ``qc``, ...) require
90
+ it there.
91
+
92
+ Skips work that has already been done unless ``force=True``.
93
+
94
+ Parameters
95
+ ----------
96
+ fasta_path : str
97
+ Reference FASTA to index.
98
+ out_dir : str, optional
99
+ Directory for the converted reference + BWA index. Defaults to the
100
+ FASTA's directory.
101
+ out_name : str, optional
102
+ Base name for the converted reference + BWA index. Defaults to the
103
+ FASTA file name.
104
+ force : bool, default False
105
+ Rebuild outputs even if they already exist.
106
+ """
107
+ if not os.path.exists(fasta_path):
108
+ raise FileNotFoundError(f"FASTA not found: {fasta_path}")
109
+
110
+ _check_executable("bwa")
111
+ _check_executable("samtools")
112
+
113
+ if out_dir is None:
114
+ out_dir = os.path.dirname(fasta_path) or "."
115
+ if out_name is None:
116
+ out_name = os.path.basename(fasta_path)
117
+ os.makedirs(out_dir, exist_ok=True)
118
+
119
+ logger.info(f"Indexing {fasta_path}")
120
+
121
+ # The .fai must sit next to the original FASTA for pysam-based subcommands.
122
+ fai_path = fasta_path + ".fai"
123
+ if force or not os.path.exists(fai_path):
124
+ logger.info("samtools faidx ...")
125
+ subprocess.run(["samtools", "faidx", fasta_path], check=True)
126
+ else:
127
+ logger.info(f" {fai_path} exists; skipping faidx")
128
+
129
+ converted_path = os.path.join(out_dir, f"{out_name}.deamtools.c2t")
130
+ if force or not os.path.exists(converted_path):
131
+ logger.info(f" writing converted reference to {converted_path}")
132
+ _convert_fasta(fasta_path, converted_path)
133
+ else:
134
+ logger.info(f" {converted_path} exists; skipping conversion")
135
+
136
+ bwt_path = converted_path + ".bwt"
137
+ if force or not os.path.exists(bwt_path):
138
+ logger.info("bwa index (this may take a while) ...")
139
+ subprocess.run(["bwa", "index", converted_path], check=True)
140
+ else:
141
+ logger.info(f" {bwt_path} exists; skipping bwa index")
142
+
143
+ logger.info(f"Converted index: {converted_path}")
144
+ logger.info("Done")
File without changes