countmut 0.0.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.
- countmut/__init__.py +27 -0
- countmut/cli.py +396 -0
- countmut/core.py +728 -0
- countmut/utils.py +108 -0
- countmut-0.0.1.dist-info/METADATA +183 -0
- countmut-0.0.1.dist-info/RECORD +10 -0
- countmut-0.0.1.dist-info/WHEEL +5 -0
- countmut-0.0.1.dist-info/entry_points.txt +2 -0
- countmut-0.0.1.dist-info/licenses/LICENSE +21 -0
- countmut-0.0.1.dist-info/top_level.txt +1 -0
countmut/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CountMut - Fast, parallel mutation counting from BAM pileup data
|
|
3
|
+
|
|
4
|
+
This package provides efficient mutation counting functionality with:
|
|
5
|
+
- Parallel processing using genomic windows
|
|
6
|
+
- Bisulfite conversion analysis
|
|
7
|
+
- Rich logging and progress tracking
|
|
8
|
+
- Modern CLI interface
|
|
9
|
+
|
|
10
|
+
Author: Ye Chang
|
|
11
|
+
Date: 2025-10-23
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .cli import main
|
|
15
|
+
from .core import count_mutations
|
|
16
|
+
from .utils import format_duration, get_output_headers, write_output
|
|
17
|
+
|
|
18
|
+
__author__ = "Ye Chang"
|
|
19
|
+
__email__ = "yech1990@gmail.com"
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"count_mutations",
|
|
23
|
+
"format_duration",
|
|
24
|
+
"get_output_headers",
|
|
25
|
+
"main",
|
|
26
|
+
"write_output",
|
|
27
|
+
]
|
countmut/cli.py
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
CountMut CLI - Beautiful command-line interface for mutation counting
|
|
4
|
+
|
|
5
|
+
This module provides a modern CLI interface for CountMut with rich output,
|
|
6
|
+
progress tracking, and comprehensive help.
|
|
7
|
+
|
|
8
|
+
Author: Ye Chang
|
|
9
|
+
Date: 2025-10-23
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from importlib import metadata as importlib_metadata
|
|
16
|
+
|
|
17
|
+
import pysam
|
|
18
|
+
import rich_click as click
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
from rich.panel import Panel
|
|
21
|
+
from rich.table import Table
|
|
22
|
+
|
|
23
|
+
from .core import count_mutations
|
|
24
|
+
from .utils import format_duration
|
|
25
|
+
|
|
26
|
+
# Configure rich-click
|
|
27
|
+
click.rich_click.TEXT_MARKUP = "rich"
|
|
28
|
+
click.rich_click.SHOW_ARGUMENTS = True
|
|
29
|
+
click.rich_click.GROUP_ARGUMENTS_OPTIONS = True
|
|
30
|
+
click.rich_click.STYLE_ERRORS_SUGGESTION = "magenta italic"
|
|
31
|
+
click.rich_click.ERRORS_SUGGESTION = (
|
|
32
|
+
"Try running the '--help' flag for more information."
|
|
33
|
+
)
|
|
34
|
+
click.rich_click.ERRORS_EPILOGUE = "To find out more, visit [link=https://github.com/y9c/countmut]https://github.com/y9c/countmut[/link]"
|
|
35
|
+
click.rich_click.TEXT_EMOJIS = True
|
|
36
|
+
|
|
37
|
+
console = Console()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def validate_bam_file(bam_file: str, threads: int = 1) -> bool:
|
|
41
|
+
"""Validate BAM file and create index if needed."""
|
|
42
|
+
try:
|
|
43
|
+
# Check if index exists and is newer than BAM file
|
|
44
|
+
index_file = bam_file + ".bai"
|
|
45
|
+
bam_mtime = os.path.getmtime(bam_file)
|
|
46
|
+
|
|
47
|
+
if not os.path.exists(index_file):
|
|
48
|
+
console.print("📝 Creating BAM index...")
|
|
49
|
+
pysam.index(bam_file, "-@", str(threads))
|
|
50
|
+
else:
|
|
51
|
+
# Check if index is older than BAM file
|
|
52
|
+
index_mtime = os.path.getmtime(index_file)
|
|
53
|
+
if index_mtime < bam_mtime:
|
|
54
|
+
console.print("🔄 BAM index is older than BAM file, rebuilding...")
|
|
55
|
+
pysam.index(bam_file, "-@", str(threads))
|
|
56
|
+
|
|
57
|
+
# Check if sorted by coordinate
|
|
58
|
+
with pysam.AlignmentFile(bam_file, "rb") as f:
|
|
59
|
+
header = f.header
|
|
60
|
+
if "HD" in header and "SO" in header["HD"]:
|
|
61
|
+
return header["HD"]["SO"] == "coordinate"
|
|
62
|
+
return False
|
|
63
|
+
except Exception as e:
|
|
64
|
+
console.print(f"[red]Validation error: {e}[/red]")
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@click.command(
|
|
70
|
+
name="countmut",
|
|
71
|
+
no_args_is_help=True,
|
|
72
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
73
|
+
epilog="""
|
|
74
|
+
Examples:
|
|
75
|
+
|
|
76
|
+
# Basic usage
|
|
77
|
+
countmut -i input.bam -r reference.fa
|
|
78
|
+
|
|
79
|
+
# Save to file with custom parameters
|
|
80
|
+
countmut -i input.bam -r reference.fa -o mutations.tsv --ref-base T --mut-base C
|
|
81
|
+
|
|
82
|
+
# Use more threads and smaller bins
|
|
83
|
+
countmut -i input.bam -r reference.fa -t 16 -b 5000
|
|
84
|
+
|
|
85
|
+
# Save additional statistics
|
|
86
|
+
countmut -i input.bam -r reference.fa -s
|
|
87
|
+
|
|
88
|
+
# Process specific region
|
|
89
|
+
countmut -i input.bam -r reference.fa --region chr1:1000000-2000000
|
|
90
|
+
""",
|
|
91
|
+
)
|
|
92
|
+
@click.option(
|
|
93
|
+
"-i",
|
|
94
|
+
"--input",
|
|
95
|
+
"samfile",
|
|
96
|
+
type=click.Path(exists=True, path_type=str),
|
|
97
|
+
required=True,
|
|
98
|
+
help="Input BAM file (coordinate-sorted, indexed)",
|
|
99
|
+
)
|
|
100
|
+
@click.option(
|
|
101
|
+
"-r",
|
|
102
|
+
"--reference",
|
|
103
|
+
"reffile",
|
|
104
|
+
type=click.Path(exists=True, path_type=str),
|
|
105
|
+
required=True,
|
|
106
|
+
help="Reference FASTA file",
|
|
107
|
+
)
|
|
108
|
+
@click.option(
|
|
109
|
+
"-o",
|
|
110
|
+
"--output",
|
|
111
|
+
type=click.Path(path_type=str),
|
|
112
|
+
help="[bold]Output file[/bold] for mutation counts (TSV format). If not specified, prints to stdout.",
|
|
113
|
+
)
|
|
114
|
+
@click.option(
|
|
115
|
+
"--ref-base",
|
|
116
|
+
default="A",
|
|
117
|
+
show_default=True,
|
|
118
|
+
help="[bold]Reference base[/bold] to count mutations from (A, T, G, or C)",
|
|
119
|
+
)
|
|
120
|
+
@click.option(
|
|
121
|
+
"--mut-base",
|
|
122
|
+
default="G",
|
|
123
|
+
show_default=True,
|
|
124
|
+
help="[bold]Mutation base[/bold] to count (A, T, G, or C)",
|
|
125
|
+
)
|
|
126
|
+
@click.option(
|
|
127
|
+
"-b",
|
|
128
|
+
"--bin-size",
|
|
129
|
+
type=int,
|
|
130
|
+
default=10_000,
|
|
131
|
+
show_default=True,
|
|
132
|
+
help="[bold]Genomic bin size[/bold] for parallel processing (in base pairs)",
|
|
133
|
+
)
|
|
134
|
+
@click.option(
|
|
135
|
+
"-t",
|
|
136
|
+
"--threads",
|
|
137
|
+
type=int,
|
|
138
|
+
default=None,
|
|
139
|
+
help="[bold]Number of threads[/bold] for parallel processing (default: auto-detect)",
|
|
140
|
+
)
|
|
141
|
+
@click.option(
|
|
142
|
+
"-s",
|
|
143
|
+
"--save-rest",
|
|
144
|
+
is_flag=True,
|
|
145
|
+
help="[bold]Save additional statistics[/bold] including y0, y1, y2 columns",
|
|
146
|
+
)
|
|
147
|
+
@click.option(
|
|
148
|
+
"--region",
|
|
149
|
+
type=str,
|
|
150
|
+
help="[bold]Genomic region[/bold] to process (e.g., 'chr1:1000000-2000000')",
|
|
151
|
+
)
|
|
152
|
+
@click.option(
|
|
153
|
+
"-f",
|
|
154
|
+
"--force",
|
|
155
|
+
is_flag=True,
|
|
156
|
+
help="[bold]Overwrite output file[/bold] without prompting",
|
|
157
|
+
)
|
|
158
|
+
@click.option(
|
|
159
|
+
"--strand",
|
|
160
|
+
type=click.Choice(["both", "forward", "reverse"], case_sensitive=False),
|
|
161
|
+
default="both",
|
|
162
|
+
show_default=True,
|
|
163
|
+
help="[bold]Strand processing[/bold]: 'both' (default), 'forward' (+ only), or 'reverse' (- only)",
|
|
164
|
+
)
|
|
165
|
+
@click.option(
|
|
166
|
+
"--pad",
|
|
167
|
+
type=int,
|
|
168
|
+
default=15,
|
|
169
|
+
show_default=True,
|
|
170
|
+
help="[bold]Motif half-window padding[/bold] around each site",
|
|
171
|
+
)
|
|
172
|
+
@click.option(
|
|
173
|
+
"--trim-start",
|
|
174
|
+
type=int,
|
|
175
|
+
default=2,
|
|
176
|
+
show_default=True,
|
|
177
|
+
help="[bold]Trim bases[/bold] at read 5' end when counting",
|
|
178
|
+
)
|
|
179
|
+
@click.option(
|
|
180
|
+
"--trim-end",
|
|
181
|
+
type=int,
|
|
182
|
+
default=2,
|
|
183
|
+
show_default=True,
|
|
184
|
+
help="[bold]Trim bases[/bold] at read 3' end when counting",
|
|
185
|
+
)
|
|
186
|
+
@click.option(
|
|
187
|
+
"--max-unc",
|
|
188
|
+
type=int,
|
|
189
|
+
default=3,
|
|
190
|
+
show_default=True,
|
|
191
|
+
help="[bold]Max unconverted threshold[/bold] (Zf) to consider converted",
|
|
192
|
+
)
|
|
193
|
+
@click.option(
|
|
194
|
+
"--min-con",
|
|
195
|
+
type=int,
|
|
196
|
+
default=1,
|
|
197
|
+
show_default=True,
|
|
198
|
+
help="[bold]Min converted threshold[/bold] (Yf) to consider converted",
|
|
199
|
+
)
|
|
200
|
+
@click.option(
|
|
201
|
+
"--max-sub",
|
|
202
|
+
type=int,
|
|
203
|
+
default=1,
|
|
204
|
+
show_default=True,
|
|
205
|
+
help="[bold]Max substitutions[/bold] (NS) to consider mapped",
|
|
206
|
+
)
|
|
207
|
+
@click.version_option(importlib_metadata.version("countmut"), "--version", "-v", prog_name="countmut", message="%(prog)s %(version)s")
|
|
208
|
+
def main(
|
|
209
|
+
samfile: str,
|
|
210
|
+
reffile: str,
|
|
211
|
+
output: str,
|
|
212
|
+
ref_base: str,
|
|
213
|
+
mut_base: str,
|
|
214
|
+
bin_size: int,
|
|
215
|
+
threads: int,
|
|
216
|
+
save_rest: bool,
|
|
217
|
+
region: str,
|
|
218
|
+
force: bool,
|
|
219
|
+
strand: str,
|
|
220
|
+
pad: int,
|
|
221
|
+
trim_start: int,
|
|
222
|
+
trim_end: int,
|
|
223
|
+
max_unc: int,
|
|
224
|
+
min_con: int,
|
|
225
|
+
max_sub: int,
|
|
226
|
+
):
|
|
227
|
+
"""
|
|
228
|
+
[bold blue]🧬 CountMut - Fast Mutation Counting from BAM Pileup[/bold blue]
|
|
229
|
+
|
|
230
|
+
A fast, parallel tool for counting mutations from BAM pileup data with
|
|
231
|
+
bisulfite conversion analysis and genomic window processing.
|
|
232
|
+
|
|
233
|
+
[bold]Key Features:[/bold]
|
|
234
|
+
|
|
235
|
+
• [bold green]Parallel processing[/bold green]: Multi-threaded genomic window processing\n
|
|
236
|
+
• [bold green]Bisulfite analysis[/bold green]: Built-in conversion detection\n
|
|
237
|
+
• [bold green]Flexible output[/bold green]: TSV format with optional statistics\n
|
|
238
|
+
• [bold green]Memory efficient[/bold green]: Streaming processing for large files\n
|
|
239
|
+
• [bold green]Rich output[/bold green]: Progress bars and detailed statistics\n
|
|
240
|
+
• [bold green]Region support[/bold green]: Process specific genomic regions
|
|
241
|
+
|
|
242
|
+
[bold]Input Requirements:[/bold]
|
|
243
|
+
|
|
244
|
+
• Input BAM file, coordinate-sorted (required), indexed with .bai file (created automatically if missing)\n
|
|
245
|
+
• Reference FASTA file (required)
|
|
246
|
+
|
|
247
|
+
[bold]Output Format:[/bold]
|
|
248
|
+
|
|
249
|
+
The output TSV file contains the following columns:
|
|
250
|
+
• chrom: Chromosome name
|
|
251
|
+
• pos: Position (1-based)
|
|
252
|
+
• strand: Strand (+ or -)
|
|
253
|
+
• motif: Sequence motif around the position
|
|
254
|
+
• u0, d0: Drop counts (unconverted, total)
|
|
255
|
+
• u1, d1: Clean counts (unconverted, total)
|
|
256
|
+
• u2, d2: Unconverted counts (unconverted, total)
|
|
257
|
+
• y0, y1, y2: Additional counts (if --save-rest is used)
|
|
258
|
+
|
|
259
|
+
"""
|
|
260
|
+
|
|
261
|
+
# Banner disabled per request
|
|
262
|
+
|
|
263
|
+
# Validate input files
|
|
264
|
+
if not os.path.exists(samfile):
|
|
265
|
+
console.print(f"[red]❌ Input BAM file '{samfile}' does not exist![/red]")
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
if not os.path.exists(reffile):
|
|
269
|
+
console.print(f"[red]❌ Reference file '{reffile}' does not exist![/red]")
|
|
270
|
+
return
|
|
271
|
+
|
|
272
|
+
# Validate base parameters
|
|
273
|
+
valid_bases = {"A", "T", "G", "C"}
|
|
274
|
+
if ref_base.upper() not in valid_bases:
|
|
275
|
+
console.print(
|
|
276
|
+
f"[red]❌ Invalid reference base '{ref_base}'. Must be one of: {', '.join(valid_bases)}[/red]"
|
|
277
|
+
)
|
|
278
|
+
return
|
|
279
|
+
|
|
280
|
+
if mut_base.upper() not in valid_bases:
|
|
281
|
+
console.print(
|
|
282
|
+
f"[red]❌ Invalid mutation base '{mut_base}'. Must be one of: {', '.join(valid_bases)}[/red]"
|
|
283
|
+
)
|
|
284
|
+
return
|
|
285
|
+
|
|
286
|
+
# Validate numeric parameters
|
|
287
|
+
if bin_size <= 0:
|
|
288
|
+
console.print(f"[red]❌ Bin size must be positive, got: {bin_size}[/red]")
|
|
289
|
+
return
|
|
290
|
+
|
|
291
|
+
if threads is not None and threads <= 0:
|
|
292
|
+
console.print(f"[red]❌ Thread count must be positive, got: {threads}[/red]")
|
|
293
|
+
return
|
|
294
|
+
|
|
295
|
+
# Check output file
|
|
296
|
+
if output and os.path.exists(output):
|
|
297
|
+
if not force:
|
|
298
|
+
response = console.input(
|
|
299
|
+
f"[yellow]⚠️ Output file '{output}' already exists. Overwrite? (y/N): [/yellow]"
|
|
300
|
+
)
|
|
301
|
+
if response.lower() != "y":
|
|
302
|
+
console.print("[yellow]Operation cancelled.[/yellow]")
|
|
303
|
+
return
|
|
304
|
+
else:
|
|
305
|
+
console.print(f"[yellow]⚠️ Overwriting existing file: {output}[/yellow]")
|
|
306
|
+
|
|
307
|
+
# Create output directory if needed
|
|
308
|
+
if output:
|
|
309
|
+
output_dir = os.path.dirname(output)
|
|
310
|
+
if output_dir and not os.path.exists(output_dir):
|
|
311
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
312
|
+
|
|
313
|
+
# Display configuration
|
|
314
|
+
info_table = Table(show_header=False, box=None, padding=(0, 1))
|
|
315
|
+
info_table.add_column(style="bold blue", justify="left")
|
|
316
|
+
info_table.add_column(style="white", justify="left")
|
|
317
|
+
|
|
318
|
+
info_table.add_row("Input BAM:", samfile)
|
|
319
|
+
info_table.add_row("Reference:", reffile)
|
|
320
|
+
info_table.add_row("Output:", output or "stdout")
|
|
321
|
+
info_table.add_row("Reference base:", ref_base.upper())
|
|
322
|
+
info_table.add_row("Mutation base:", mut_base.upper())
|
|
323
|
+
info_table.add_row("Bin size:", f"{bin_size:,}")
|
|
324
|
+
info_table.add_row("Threads:", str(threads or "auto"))
|
|
325
|
+
info_table.add_row("Save additional stats:", "Yes" if save_rest else "No")
|
|
326
|
+
if region:
|
|
327
|
+
info_table.add_row("Region:", region)
|
|
328
|
+
info_table.add_row("Pad:", str(pad))
|
|
329
|
+
info_table.add_row("Trim start:", str(trim_start))
|
|
330
|
+
info_table.add_row("Trim end:", str(trim_end))
|
|
331
|
+
info_table.add_row("Max unconverted (Zf):", str(max_unc))
|
|
332
|
+
info_table.add_row("Min converted (Yf):", str(min_con))
|
|
333
|
+
info_table.add_row("Max substitutions (NS):", str(max_sub))
|
|
334
|
+
|
|
335
|
+
console.print(
|
|
336
|
+
Panel(
|
|
337
|
+
info_table,
|
|
338
|
+
title="[bold green]Processing configuration[/bold green]",
|
|
339
|
+
border_style="green",
|
|
340
|
+
padding=(1, 2),
|
|
341
|
+
)
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
# Validate BAM file
|
|
345
|
+
console.print("🔍 Validating BAM file...")
|
|
346
|
+
if not validate_bam_file(samfile, threads or 1):
|
|
347
|
+
console.print("[red]❌ BAM file validation failed![/red]")
|
|
348
|
+
return
|
|
349
|
+
console.print("✅ BAM file validation passed")
|
|
350
|
+
|
|
351
|
+
# Process the file
|
|
352
|
+
console.print("🚀 Starting mutation counting...")
|
|
353
|
+
|
|
354
|
+
try:
|
|
355
|
+
_start_time = time.time()
|
|
356
|
+
success = count_mutations(
|
|
357
|
+
samfile=samfile,
|
|
358
|
+
reffile=reffile,
|
|
359
|
+
output_file=output,
|
|
360
|
+
ref_base=ref_base.upper(),
|
|
361
|
+
mut_base=mut_base.upper(),
|
|
362
|
+
bin_size=bin_size,
|
|
363
|
+
threads=threads,
|
|
364
|
+
save_rest=save_rest,
|
|
365
|
+
region=region,
|
|
366
|
+
strand=strand,
|
|
367
|
+
pad=pad,
|
|
368
|
+
trim_start=trim_start,
|
|
369
|
+
trim_end=trim_end,
|
|
370
|
+
max_unc=max_unc,
|
|
371
|
+
min_con=min_con,
|
|
372
|
+
max_sub=max_sub,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
if success:
|
|
376
|
+
_duration = time.time() - _start_time
|
|
377
|
+
console.print(f"✅ Mutation counting completed successfully! (⏱️ {format_duration(_duration)})")
|
|
378
|
+
if output:
|
|
379
|
+
console.print(f"📄 Results saved to: {output}")
|
|
380
|
+
else:
|
|
381
|
+
console.print("[red]❌ Mutation counting failed![/red]")
|
|
382
|
+
sys.exit(1)
|
|
383
|
+
|
|
384
|
+
except KeyboardInterrupt:
|
|
385
|
+
console.print("\n[yellow]⚠️ Processing interrupted by user[/yellow]")
|
|
386
|
+
sys.exit(1)
|
|
387
|
+
except Exception as e:
|
|
388
|
+
console.print(f"[red]❌ Unexpected error: {e}[/red]")
|
|
389
|
+
import traceback
|
|
390
|
+
|
|
391
|
+
traceback.print_exc()
|
|
392
|
+
sys.exit(1)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
if __name__ == "__main__":
|
|
396
|
+
main()
|