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.
- bioforge-2.3.0.data/purelib/bioforge/__init__.py +74 -0
- bioforge-2.3.0.data/purelib/bioforge/aligner.py +953 -0
- bioforge-2.3.0.data/purelib/bioforge/analyze.py +385 -0
- bioforge-2.3.0.data/purelib/bioforge/bgzf.py +119 -0
- bioforge-2.3.0.data/purelib/bioforge/biocore.py +2092 -0
- bioforge-2.3.0.data/purelib/bioforge/engine/__init__.py +1 -0
- bioforge-2.3.0.data/purelib/bioforge/engine/_loader.py +543 -0
- bioforge-2.3.0.data/purelib/bioforge/engine/build.py +231 -0
- bioforge-2.3.0.data/purelib/bioforge/engine/engine.c +1538 -0
- bioforge-2.3.0.data/purelib/bioforge/engine/engine.dll +0 -0
- bioforge-2.3.0.data/purelib/bioforge/qcreport.py +298 -0
- bioforge-2.3.0.data/purelib/bioforge/smart_translator.py +601 -0
- bioforge-2.3.0.dist-info/METADATA +736 -0
- bioforge-2.3.0.dist-info/RECORD +18 -0
- bioforge-2.3.0.dist-info/WHEEL +5 -0
- bioforge-2.3.0.dist-info/entry_points.txt +4 -0
- bioforge-2.3.0.dist-info/licenses/LICENSE +280 -0
- bioforge-2.3.0.dist-info/top_level.txt +1 -0
|
@@ -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())
|