bioforge 2.3.0__py3-none-win_amd64.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,385 @@
1
+ """
2
+ analyze.py
3
+ ══════════════════════════════════════════════════════════════════════
4
+ Mutation analysis pipeline — nucleotide and/or amino acid level.
5
+
6
+ Three analysis modes:
7
+ --mode dna : compare at nucleotide level only (A/C/G/T mutations)
8
+ --mode protein : compare at amino acid level only (translate if DNA input)
9
+ --mode both : full report — nucleotide mutations + amino acid impact
10
+ (default when input is DNA)
11
+
12
+ Silent (synonymous) DNA changes that do NOT alter the amino acid are
13
+ labelled as such in 'both' mode and excluded from 'protein' mode entirely.
14
+
15
+ Pipeline
16
+ ────────
17
+ 1. Load both FASTA files (SmartImporter)
18
+ 2. [dna / both] align at DNA level (SequenceAligner on nucleotides)
19
+ 3. [protein/both] translate + align at protein level (SmartTranslator →
20
+ SequenceAligner on amino acids)
21
+ 4. Build and output report
22
+
23
+ Usage
24
+ ─────
25
+ python analyze.py reference.fa query.fa
26
+ python analyze.py reference.fa query.fa --mode dna
27
+ python analyze.py reference.fa query.fa --mode protein
28
+ python analyze.py reference.fa query.fa --mode both --output report.md
29
+ python analyze.py --help
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import argparse
35
+ import sys
36
+ import textwrap
37
+ import warnings
38
+ from dataclasses import dataclass
39
+ from pathlib import Path
40
+ from typing import Literal, Optional
41
+
42
+ from .aligner import AlignmentResult, SequenceAligner, format_alignment
43
+ from .biocore import (
44
+ BioForgeError,
45
+ PackedSequence,
46
+ SeqType,
47
+ SequenceTypeError,
48
+ SequenceValueError,
49
+ SmartImporter,
50
+ )
51
+ from .smart_translator import SmartTranslator
52
+
53
+ # ── Amino acid full names ──────────────────────────────────────────────────────
54
+ _AA_NAMES: dict[str, str] = {
55
+ "A": "Ala", "C": "Cys", "D": "Asp", "E": "Glu", "F": "Phe",
56
+ "G": "Gly", "H": "His", "I": "Ile", "K": "Lys", "L": "Leu",
57
+ "M": "Met", "N": "Asn", "P": "Pro", "Q": "Gln", "R": "Arg",
58
+ "S": "Ser", "T": "Thr", "V": "Val", "W": "Trp", "Y": "Tyr",
59
+ "*": "Stop", "-": "Gap", "X": "Unk",
60
+ }
61
+
62
+ _NUC_NAMES: dict[str, str] = {
63
+ "A": "Adenina", "C": "Citosina", "G": "Guanina",
64
+ "T": "Timina", "U": "Uracilo", "N": "Ambigua", "-": "Gap",
65
+ }
66
+
67
+ # Conservative substitution groups (physicochemical similarity)
68
+ _CONSERVATIVE: list[frozenset[str]] = [
69
+ frozenset("ST"), frozenset("DE"), frozenset("KR"), frozenset("NQ"),
70
+ frozenset("LIVM"), frozenset("FYW"), frozenset("AG"),
71
+ ]
72
+
73
+
74
+ def _change_type(a: str, b: str) -> str:
75
+ for group in _CONSERVATIVE:
76
+ if a in group and b in group:
77
+ return "conservative"
78
+ return "radical"
79
+
80
+
81
+ # ══════════════════════════════════════════════════════════════════════════════
82
+ # §1 RESULT DATACLASS
83
+ # ══════════════════════════════════════════════════════════════════════════════
84
+
85
+ @dataclass
86
+ class AnalysisResult:
87
+ ref_header: str
88
+ query_header: str
89
+ ref_seq: PackedSequence # original input sequence
90
+ qry_seq: PackedSequence # original input sequence
91
+ dna_alignment: Optional[AlignmentResult] # None if mode=protein or input=protein
92
+ ref_protein: Optional[PackedSequence] # None if mode=dna
93
+ qry_protein: Optional[PackedSequence] # None if mode=dna
94
+ aa_alignment: Optional[AlignmentResult] # None if mode=dna
95
+ mode: str
96
+ was_dna: bool
97
+
98
+
99
+ # ══════════════════════════════════════════════════════════════════════════════
100
+ # §2 PIPELINE
101
+ # ══════════════════════════════════════════════════════════════════════════════
102
+
103
+ def run(
104
+ ref_path: str,
105
+ query_path: str,
106
+ mode: Literal["dna", "protein", "both"] = "both",
107
+ ) -> AnalysisResult:
108
+ """
109
+ Execute the full analysis pipeline.
110
+
111
+ Parameters
112
+ ----------
113
+ ref_path : path to reference FASTA file
114
+ query_path : path to query FASTA file
115
+ mode : 'dna' | 'protein' | 'both'
116
+ If input is protein, 'dna' and 'both' fall back to 'protein'.
117
+
118
+ Returns
119
+ -------
120
+ AnalysisResult
121
+ """
122
+ if mode not in ("dna", "protein", "both"):
123
+ raise ValueError(
124
+ f"mode debe ser 'dna', 'protein' o 'both', se recibió {mode!r}."
125
+ )
126
+ ref_seqs = SmartImporter.from_file(ref_path)
127
+ query_seqs = SmartImporter.from_file(query_path)
128
+
129
+ if not ref_seqs:
130
+ raise SequenceValueError(
131
+ f"No se encontraron secuencias en: {ref_path}. "
132
+ "Comprueba que el archivo FASTA tenga al menos un registro con '>'."
133
+ )
134
+ if not query_seqs:
135
+ raise SequenceValueError(
136
+ f"No se encontraron secuencias en: {query_path}. "
137
+ "Comprueba que el archivo FASTA tenga al menos un registro con '>'."
138
+ )
139
+
140
+ ref_seq = ref_seqs[0]
141
+ query_seq = query_seqs[0]
142
+
143
+ if ref_seq.seq_type != query_seq.seq_type:
144
+ raise SequenceTypeError(
145
+ f"Tipos incompatibles: referencia es {ref_seq.seq_type.name} "
146
+ f"pero query es {query_seq.seq_type.name}. "
147
+ "Ambos archivos deben contener el mismo tipo de secuencia."
148
+ )
149
+
150
+ was_dna = ref_seq.seq_type == SeqType.NUCLEOTIDE
151
+
152
+ # Si la entrada es proteína no se puede hacer análisis de nucleótidos
153
+ effective_mode = mode
154
+ if not was_dna and mode in ("dna", "both"):
155
+ effective_mode = "protein"
156
+
157
+ # ── DNA alignment ──────────────────────────────────────────────────────────
158
+ dna_aln: Optional[AlignmentResult] = None
159
+ if was_dna and effective_mode in ("dna", "both"):
160
+ dna_aln = SequenceAligner.align(ref_seq, query_seq)
161
+
162
+ # ── Protein alignment ──────────────────────────────────────────────────────
163
+ ref_prot = qry_prot = aa_aln = None
164
+ if effective_mode in ("protein", "both"):
165
+ if was_dna:
166
+ with warnings.catch_warnings():
167
+ warnings.simplefilter("ignore", UserWarning)
168
+ ref_prot = SmartTranslator.translate(ref_seq)
169
+ qry_prot = SmartTranslator.translate(query_seq)
170
+ else:
171
+ ref_prot = ref_seq
172
+ qry_prot = query_seq
173
+ aa_aln = SequenceAligner.align(ref_prot, qry_prot)
174
+
175
+ return AnalysisResult(
176
+ ref_header = ref_seq.header,
177
+ query_header = query_seq.header,
178
+ ref_seq = ref_seq,
179
+ qry_seq = query_seq,
180
+ dna_alignment = dna_aln,
181
+ ref_protein = ref_prot,
182
+ qry_protein = qry_prot,
183
+ aa_alignment = aa_aln,
184
+ mode = effective_mode,
185
+ was_dna = was_dna,
186
+ )
187
+
188
+
189
+ # ══════════════════════════════════════════════════════════════════════════════
190
+ # §3 REPORT FORMATTER
191
+ # ══════════════════════════════════════════════════════════════════════════════
192
+
193
+ def build_report(result: AnalysisResult) -> str:
194
+ W = 68
195
+ sep = "─" * W
196
+ dbl = "═" * W
197
+ lines: list[str] = []
198
+
199
+ def add(*text: str) -> None:
200
+ lines.extend(text)
201
+
202
+ # ── Header ─────────────────────────────────────────────────────────────────
203
+ add(dbl, " MUTATION ANALYSIS REPORT", dbl, "")
204
+ add(f" Reference : {result.ref_header[:55]}")
205
+ add(f" Query : {result.query_header[:55]}")
206
+ tipo = "DNA" if result.was_dna else "Protein"
207
+ add(f" Input : {tipo} | Mode: {result.mode}", "")
208
+
209
+ # ── DNA section ────────────────────────────────────────────────────────────
210
+ if result.dna_alignment is not None:
211
+ aln = result.dna_alignment
212
+ add(sep, " DNA ALIGNMENT", sep)
213
+ add(f" Reference length : {result.ref_seq.n_symbols:>6} nt")
214
+ add(f" Query length : {result.qry_seq.n_symbols:>6} nt")
215
+ add(f" Identity : {aln.identity:>6.1%} "
216
+ f"({aln.n_matches}/{aln.n_matches + aln.n_mismatches + aln.n_gaps} positions)")
217
+ add(f" Matches : {aln.n_matches:>6}")
218
+ add(f" Substitutions : {aln.n_mismatches:>6}")
219
+ add(f" Indels (nt) : {aln.n_gaps:>6}", "")
220
+
221
+ add(format_alignment(aln, width=60))
222
+
223
+ nuc_muts = aln.mutations
224
+ add(sep, f" NUCLEOTIDE MUTATIONS ({len(nuc_muts)} total)", sep)
225
+ if not nuc_muts:
226
+ add(" No nucleotide mutations.", "")
227
+ else:
228
+ subs = [m for m in nuc_muts if m.kind == "substitution"]
229
+ dels = [m for m in nuc_muts if m.kind == "deletion"]
230
+ ins = [m for m in nuc_muts if m.kind == "insertion"]
231
+ if subs:
232
+ add(f" Substitutions ({len(subs)}):")
233
+ for m in subs:
234
+ rn = _NUC_NAMES.get(m.sym_a, m.sym_a)
235
+ qn = _NUC_NAMES.get(m.sym_b, m.sym_b)
236
+ # Mark synonymous if both modes active (protein section will clarify)
237
+ add(f" pos {m.pos_a + 1:>5} {m.sym_a} → {m.sym_b} [{rn} → {qn}]")
238
+ add("")
239
+ if dels:
240
+ add(f" Deletions in query ({len(dels)} nt):")
241
+ for m in dels:
242
+ add(f" pos {m.pos_a + 1:>5} {m.sym_a} [{_NUC_NAMES.get(m.sym_a, m.sym_a)}] missing")
243
+ add("")
244
+ if ins:
245
+ add(f" Insertions in query ({len(ins)} nt):")
246
+ for m in ins:
247
+ add(f" pos {m.pos_b + 1:>5} {m.sym_b} [{_NUC_NAMES.get(m.sym_b, m.sym_b)}] inserted")
248
+ add("")
249
+
250
+ # ── Protein section ────────────────────────────────────────────────────────
251
+ if result.aa_alignment is not None:
252
+ aln = result.aa_alignment
253
+ add(sep, " PROTEIN ALIGNMENT", sep)
254
+ add(f" Reference length : {result.ref_protein.n_symbols:>6} aa")
255
+ add(f" Query length : {result.qry_protein.n_symbols:>6} aa")
256
+ add(f" Identity : {aln.identity:>6.1%} "
257
+ f"({aln.n_matches}/{aln.n_matches + aln.n_mismatches + aln.n_gaps} positions)")
258
+ add(f" Matches : {aln.n_matches:>6}")
259
+ add(f" Substitutions : {aln.n_mismatches:>6}")
260
+ add(f" Indels (aa) : {aln.n_gaps:>6}", "")
261
+
262
+ if result.was_dna:
263
+ add(f" Reference protein : {result.ref_protein.to_string()[:60]}")
264
+ add(f" Query protein : {result.qry_protein.to_string()[:60]}", "")
265
+
266
+ add(format_alignment(aln, width=60))
267
+
268
+ subs = [m for m in aln.mutations if m.kind == "substitution"]
269
+ dels = [m for m in aln.mutations if m.kind == "deletion"]
270
+ ins = [m for m in aln.mutations if m.kind == "insertion"]
271
+ total = len(aln.mutations)
272
+
273
+ add(sep, f" AMINO ACID MUTATIONS ({total} total)", sep)
274
+ if total == 0:
275
+ add(" No amino acid mutations. Sequences are functionally identical.", "")
276
+ if result.dna_alignment and result.dna_alignment.n_mismatches > 0:
277
+ add(" Note: DNA differences exist but are all synonymous (silent).", "")
278
+ else:
279
+ if subs:
280
+ add(f" Substitutions ({len(subs)}):")
281
+ for m in subs:
282
+ rn = _AA_NAMES.get(m.sym_a, m.sym_a)
283
+ qn = _AA_NAMES.get(m.sym_b, m.sym_b)
284
+ impact = _change_type(m.sym_a, m.sym_b)
285
+ tag = "(conservative)" if impact == "conservative" else "(RADICAL)"
286
+ add(f" pos {m.pos_a + 1:>4} {m.sym_a} → {m.sym_b} "
287
+ f"[{rn} → {qn}] {tag}")
288
+ add("")
289
+ if dels:
290
+ add(f" Deletions in query ({len(dels)} aa):")
291
+ for m in dels:
292
+ add(f" pos {m.pos_a + 1:>4} {m.sym_a} [{_AA_NAMES.get(m.sym_a, m.sym_a)}] missing")
293
+ add("")
294
+ if ins:
295
+ add(f" Insertions in query ({len(ins)} aa):")
296
+ for m in ins:
297
+ add(f" pos {m.pos_b + 1:>4} {m.sym_b} [{_AA_NAMES.get(m.sym_b, m.sym_b)}] inserted")
298
+ add("")
299
+
300
+ # Interpretation
301
+ radical = [m for m in subs if _change_type(m.sym_a, m.sym_b) == "radical"]
302
+ add(sep, " INTERPRETATION", sep)
303
+ if radical:
304
+ add(f" {len(radical)} radical substitution(s) — likely affect protein function.")
305
+ elif subs:
306
+ add(" All substitutions conservative — functional impact likely low.")
307
+ if dels or ins:
308
+ add(f" {len(dels)+len(ins)} indel position(s) — often high functional impact.")
309
+ add("")
310
+
311
+ add(dbl)
312
+ return "\n".join(lines)
313
+
314
+
315
+ # ══════════════════════════════════════════════════════════════════════════════
316
+ # §4 CLI
317
+ # ══════════════════════════════════════════════════════════════════════════════
318
+
319
+ def _parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace:
320
+ parser = argparse.ArgumentParser(
321
+ prog="analyze",
322
+ description=textwrap.dedent("""\
323
+ Mutation analysis at nucleotide and/or amino acid level.
324
+ Modes: dna | protein | both (default)
325
+ """),
326
+ formatter_class=argparse.RawDescriptionHelpFormatter,
327
+ )
328
+ parser.add_argument("reference", help="Reference FASTA file")
329
+ parser.add_argument("query", help="Query FASTA file")
330
+ parser.add_argument(
331
+ "--mode", "-m",
332
+ choices=["dna", "protein", "both"],
333
+ default="both",
334
+ help="Analysis level: dna, protein, or both (default: both)",
335
+ )
336
+ parser.add_argument(
337
+ "--output", "-o",
338
+ metavar="FILE",
339
+ help="Save report to file (.md or .txt). Prints to screen if omitted.",
340
+ )
341
+ return parser.parse_args(argv)
342
+
343
+
344
+ def main(argv: Optional[list[str]] = None) -> int:
345
+ sys.stdout.reconfigure(encoding="utf-8")
346
+ args = _parse_args(argv)
347
+
348
+ try:
349
+ result = run(args.reference, args.query, mode=args.mode)
350
+ except BioForgeError as exc:
351
+ print(f"Error: {exc}", file=sys.stderr)
352
+ return 1
353
+ except (ValueError, TypeError) as exc:
354
+ print(f"Error: {exc}", file=sys.stderr)
355
+ return 1
356
+ except FileNotFoundError as exc:
357
+ print(f"Archivo no encontrado: {exc.filename}", file=sys.stderr)
358
+ return 1
359
+ except PermissionError as exc:
360
+ print(f"Sin permiso para leer: {exc.filename}", file=sys.stderr)
361
+ return 1
362
+ except MemoryError:
363
+ print(
364
+ "Error: memoria insuficiente para el alineamiento. "
365
+ "Las secuencias son demasiado largas para el modo global.",
366
+ file=sys.stderr,
367
+ )
368
+ return 1
369
+ except OSError as exc:
370
+ print(f"Error de E/S: {exc}", file=sys.stderr)
371
+ return 1
372
+
373
+ report = build_report(result)
374
+
375
+ if args.output:
376
+ Path(args.output).write_text(report, encoding="utf-8")
377
+ print(f"Report saved to: {args.output}")
378
+ else:
379
+ print(report)
380
+
381
+ return 0
382
+
383
+
384
+ if __name__ == "__main__":
385
+ sys.exit(main())
@@ -0,0 +1,119 @@
1
+ """
2
+ bgzf.py
3
+ ══════════════════════════════════════════════════════════════════════
4
+ Conversor a BGZF — gzip por bloques independientes, descomprimible en
5
+ PARALELO.
6
+
7
+ Un archivo BGZF es un `.gz` 100 % válido (cualquier herramienta lo lee con
8
+ `gunzip`/zlib), pero sus bloques de 64 KB se pueden descomprimir en paralelo.
9
+ Convertir una vez un FASTQ que vas a procesar muchas veces hace que BioForge
10
+ lo lea con la vía más rápida (palanca 3).
11
+
12
+ Uso
13
+ ───
14
+ python -m bioforge.bgzf reads.fastq # -> reads.fastq.gz (BGZF)
15
+ python -m bioforge.bgzf reads.fastq -o out.gz -l 9 -t 0
16
+ bioforge-bgzip reads.fastq # si el paquete está instalado
17
+
18
+ Requiere el motor C compilado con libdeflate (build.py lo enlaza si está).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import os
25
+ import sys
26
+ from typing import Optional
27
+
28
+ import numpy as np
29
+
30
+ from .biocore import EngineError
31
+
32
+ try:
33
+ from .engine._loader import C_LIBDEFLATE_AVAILABLE as _C_LIBDEFLATE_AVAILABLE
34
+ from .engine._loader import c_bgzf_compress as _c_bgzf_compress
35
+ except ImportError: # pragma: no cover
36
+ _C_LIBDEFLATE_AVAILABLE = False
37
+
38
+
39
+ def compress_bytes(data: bytes, level: int = 6, n_threads: int = 0) -> bytes:
40
+ """Comprime ``data`` a BGZF y devuelve los bytes comprimidos.
41
+
42
+ ``n_threads``: 0 = todos los núcleos. ``level``: 1–12 (libdeflate).
43
+ """
44
+ if not _C_LIBDEFLATE_AVAILABLE:
45
+ raise EngineError(
46
+ "El motor C no tiene libdeflate; recompila con "
47
+ "`python bioforge/engine/build.py` (necesita la librería libdeflate).")
48
+ nt = (os.cpu_count() or 1) if n_threads <= 0 else n_threads
49
+ inbuf = np.frombuffer(data, dtype=np.uint8)
50
+ # Holgura: overhead de framing por bloque + posible expansión de deflate.
51
+ nblocks = (len(data) + 0xFF00 - 1) // 0xFF00
52
+ cap = len(data) + len(data) // 8 + nblocks * 64 + 1024
53
+ out = np.empty(cap, dtype=np.uint8)
54
+ n = _c_bgzf_compress(inbuf, out, level, nt)
55
+ if n < 0: # holgura insuficiente: reintentar
56
+ out = np.empty(cap * 2 + (1 << 20), dtype=np.uint8)
57
+ n = _c_bgzf_compress(inbuf, out, level, nt)
58
+ if n < 0:
59
+ raise EngineError("Fallo al comprimir a BGZF.")
60
+ return out[:n].tobytes()
61
+
62
+
63
+ def compress_file(in_path: str, out_path: Optional[str] = None,
64
+ level: int = 6, n_threads: int = 0) -> str:
65
+ """Convierte ``in_path`` a un archivo BGZF. Devuelve la ruta de salida."""
66
+ if out_path is None:
67
+ out_path = in_path + ".gz"
68
+ if os.path.abspath(out_path) == os.path.abspath(in_path):
69
+ raise ValueError(
70
+ f"La salida coincide con la entrada ({in_path!r}); indica otra ruta "
71
+ "con -o para no sobrescribir el archivo original.")
72
+ with open(in_path, "rb") as f:
73
+ data = f.read()
74
+ comp = compress_bytes(data, level=level, n_threads=n_threads)
75
+ with open(out_path, "wb") as f:
76
+ f.write(comp)
77
+ return out_path
78
+
79
+
80
+ # ── CLI ──────────────────────────────────────────────────────────────────────
81
+
82
+ def _parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace:
83
+ p = argparse.ArgumentParser(
84
+ prog="bioforge-bgzip",
85
+ description="Convierte un archivo a BGZF (gzip por bloques, paralelizable).")
86
+ p.add_argument("input", help="Archivo a comprimir (p.ej. reads.fastq)")
87
+ p.add_argument("--output", "-o", help="Ruta de salida (por defecto: input + .gz)")
88
+ p.add_argument("--level", "-l", type=int, default=6,
89
+ help="Nivel de compresión 1–12 (libdeflate, por defecto 6)")
90
+ p.add_argument("--threads", "-t", type=int, default=0,
91
+ help="Hilos (0 = todos los núcleos)")
92
+ return p.parse_args(argv)
93
+
94
+
95
+ def main(argv: Optional[list[str]] = None) -> int:
96
+ sys.stdout.reconfigure(encoding="utf-8")
97
+ args = _parse_args(argv)
98
+ try:
99
+ import time
100
+ t0 = time.perf_counter()
101
+ out = compress_file(args.input, args.output, args.level, args.threads)
102
+ dt = time.perf_counter() - t0
103
+ ins = os.path.getsize(args.input)
104
+ outs = os.path.getsize(out)
105
+ print(f"BGZF: {args.input} -> {out}")
106
+ print(f" {ins/1e6:.1f} MB -> {outs/1e6:.1f} MB "
107
+ f"({outs/ins*100:.0f}%) en {dt*1000:.0f} ms")
108
+ print(" Léelo en paralelo con n_threads en SmartImporter.stream_fastq_batches.")
109
+ except FileNotFoundError:
110
+ print(f"Archivo no encontrado: {args.input}", file=sys.stderr)
111
+ return 1
112
+ except (EngineError, ValueError, OSError) as exc:
113
+ print(f"Error: {exc}", file=sys.stderr)
114
+ return 1
115
+ return 0
116
+
117
+
118
+ if __name__ == "__main__":
119
+ sys.exit(main())