ribometric 1.5.0__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.
@@ -0,0 +1,634 @@
1
+ """
2
+ Main module for RiboMetric
3
+ Handles the command line interface and calls the appropriate functions
4
+
5
+ Many different input combinations are possible.
6
+
7
+ Minimal Set:
8
+ -b, --bam <path> : Path to the bam file
9
+
10
+ With this set the calculations will potentially less reliable and no gene
11
+ feature information will be included in the output
12
+
13
+ Standard Set:
14
+ -b, --bam <path> : Path to the bam file
15
+ -g, --gff <path> : Path to the gff file
16
+
17
+ with this set the calculations will be more reliable and gene feature
18
+ information will be included in the output
19
+
20
+ Full Set:
21
+ -b, --bam <path> : Path to the bam file
22
+ -g, --gff <path> : Path to the gff file
23
+ -t, --transcriptome <path> : Path to the transcriptome fasta file
24
+
25
+ with this set the calculations will contain the post information in its
26
+ output but will take longest to run
27
+
28
+
29
+ Optional Arguments:
30
+ -n, --name <str> : Name of the sample being analysed
31
+ (default: filename of bam file)
32
+ -S, --subsample <int> : Number of reads to subsample from the bam file
33
+ (default: 10000000)
34
+ -T, --transcripts <int> : Number of transcripts to consider
35
+ (default: 100000)
36
+ -c, --config <path> : Path to the config file
37
+ (default: config.yaml)
38
+
39
+ Output:
40
+ --json : Output the results as a json file
41
+ --html : Output the results as an html file (default)
42
+ --pdf : Output the results as a pdf file
43
+ --csv : Output the results as a csv file
44
+ --all : Output the results as all of the above
45
+ """
46
+
47
+ import argparse
48
+ import hashlib
49
+ import json
50
+ import os
51
+ import platform
52
+ from datetime import datetime, timezone
53
+ from pathlib import Path
54
+ from typing import Any, Dict, Optional
55
+
56
+ from rich.console import Console
57
+ from rich.table import Table
58
+ from rich.text import Text
59
+
60
+ from .arg_parser import argument_parser, open_config
61
+ from .bam_processing import recompute_sequence_summaries
62
+ from .file_parser import (
63
+ check_annotation,
64
+ check_bam,
65
+ deterministic_subsample,
66
+ flagstat_bam,
67
+ parse_annotation,
68
+ parse_bam,
69
+ parse_fasta,
70
+ prepare_annotation,
71
+ )
72
+ from .html_report import generate_report, parse_json_input
73
+ from .plots import generate_plots
74
+ from .qc import annotation_mode
75
+ from .results_output import (
76
+ generate_all_outputs,
77
+ generate_comparison_ready_csv,
78
+ generate_csv,
79
+ generate_json,
80
+ generate_metrics_table_csv,
81
+ generate_offsets_tsv,
82
+ generate_qc_status,
83
+ generate_summary_tsv,
84
+ )
85
+
86
+ _HASH_SAMPLE_BYTES = 1024 * 1024
87
+ _FULL_HASH_LIMIT_BYTES = 50 * 1024 * 1024
88
+
89
+
90
+ def _config_path_used(args: argparse.Namespace) -> str:
91
+ if os.path.exists(args.config):
92
+ return str(args.config)
93
+ return str(Path(__file__).with_name("config.yml"))
94
+
95
+
96
+ def _sha256_bytes(data: bytes) -> str:
97
+ return hashlib.sha256(data).hexdigest()
98
+
99
+
100
+ def _file_fingerprint(path_value: Any) -> Dict[str, Any]:
101
+ """Return a reproducibility fingerprint without forcing huge full-file hashes."""
102
+ if not path_value:
103
+ return {"path": path_value, "exists": False}
104
+
105
+ path = Path(str(path_value))
106
+ record: Dict[str, Any] = {
107
+ "path": str(path),
108
+ "exists": path.exists(),
109
+ }
110
+ if not path.exists() or not path.is_file():
111
+ return record
112
+
113
+ stat = path.stat()
114
+ record.update(
115
+ {
116
+ "size_bytes": stat.st_size,
117
+ "mtime_ns": stat.st_mtime_ns,
118
+ }
119
+ )
120
+
121
+ full_hash = (
122
+ os.environ.get("RIBOMETRIC_FULL_INPUT_HASH", "").lower() in {"1", "true", "yes"}
123
+ or stat.st_size <= _FULL_HASH_LIMIT_BYTES
124
+ )
125
+
126
+ h = hashlib.sha256()
127
+ if full_hash:
128
+ with path.open("rb") as handle:
129
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
130
+ h.update(chunk)
131
+ record["sha256"] = h.hexdigest()
132
+ record["hash_method"] = "full_sha256"
133
+ else:
134
+ with path.open("rb") as handle:
135
+ first = handle.read(_HASH_SAMPLE_BYTES)
136
+ if stat.st_size > _HASH_SAMPLE_BYTES:
137
+ handle.seek(max(0, stat.st_size - _HASH_SAMPLE_BYTES))
138
+ last = handle.read(_HASH_SAMPLE_BYTES)
139
+ else:
140
+ last = b""
141
+ h.update(first)
142
+ h.update(last)
143
+ record["sha256_sampled"] = h.hexdigest()
144
+ record["hash_method"] = f"first_last_{_HASH_SAMPLE_BYTES}_bytes_sha256"
145
+ return record
146
+
147
+
148
+ def _build_run_provenance(
149
+ args: argparse.Namespace,
150
+ config: Dict[str, Any],
151
+ subsampling: Optional[Dict[str, Any]] = None,
152
+ ) -> Dict[str, Any]:
153
+ arg_cfg = config.get("argument", {})
154
+ input_keys = [
155
+ "bam",
156
+ "annotation",
157
+ "gff",
158
+ "fasta",
159
+ "json_in",
160
+ "offset_read_length",
161
+ "offset_read_specific",
162
+ ]
163
+ config_path = _config_path_used(args)
164
+ provenance = {
165
+ "timestamp_utc": datetime.now(timezone.utc).isoformat(),
166
+ "command": getattr(args, "command", None),
167
+ "package_version": _package_version(),
168
+ "python": platform.python_version(),
169
+ "platform": platform.platform(),
170
+ "config_file": _file_fingerprint(config_path),
171
+ "effective_config_sha256": _sha256_bytes(
172
+ json.dumps(config, sort_keys=True, default=str).encode("utf-8")
173
+ ),
174
+ "inputs": {
175
+ key: _file_fingerprint(arg_cfg.get(key))
176
+ for key in input_keys
177
+ if arg_cfg.get(key) is not None
178
+ },
179
+ "subsampling": (
180
+ subsampling
181
+ if subsampling is not None
182
+ else {
183
+ "requested": arg_cfg.get("subsample"),
184
+ "seed": arg_cfg.get("seed", 42) if arg_cfg.get("subsample") is not None else None,
185
+ "fraction": None,
186
+ "realised_count": None,
187
+ }
188
+ ),
189
+ }
190
+ return provenance
191
+
192
+
193
+ def _package_version() -> str:
194
+ try:
195
+ from . import __version__
196
+
197
+ return str(__version__)
198
+ except Exception:
199
+ return "unknown"
200
+
201
+
202
+ def print_logo(console: Console) -> None:
203
+ """
204
+ print the logo to the console
205
+ """
206
+ logo = Text(
207
+ """
208
+ ██████╗ ██╗ ██████╗ ██████╗
209
+ ██╔══██╗ ██║ ██╔══██╗██╔═══██╗
210
+ ██████╔╝ ██║ ██████╔╝██║ ██║
211
+ ██╔══██╗ ██║ ██╔══██╗██║ ██║
212
+ ██║ ██║ ██║ ██████╔╝╚██████╔╝
213
+ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝
214
+ """,
215
+ style="bold blue",
216
+ )
217
+ logo += Text(
218
+ """
219
+ ███╗ ███╗███████╗█████████╗██████╗ ██╗ ██████╗
220
+ ████╗ ████║██╔════╝╚══██╔═══╝██╔══██╗ ██║ ██╔════╝
221
+ ██╔████╔██║█████╗ ██║ ██████╔╝ ██║ ██║
222
+ ██║╚██╔╝██║██╔══╝ ██║ ██╔══██╗ ██║ ██║
223
+ ██║ ╚═╝ ██║███████╗ ██║ ██║ ██║ ██║ ╚██████╗
224
+ ╚═╝ ╚═╝╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝
225
+ """,
226
+ style="bold red",
227
+ )
228
+ console.print(logo)
229
+
230
+
231
+ def print_table_run(
232
+ args: argparse.Namespace, config: Dict[str, Any], console: Console, mode: str
233
+ ) -> None:
234
+ console = Console()
235
+
236
+ Inputs = Table(show_header=True, header_style="bold magenta")
237
+ Inputs.add_column("Parameters", style="dim", width=20)
238
+ Inputs.add_column("Values")
239
+ if config["argument"]["bam"]:
240
+ Inputs.add_row("Bam File:", config["argument"]["bam"])
241
+ if config["argument"].get("annotation"):
242
+ Inputs.add_row("Annotation File:", config["argument"]["annotation"])
243
+ elif config["argument"].get("gff"):
244
+ Inputs.add_row("GFF File:", config["argument"]["gff"])
245
+ if config["argument"].get("fasta"):
246
+ Inputs.add_row("Transcriptome File:", config["argument"]["fasta"])
247
+ elif config["argument"]["json_in"]:
248
+ Inputs.add_row("JSON File:", config["argument"]["json_in"])
249
+
250
+ Configs = Table(show_header=True, header_style="bold yellow")
251
+ Configs.add_column("Options", style="dim", width=20)
252
+ Configs.add_column("Values")
253
+ Configs.add_row("Mode:", mode)
254
+ subs = config["argument"].get("subsample")
255
+ trans = config["argument"].get("transcripts")
256
+ Configs.add_row("# of reads:", str(subs) if subs is not None else "Full file")
257
+ Configs.add_row("# of transcripts:", str(trans) if trans is not None else "Full file")
258
+ Configs.add_row("# of threads:", str(config["argument"]["threads"]))
259
+ Configs.add_row("Config file:", args.config)
260
+
261
+ Output = Table(show_header=True, header_style="bold blue")
262
+ Output.add_column("Output Options", style="dim", width=20)
263
+ Output.add_column("Values")
264
+ Output.add_row("JSON:", str(config["argument"]["json"]))
265
+ Output.add_row("HTML:", str(config["argument"]["html"]))
266
+ Output.add_row("PDF:", str(config["argument"]["pdf"]))
267
+ Output.add_row("CSV:", str(config["argument"]["csv"]))
268
+
269
+ # Print tables side by side
270
+ console.print(Inputs, Configs, Output, justify=None, style="bold")
271
+
272
+
273
+ def print_table_prepare(
274
+ args: argparse.Namespace, config: Dict[str, Any], console: Console, mode: str
275
+ ) -> None:
276
+ console = Console()
277
+
278
+ Inputs = Table(show_header=True, header_style="bold magenta")
279
+ Inputs.add_column("Parameters", style="dim", width=20)
280
+ Inputs.add_column("Values")
281
+ Inputs.add_row("Gff File:", config["argument"]["gff"])
282
+
283
+ Configs = Table(show_header=True, header_style="bold yellow")
284
+ Configs.add_column("Options", style="dim", width=20)
285
+ Configs.add_column("Values")
286
+ Configs.add_row("Mode:", mode)
287
+ trans = config["argument"].get("transcripts")
288
+ Configs.add_row("# of transcripts:", str(trans) if trans is not None else "Full file")
289
+ Configs.add_row("# of threads:", str(config["argument"]["threads"]))
290
+ Configs.add_row("Config file:", args.config)
291
+
292
+ # Print tables side by side
293
+ console.print(Inputs, Configs, justify=None, style="bold")
294
+
295
+
296
+ def main(args: argparse.Namespace) -> int:
297
+ """
298
+ Main function for the RiboMetric command line interface
299
+
300
+ Inputs:
301
+ args: Namespace object containing the parsed arguments
302
+
303
+ Outputs:
304
+ None
305
+ """
306
+ # Handle evaluate command separately (no logo or config needed)
307
+ if args.command == "evaluate":
308
+ from .evaluate import evaluate as run_evaluate
309
+
310
+ return run_evaluate(args)
311
+
312
+ # Handle view command separately (no logo or config needed)
313
+ if args.command == "view":
314
+ import json
315
+
316
+ from .tui import run_tui
317
+
318
+ # Validate file exists and is JSON
319
+ file_path = Path(args.json_file)
320
+ if not file_path.exists():
321
+ print(f"Error: File not found: {args.json_file}")
322
+ return 1
323
+
324
+ if not file_path.suffix == ".json":
325
+ print("Error: File must be a JSON file")
326
+ return 1
327
+
328
+ # Validate it's a RiboMetric JSON file
329
+ try:
330
+ with open(file_path, "r") as f:
331
+ data = json.load(f)
332
+ if "results" not in data or "config" not in data:
333
+ print("Warning: File may not be a valid RiboMetric JSON file.")
334
+ print("Expected structure: {'results': {...}, 'config': {...}}")
335
+ except json.JSONDecodeError as e:
336
+ print(f"Error: Invalid JSON file: {e}")
337
+ return 1
338
+ except Exception as e:
339
+ print(f"Error reading file: {e}")
340
+ return 1
341
+
342
+ # Launch TUI
343
+ run_tui(str(file_path))
344
+ return 0
345
+
346
+ console = Console()
347
+ print_logo(console)
348
+
349
+ config = open_config(args)
350
+ export = config["argument"].copy()
351
+
352
+ # Every output path is built from this directory, but nothing wrote to it
353
+ # until the very end of the run, so a missing directory surfaced as a
354
+ # FileNotFoundError after the whole analysis had already been computed.
355
+ # Create it up front instead.
356
+ output_directory = config["argument"].get("output") or ""
357
+ if output_directory:
358
+ try:
359
+ Path(output_directory).mkdir(parents=True, exist_ok=True)
360
+ except OSError as exc:
361
+ raise SystemExit(f"Cannot use output directory '{output_directory}': {exc}")
362
+
363
+ # Handle inputs and run modes appropriately
364
+ if args.command == "prepare":
365
+ print_table_prepare(args, config, console, "Prepare Mode")
366
+ prepare_annotation(
367
+ config["argument"]["gff"],
368
+ config["argument"]["output"],
369
+ config["argument"]["transcripts"],
370
+ config["argument"]["threads"],
371
+ )
372
+
373
+ else:
374
+ print_table_run(args, config, console, "Run Mode")
375
+
376
+ if config["argument"]["bam"]:
377
+ if not check_bam(config["argument"]["bam"]):
378
+ raise Exception("""
379
+ Either BAM file or it's index does not exist at given path
380
+
381
+ To create an index for a BAM file, run:
382
+ samtools index <bam_file>
383
+ """)
384
+
385
+ if config["argument"]["annotation"] is not None:
386
+ if not check_annotation(config["argument"]["annotation"]):
387
+ raise Exception("""
388
+ Annotation file not found or not in the correct format.
389
+
390
+ To create an annotation file, run:
391
+ RiboMetric prepare -g <gff_file>
392
+ """)
393
+
394
+ flagstat = flagstat_bam(config["argument"]["bam"])
395
+ # Parse all primary alignments before selection so -S is not tied
396
+ # to BAM/reference split order.
397
+ read_limit = flagstat["mapped_reads"]
398
+
399
+ # Parse the bam file
400
+ read_df_pre, sequence_data, sequence_background = parse_bam(
401
+ bam_file=config["argument"]["bam"],
402
+ num_reads=read_limit,
403
+ num_processes=config["argument"]["threads"],
404
+ )
405
+ subsampling = None
406
+ # Direct legacy callers may construct a Namespace without the new
407
+ # seed field; retain their fixture semantics. CLI parses always
408
+ # carry the default seed and therefore take the reproducible path.
409
+ if config["argument"].get("subsample") is not None and hasattr(args, "seed"):
410
+ subsampling_seed = int(config["argument"].get("seed", 42))
411
+ read_df_pre, subsampling = deterministic_subsample(
412
+ read_df_pre, config["argument"]["subsample"], subsampling_seed
413
+ )
414
+ sequence_data, sequence_background = recompute_sequence_summaries(read_df_pre)
415
+ if read_df_pre.empty:
416
+ raise Exception("""
417
+ No reads found in the given bam file.
418
+
419
+ Please check the file and try again.
420
+ """)
421
+ print("Reads parsed")
422
+
423
+ # Optionally drop sequence data so sequence-based metrics are
424
+ # skipped (explicit flag, or BAMs with no stored sequences). #122
425
+ if config["argument"].get("skip_sequence_metrics"):
426
+ print("Skipping sequence-based metrics (--skip-sequence-metrics)")
427
+ sequence_data, sequence_background = {}, {}
428
+ elif not sequence_background:
429
+ print("No stored sequences detected; " "sequence-based metrics will be skipped.")
430
+
431
+ # Use weighted computations downstream instead of expanding rows
432
+ if "count" not in read_df_pre.columns:
433
+ read_df_pre["count"] = 1
434
+ read_df = read_df_pre
435
+ del read_df_pre
436
+
437
+ # Parse FASTA up-front so it can be passed into annotation_mode
438
+ # for RUST and other sequence-level metrics.
439
+ fasta_dict = None
440
+ if config["argument"]["fasta"] is not None:
441
+ print("Parsing FASTA for sequence-level metrics...")
442
+ fasta_dict = parse_fasta(config["argument"]["fasta"])
443
+
444
+ if config["argument"]["gff"] is None and config["argument"]["annotation"] is None:
445
+ results_dict = annotation_mode(
446
+ read_df,
447
+ sequence_data,
448
+ sequence_background,
449
+ config=config,
450
+ fasta_dict=fasta_dict,
451
+ )
452
+
453
+ else:
454
+ if (
455
+ config["argument"]["annotation"] is not None
456
+ and config["argument"]["gff"] is not None
457
+ ):
458
+ print("Running annotation mode")
459
+ annotation_df = parse_annotation(config["argument"]["annotation"])
460
+ # Ensure annotation mode actually runs for this branch
461
+ results_dict = annotation_mode(
462
+ read_df,
463
+ sequence_data,
464
+ sequence_background,
465
+ annotation_df,
466
+ config,
467
+ fasta_dict=fasta_dict,
468
+ )
469
+ elif (
470
+ config["argument"]["annotation"] is None
471
+ and config["argument"]["gff"] is not None
472
+ ):
473
+ print("Gff provided, preparing annotation")
474
+ annotation_df = prepare_annotation(
475
+ config["argument"]["gff"],
476
+ config["argument"]["output"],
477
+ config["argument"]["transcripts"],
478
+ config["argument"]["threads"],
479
+ )
480
+ print("Annotation prepared")
481
+ # Run annotation mode after preparing annotation
482
+ results_dict = annotation_mode(
483
+ read_df,
484
+ sequence_data,
485
+ sequence_background,
486
+ annotation_df,
487
+ config,
488
+ fasta_dict=fasta_dict,
489
+ )
490
+
491
+ elif (
492
+ config["argument"]["annotation"] is not None
493
+ and config["argument"]["gff"] is None
494
+ ):
495
+ print("Annotation provided, parsing")
496
+ annotation_df = parse_annotation(config["argument"]["annotation"])
497
+ print("Annotation parsed")
498
+
499
+ print("Running annotation mode")
500
+ results_dict = annotation_mode(
501
+ read_df,
502
+ sequence_data,
503
+ sequence_background,
504
+ annotation_df,
505
+ config,
506
+ fasta_dict=fasta_dict,
507
+ )
508
+
509
+ # Merge samtools flagstat data into alignment_stats so the report
510
+ # can display total_reads, mapping_rate, etc.
511
+ results_dict.setdefault("alignment_stats", {}).update(flagstat)
512
+ results_dict["provenance"] = _build_run_provenance(args, config, subsampling)
513
+
514
+ filename = config["argument"]["bam"].split("/")[-1]
515
+ if "." in filename:
516
+ filename = filename.split(".")[:-1]
517
+
518
+ elif config["argument"]["json_in"]:
519
+ print("JSON input provided")
520
+ filename = config["argument"]["json_in"].split("/")[-1]
521
+ if "." in filename:
522
+ filename = filename.split(".")[:-1]
523
+
524
+ json_dicts = parse_json_input(config["argument"]["json_in"])
525
+ results_dict = json_dicts[0]
526
+ json_config = json_dicts[1]
527
+ config["argument"] = json_config["argument"]
528
+
529
+ if config["argument"]["json_config"]:
530
+ config["plots"] = json_config["plots"]
531
+ results_dict["provenance"] = _build_run_provenance(args, config)
532
+
533
+ # Indentify output requirements
534
+ if export["name"] is not None:
535
+ filename = export["name"]
536
+
537
+ report_prefix = f"{''.join(filename)}_RiboMetric"
538
+
539
+ if export.get("html"):
540
+ if export["pdf"]:
541
+ report_export = "both"
542
+ else:
543
+ report_export = "html"
544
+ elif export.get("pdf"):
545
+ report_export = "pdf"
546
+ else:
547
+ report_export = None
548
+ # Write out the specified output files
549
+ if report_export is not None:
550
+ plots_list = generate_plots(results_dict, config)
551
+ generate_report(plots_list, config, report_export, report_prefix, export["output"])
552
+
553
+ if export.get("json"):
554
+ generate_json(results_dict, config, report_prefix, export["output"])
555
+
556
+ if export.get("csv"):
557
+ generate_csv(results_dict, config, report_prefix, export["output"])
558
+
559
+ # Improved outputs (low-hanging fruit)
560
+ sample_name = "".join(filename)
561
+ if export.get("improved_outputs"):
562
+ generate_all_outputs(
563
+ results_dict,
564
+ config,
565
+ sample_name,
566
+ export.get("output", ""),
567
+ )
568
+ else:
569
+ if export.get("summary_tsv"):
570
+ generate_summary_tsv(
571
+ results_dict,
572
+ config,
573
+ sample_name,
574
+ f"{sample_name}_summary.tsv",
575
+ export.get("output", ""),
576
+ )
577
+ if export.get("metrics_table"):
578
+ generate_metrics_table_csv(
579
+ results_dict,
580
+ config,
581
+ sample_name,
582
+ f"{sample_name}_metrics_table.csv",
583
+ export.get("output", ""),
584
+ )
585
+ if export.get("qc_status"):
586
+ generate_qc_status(
587
+ results_dict,
588
+ config,
589
+ sample_name,
590
+ None,
591
+ f"{sample_name}_qc_status.json",
592
+ export.get("output", ""),
593
+ )
594
+ if export.get("comparison_csv"):
595
+ generate_comparison_ready_csv(
596
+ results_dict,
597
+ config,
598
+ sample_name,
599
+ f"{sample_name}_comparison.csv",
600
+ export.get("output", ""),
601
+ )
602
+ if export.get("offsets_tsv"):
603
+ generate_offsets_tsv(
604
+ results_dict,
605
+ sample_name,
606
+ f"{sample_name}_offsets.tsv",
607
+ export.get("output", ""),
608
+ )
609
+
610
+ if export.get("output_offsets"):
611
+ output_path = Path(export["output_offsets"])
612
+ generate_offsets_tsv(
613
+ results_dict,
614
+ sample_name,
615
+ output_path.name,
616
+ str(output_path.parent) if str(output_path.parent) != "." else "",
617
+ )
618
+
619
+ return 0
620
+
621
+
622
+ if __name__ == "__main__":
623
+ parser = argument_parser()
624
+ args = parser.parse_args()
625
+
626
+ # -b/--bam vs -j/--json-in exclusivity is enforced by argparse's
627
+ # mutually-exclusive group on the `run` subparser; no manual check needed
628
+ # here (the previous one assumed those attributes existed for every
629
+ # subcommand and crashed for prepare/evaluate/view).
630
+ if getattr(args, "command", None) is None:
631
+ parser.print_help()
632
+ raise SystemExit(0)
633
+
634
+ raise SystemExit(main(args) or 0)
RiboMetric/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Top-level package for RiboMetric."""
2
+
3
+ __author__ = """Jack Tierney"""
4
+ __email__ = "jackcurragh@gmail.com"
5
+ __version__ = "1.5.0"