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 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()