mafutils 0.1.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.
- mafutils/__init__.py +3 -0
- mafutils/__main__.py +5 -0
- mafutils/__pycache__/__init__.cpython-312.pyc +0 -0
- mafutils/__pycache__/__main__.cpython-312.pyc +0 -0
- mafutils/__pycache__/cli.cpython-312.pyc +0 -0
- mafutils/__pycache__/fetch.cpython-312.pyc +0 -0
- mafutils/__pycache__/index.cpython-312.pyc +0 -0
- mafutils/__pycache__/stats.cpython-312.pyc +0 -0
- mafutils/_version.py +24 -0
- mafutils/cli.py +20 -0
- mafutils/fetch.py +1311 -0
- mafutils/index.py +102 -0
- mafutils/lib/__init__.py +0 -0
- mafutils/lib/__pycache__/__init__.cpython-312.pyc +0 -0
- mafutils/lib/__pycache__/common.cpython-312.pyc +0 -0
- mafutils/lib/__pycache__/loginit.cpython-312.pyc +0 -0
- mafutils/lib/common.py +39 -0
- mafutils/lib/loginit.py +169 -0
- mafutils/stats.py +1312 -0
- mafutils-0.1.1.dist-info/METADATA +198 -0
- mafutils-0.1.1.dist-info/RECORD +25 -0
- mafutils-0.1.1.dist-info/WHEEL +5 -0
- mafutils-0.1.1.dist-info/entry_points.txt +2 -0
- mafutils-0.1.1.dist-info/licenses/LICENSE +21 -0
- mafutils-0.1.1.dist-info/top_level.txt +1 -0
mafutils/fetch.py
ADDED
|
@@ -0,0 +1,1311 @@
|
|
|
1
|
+
#############################################################################
|
|
2
|
+
# Given a MAF file, its index generated by `mafutils index`, and a bed file,
|
|
3
|
+
# this script will output the alignment blocks from the MAF file that overlap
|
|
4
|
+
# the regions specified in the bed file. The output will be trimmed to only
|
|
5
|
+
# include the exact portion of the alignment block that overlaps the bed region.
|
|
6
|
+
#
|
|
7
|
+
# Gregg Thomas, April 2025
|
|
8
|
+
#############################################################################
|
|
9
|
+
|
|
10
|
+
"""
|
|
11
|
+
mafutils fetch
|
|
12
|
+
|
|
13
|
+
This script fetches alignment blocks from a MAF (Multiple Alignment Format) file
|
|
14
|
+
that overlap regions specified in a BED file. It works in two modes:
|
|
15
|
+
|
|
16
|
+
Block mode:
|
|
17
|
+
- Uses a block-level index
|
|
18
|
+
- Extracts blocks overlapping each [start, end) BED interval
|
|
19
|
+
- Trims blocks to exactly match the specified region
|
|
20
|
+
- Output files are named by region ID or scaffold.counter (e.g., chr1.1.maf)
|
|
21
|
+
|
|
22
|
+
Scaffold mode:
|
|
23
|
+
- Uses a scaffold-level index
|
|
24
|
+
- Treats the BED file as a list of scaffold names (only column 1 is required)
|
|
25
|
+
- Extracts all alignment blocks for each scaffold from the MAF
|
|
26
|
+
- Output files are named by scaffold (e.g., chr1.maf, chrZ.maf)
|
|
27
|
+
|
|
28
|
+
Parallelization:
|
|
29
|
+
- Block mode is parallelized by region
|
|
30
|
+
- Scaffold mode is parallelized by scaffold
|
|
31
|
+
- The number of processes can be controlled with --processes
|
|
32
|
+
|
|
33
|
+
Required input:
|
|
34
|
+
- A MAF file (.maf or .maf.gz)
|
|
35
|
+
- A MAF index file (from `mafutils index`)
|
|
36
|
+
- A BED file
|
|
37
|
+
|
|
38
|
+
Optional arguments:
|
|
39
|
+
--outdir, -o Output directory (default: current directory)
|
|
40
|
+
--processes, -p Number of parallel processes (default: 1)
|
|
41
|
+
--mode, -m Mode: 'block' or 'scaffold' (default: 'block')
|
|
42
|
+
|
|
43
|
+
Compression:
|
|
44
|
+
- Gzipped MAF files (.maf.gz) are supported, provided the index is built
|
|
45
|
+
from the compressed file using gzip.open()
|
|
46
|
+
|
|
47
|
+
Usage:
|
|
48
|
+
python -m mafutils fetch <maf_file> <index_file> <bed_file> [--outdir DIR] [--processes N] [--mode block|scaffold]
|
|
49
|
+
|
|
50
|
+
Examples:
|
|
51
|
+
Block mode with 4 threads:
|
|
52
|
+
python -m mafutils fetch alignment.maf.gz index.idx regions.bed --processes 4
|
|
53
|
+
|
|
54
|
+
Scaffold mode with default output:
|
|
55
|
+
python -m mafutils fetch alignment.maf.gz index.idx scaffolds.bed --mode scaffold
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
import sys
|
|
59
|
+
import os
|
|
60
|
+
import re
|
|
61
|
+
import atexit
|
|
62
|
+
import gzip
|
|
63
|
+
import logging
|
|
64
|
+
import bisect
|
|
65
|
+
import time
|
|
66
|
+
from enum import Enum
|
|
67
|
+
from types import SimpleNamespace
|
|
68
|
+
from typing import Annotated
|
|
69
|
+
from concurrent.futures import FIRST_COMPLETED, ProcessPoolExecutor, wait
|
|
70
|
+
from collections import defaultdict, OrderedDict
|
|
71
|
+
|
|
72
|
+
import typer
|
|
73
|
+
|
|
74
|
+
from mafutils.lib import common as COMMON
|
|
75
|
+
from mafutils.lib import loginit as LOGINIT
|
|
76
|
+
|
|
77
|
+
#############################################################################
|
|
78
|
+
|
|
79
|
+
MAX_NO_OVERLAP_REGIONS_DEFAULT = 10000
|
|
80
|
+
MAX_NO_OVERLAP_FRACTION_DEFAULT = 0.10
|
|
81
|
+
WORKER_BLOCK_CACHE_MAX = 2048
|
|
82
|
+
|
|
83
|
+
WORKER_HEADER = None
|
|
84
|
+
WORKER_MAF_FILE = None
|
|
85
|
+
WORKER_MAF_COMPRESSION = None
|
|
86
|
+
WORKER_INDEX = None
|
|
87
|
+
WORKER_OUTPUT = None
|
|
88
|
+
WORKER_SINGLE_OUTPUT = None
|
|
89
|
+
WORKER_AS_FASTA = None
|
|
90
|
+
WORKER_FASTA_HEADER = None
|
|
91
|
+
WORKER_EXPECTED_SPECIES = None
|
|
92
|
+
WORKER_FASTA_DEDUPE = None
|
|
93
|
+
WORKER_VERBOSE = None
|
|
94
|
+
WORKER_PROFILE = None
|
|
95
|
+
WORKER_MAF_FP = None
|
|
96
|
+
WORKER_BLOCK_CACHE = None
|
|
97
|
+
|
|
98
|
+
#############################################################################
|
|
99
|
+
|
|
100
|
+
class BasenameMode(str, Enum):
|
|
101
|
+
id = "id"
|
|
102
|
+
coords = "coords"
|
|
103
|
+
count = "count"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class FastaHeaderMode(str, Enum):
|
|
107
|
+
species_coords_id = "species-coords-id"
|
|
108
|
+
species_coords = "species-coords"
|
|
109
|
+
species_only = "species-only"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class FastaDedupeMode(str, Enum):
|
|
113
|
+
none = "none"
|
|
114
|
+
most_seq = "most-seq"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class FetchMode(str, Enum):
|
|
118
|
+
block = "block"
|
|
119
|
+
scaffold = "scaffold"
|
|
120
|
+
|
|
121
|
+
#############################################################################
|
|
122
|
+
|
|
123
|
+
def parseExpectedSpecies(args):
|
|
124
|
+
expected = []
|
|
125
|
+
seen = set()
|
|
126
|
+
|
|
127
|
+
if args.expected_species:
|
|
128
|
+
for sp in args.expected_species.split(","):
|
|
129
|
+
sp = sp.strip()
|
|
130
|
+
if sp and sp not in seen:
|
|
131
|
+
expected.append(sp)
|
|
132
|
+
seen.add(sp)
|
|
133
|
+
|
|
134
|
+
if args.expected_species_file:
|
|
135
|
+
with open(args.expected_species_file, "r", encoding="utf-8") as fp:
|
|
136
|
+
for line in fp:
|
|
137
|
+
line = line.strip()
|
|
138
|
+
if not line or line.startswith("#"):
|
|
139
|
+
continue
|
|
140
|
+
if line not in seen:
|
|
141
|
+
expected.append(line)
|
|
142
|
+
seen.add(line)
|
|
143
|
+
|
|
144
|
+
return expected
|
|
145
|
+
|
|
146
|
+
#############################################################################
|
|
147
|
+
|
|
148
|
+
def pick_chunk_size(num_regions, num_procs, min_size=100):
|
|
149
|
+
n_batches = max(num_procs * 2, 1)
|
|
150
|
+
batch_size = max(min_size, (num_regions + n_batches - 1) // n_batches)
|
|
151
|
+
return batch_size
|
|
152
|
+
|
|
153
|
+
def chunker(seq, size=1000):
|
|
154
|
+
for pos in range(0, len(seq), size):
|
|
155
|
+
yield seq[pos:pos+size]
|
|
156
|
+
|
|
157
|
+
#############################################################################
|
|
158
|
+
|
|
159
|
+
def initBatchWorker(
|
|
160
|
+
header,
|
|
161
|
+
maf_file,
|
|
162
|
+
maf_compression,
|
|
163
|
+
index,
|
|
164
|
+
output,
|
|
165
|
+
single_output,
|
|
166
|
+
as_fasta,
|
|
167
|
+
fasta_header,
|
|
168
|
+
expected_species,
|
|
169
|
+
fasta_dedupe,
|
|
170
|
+
verbose,
|
|
171
|
+
profile,
|
|
172
|
+
):
|
|
173
|
+
global WORKER_HEADER
|
|
174
|
+
global WORKER_MAF_FILE
|
|
175
|
+
global WORKER_MAF_COMPRESSION
|
|
176
|
+
global WORKER_INDEX
|
|
177
|
+
global WORKER_OUTPUT
|
|
178
|
+
global WORKER_SINGLE_OUTPUT
|
|
179
|
+
global WORKER_AS_FASTA
|
|
180
|
+
global WORKER_FASTA_HEADER
|
|
181
|
+
global WORKER_EXPECTED_SPECIES
|
|
182
|
+
global WORKER_FASTA_DEDUPE
|
|
183
|
+
global WORKER_VERBOSE
|
|
184
|
+
global WORKER_PROFILE
|
|
185
|
+
global WORKER_MAF_FP
|
|
186
|
+
global WORKER_BLOCK_CACHE
|
|
187
|
+
|
|
188
|
+
WORKER_HEADER = header
|
|
189
|
+
WORKER_MAF_FILE = maf_file
|
|
190
|
+
WORKER_MAF_COMPRESSION = maf_compression
|
|
191
|
+
WORKER_INDEX = index
|
|
192
|
+
WORKER_OUTPUT = output
|
|
193
|
+
WORKER_SINGLE_OUTPUT = single_output
|
|
194
|
+
WORKER_AS_FASTA = as_fasta
|
|
195
|
+
WORKER_FASTA_HEADER = fasta_header
|
|
196
|
+
WORKER_EXPECTED_SPECIES = expected_species
|
|
197
|
+
WORKER_FASTA_DEDUPE = fasta_dedupe
|
|
198
|
+
WORKER_VERBOSE = verbose
|
|
199
|
+
WORKER_PROFILE = profile
|
|
200
|
+
WORKER_BLOCK_CACHE = OrderedDict()
|
|
201
|
+
|
|
202
|
+
opener = gzip.open if maf_compression == "gz" else open
|
|
203
|
+
WORKER_MAF_FP = opener(maf_file, "rb")
|
|
204
|
+
atexit.register(closeBatchWorker)
|
|
205
|
+
|
|
206
|
+
#############################################################################
|
|
207
|
+
|
|
208
|
+
def closeBatchWorker():
|
|
209
|
+
global WORKER_MAF_FP
|
|
210
|
+
if WORKER_MAF_FP is not None:
|
|
211
|
+
WORKER_MAF_FP.close()
|
|
212
|
+
WORKER_MAF_FP = None
|
|
213
|
+
|
|
214
|
+
#############################################################################
|
|
215
|
+
|
|
216
|
+
def parseIndex(index_file, LOG, mode="block"):
|
|
217
|
+
"""
|
|
218
|
+
Parses either a block-level or region-level index file.
|
|
219
|
+
|
|
220
|
+
For block mode:
|
|
221
|
+
Returns: { scaffold: [ { ref_start, seq_length, offset_start, offset_end }, ... ] }
|
|
222
|
+
|
|
223
|
+
For scaffold mode:
|
|
224
|
+
Returns: { scaffold: (byte_start, byte_end) } — one entry per scaffold
|
|
225
|
+
"""
|
|
226
|
+
index = {}
|
|
227
|
+
|
|
228
|
+
with open(index_file, "r") as f:
|
|
229
|
+
for line in f:
|
|
230
|
+
if line.startswith("#") or not line.strip():
|
|
231
|
+
continue
|
|
232
|
+
fields = line.strip().split("\t")
|
|
233
|
+
|
|
234
|
+
if mode == "block":
|
|
235
|
+
if len(fields) < 7:
|
|
236
|
+
LOG.warning(f"Skipping malformed index line: {line.strip()}", file=sys.stderr)
|
|
237
|
+
continue
|
|
238
|
+
scaffold = fields[0]
|
|
239
|
+
try:
|
|
240
|
+
ref_start = int(fields[1])
|
|
241
|
+
ref_length = int(fields[2])
|
|
242
|
+
aln_length = int(fields[3])
|
|
243
|
+
offset_start = int(fields[6])
|
|
244
|
+
offset_end = int(fields[7])
|
|
245
|
+
except ValueError:
|
|
246
|
+
continue
|
|
247
|
+
entry = {
|
|
248
|
+
"ref_start": ref_start,
|
|
249
|
+
"ref_length": ref_length,
|
|
250
|
+
"aln_length": aln_length,
|
|
251
|
+
"offset_start": offset_start,
|
|
252
|
+
"offset_end": offset_end
|
|
253
|
+
}
|
|
254
|
+
index.setdefault(scaffold, []).append(entry)
|
|
255
|
+
|
|
256
|
+
elif mode == "scaffold":
|
|
257
|
+
if len(fields) != 3:
|
|
258
|
+
LOG.warning(f"Skipping malformed index line: {line.strip()}", file=sys.stderr)
|
|
259
|
+
continue
|
|
260
|
+
scaffold = fields[0]
|
|
261
|
+
try:
|
|
262
|
+
offset_start = int(fields[1])
|
|
263
|
+
offset_end = int(fields[2])
|
|
264
|
+
except ValueError:
|
|
265
|
+
continue
|
|
266
|
+
index[scaffold] = (offset_start, offset_end)
|
|
267
|
+
|
|
268
|
+
# Sort block entries per scaffold
|
|
269
|
+
if mode == "block":
|
|
270
|
+
for scaffold in index:
|
|
271
|
+
index[scaffold].sort(key=lambda x: x["ref_start"])
|
|
272
|
+
index[scaffold] = {
|
|
273
|
+
"entries": index[scaffold],
|
|
274
|
+
"starts": [entry["ref_start"] for entry in index[scaffold]],
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return index
|
|
278
|
+
|
|
279
|
+
#############################################################################
|
|
280
|
+
|
|
281
|
+
def parseBed(bed_file, LOG, mode="block"):
|
|
282
|
+
"""
|
|
283
|
+
Parses the BED file into a list of region dictionaries.
|
|
284
|
+
|
|
285
|
+
In "block" mode: expects at least 3 columns (scaffold, start, end)
|
|
286
|
+
Each region has keys:
|
|
287
|
+
- scaffold (chromosome)
|
|
288
|
+
- start (integer, 0-based)
|
|
289
|
+
- end (integer, non-inclusive)
|
|
290
|
+
- basename (from column 4, if present; otherwise None)
|
|
291
|
+
|
|
292
|
+
In "scaffold" mode: accepts 1+ columns; only uses first column (scaffold)
|
|
293
|
+
"""
|
|
294
|
+
regions = []
|
|
295
|
+
with open(bed_file, "r") as f:
|
|
296
|
+
for line in f:
|
|
297
|
+
line = line.strip()
|
|
298
|
+
if not line or line.startswith("#"):
|
|
299
|
+
continue
|
|
300
|
+
fields = line.split()
|
|
301
|
+
if mode == "scaffold":
|
|
302
|
+
scaffold = fields[0]
|
|
303
|
+
region = {"scaffold": scaffold, "start": 0, "end": 0, "id": fields[3] if len(fields) >= 4 else None}
|
|
304
|
+
regions.append(region)
|
|
305
|
+
else:
|
|
306
|
+
# block mode: require at least 3 columns (scaffold/start/end)
|
|
307
|
+
try:
|
|
308
|
+
scaffold = fields[0]
|
|
309
|
+
start = int(fields[1])
|
|
310
|
+
end = int(fields[2])
|
|
311
|
+
region = {"scaffold": scaffold, "start": start, "end": end, "id": fields[3] if len(fields) >= 4 else None}
|
|
312
|
+
regions.append(region)
|
|
313
|
+
except (IndexError, ValueError):
|
|
314
|
+
LOG.warning(f"Skipping malformed BED line: {line}", file=sys.stderr)
|
|
315
|
+
continue
|
|
316
|
+
return regions
|
|
317
|
+
|
|
318
|
+
#############################################################################
|
|
319
|
+
|
|
320
|
+
def getMAFHeader(maf_file, maf_compression):
|
|
321
|
+
"""Return header lines (starting with ##) from the MAF file, skipping blank lines."""
|
|
322
|
+
headers = []
|
|
323
|
+
if maf_compression == "gz":
|
|
324
|
+
opener = gzip.open
|
|
325
|
+
mode = "rt"
|
|
326
|
+
else:
|
|
327
|
+
opener = open
|
|
328
|
+
mode = "rt"
|
|
329
|
+
with opener(maf_file, mode) as fp:
|
|
330
|
+
for line in fp:
|
|
331
|
+
line = line.strip()
|
|
332
|
+
if not line: # Skip blank lines
|
|
333
|
+
continue
|
|
334
|
+
if line.startswith("##"):
|
|
335
|
+
headers.append(line)
|
|
336
|
+
else:
|
|
337
|
+
break
|
|
338
|
+
# print(f"Found {len(headers)} header lines in MAF file.")
|
|
339
|
+
# print(f"Header lines:\n{headers}")
|
|
340
|
+
# sys.exit();
|
|
341
|
+
return "\n".join(headers) + "\n"
|
|
342
|
+
|
|
343
|
+
#############################################################################
|
|
344
|
+
|
|
345
|
+
def speciesFromSrc(src):
|
|
346
|
+
return src.split(".", 1)[0] if "." in src else src
|
|
347
|
+
|
|
348
|
+
#############################################################################
|
|
349
|
+
|
|
350
|
+
def appendWarning(warning_state, message):
|
|
351
|
+
if warning_state is None:
|
|
352
|
+
return
|
|
353
|
+
|
|
354
|
+
warning_state["count"] += 1
|
|
355
|
+
if warning_state["messages"] is not None:
|
|
356
|
+
warning_state["messages"].append(message)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def getFillString(fill_cache, fill_char, block_len):
|
|
360
|
+
cache_key = (fill_char, block_len)
|
|
361
|
+
if cache_key not in fill_cache:
|
|
362
|
+
fill_cache[cache_key] = fill_char * block_len
|
|
363
|
+
return fill_cache[cache_key]
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def getCachedBlockText(maf_fp, entry):
|
|
367
|
+
cache_key = (entry["offset_start"], entry["offset_end"])
|
|
368
|
+
if cache_key in WORKER_BLOCK_CACHE:
|
|
369
|
+
WORKER_BLOCK_CACHE.move_to_end(cache_key)
|
|
370
|
+
return WORKER_BLOCK_CACHE[cache_key]
|
|
371
|
+
|
|
372
|
+
maf_fp.seek(entry["offset_start"])
|
|
373
|
+
block_bytes = maf_fp.read(entry["offset_end"] - entry["offset_start"])
|
|
374
|
+
block_text = block_bytes.decode("utf-8", errors="replace")
|
|
375
|
+
WORKER_BLOCK_CACHE[cache_key] = block_text
|
|
376
|
+
if len(WORKER_BLOCK_CACHE) > WORKER_BLOCK_CACHE_MAX:
|
|
377
|
+
WORKER_BLOCK_CACHE.popitem(last=False)
|
|
378
|
+
return block_text
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def initProfileState():
|
|
382
|
+
return {
|
|
383
|
+
"regions": 0,
|
|
384
|
+
"candidate_entries": 0,
|
|
385
|
+
"overlap_blocks": 0,
|
|
386
|
+
"time_batch_total": 0.0,
|
|
387
|
+
"time_region_total": 0.0,
|
|
388
|
+
"time_index_scan": 0.0,
|
|
389
|
+
"time_read_decode": 0.0,
|
|
390
|
+
"time_trim": 0.0,
|
|
391
|
+
"time_block_to_fasta": 0.0,
|
|
392
|
+
"time_fasta_stitch": 0.0,
|
|
393
|
+
"time_write_fasta": 0.0,
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def addProfile(profile_state, key, value):
|
|
398
|
+
if profile_state is not None:
|
|
399
|
+
profile_state[key] += value
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def mafBlockToFasta(block_text, region, dedupe_mode="none", use_species_keys=False):
|
|
403
|
+
"""
|
|
404
|
+
Converts a (trimmed) MAF block to multi-FASTA format.
|
|
405
|
+
Each FASTA header includes the species, chrom, strand, and the (possibly trimmed) coordinates.
|
|
406
|
+
Optionally, include region/scaffold in headers.
|
|
407
|
+
"""
|
|
408
|
+
fasta_lines = defaultdict(dict)
|
|
409
|
+
lines = block_text.strip().splitlines()
|
|
410
|
+
block_len = 0
|
|
411
|
+
|
|
412
|
+
for line in lines:
|
|
413
|
+
if line.startswith("s "):
|
|
414
|
+
fields = line.split()
|
|
415
|
+
src = fields[1] # species.chrom
|
|
416
|
+
species = speciesFromSrc(src)
|
|
417
|
+
#chrom = ".".join(src.split(".")[1:]) if "." in src else src
|
|
418
|
+
#sp = src.split(".")[0] if "." in src else src
|
|
419
|
+
start = int(fields[2])
|
|
420
|
+
size = int(fields[3])
|
|
421
|
+
strand = fields[4]
|
|
422
|
+
#srcSize = fields[5]
|
|
423
|
+
seq = fields[6]
|
|
424
|
+
block_len = len(seq)
|
|
425
|
+
key = species if use_species_keys else src
|
|
426
|
+
nongap = sum(1 for c in seq if c != "-")
|
|
427
|
+
|
|
428
|
+
# Compute 1-based closed interval for clarity (as in FASTA tools)
|
|
429
|
+
if strand == "+":
|
|
430
|
+
end = start + size
|
|
431
|
+
else:
|
|
432
|
+
# For negative strand, coordinates can be reported as on "forward" chromosome (for clarity, as in MAF)
|
|
433
|
+
end = start + size
|
|
434
|
+
|
|
435
|
+
if key in fasta_lines and dedupe_mode == "most-seq":
|
|
436
|
+
if nongap > fasta_lines[key]["nongap"]:
|
|
437
|
+
fasta_lines[key] = {'seq': seq, 'start': start, 'end': end, 'strand': strand, 'nongap': nongap}
|
|
438
|
+
elif key not in fasta_lines:
|
|
439
|
+
fasta_lines[key] = {'seq': seq, 'start': start, 'end': end, 'strand': strand, 'nongap': nongap}
|
|
440
|
+
elif dedupe_mode == "none":
|
|
441
|
+
# Preserve previous behavior when dedupe is off by keeping src keys unique.
|
|
442
|
+
# If duplicate src appears, keep first occurrence.
|
|
443
|
+
pass
|
|
444
|
+
|
|
445
|
+
for key in list(fasta_lines.keys()):
|
|
446
|
+
if "nongap" in fasta_lines[key]:
|
|
447
|
+
del fasta_lines[key]["nongap"]
|
|
448
|
+
|
|
449
|
+
return fasta_lines, block_len
|
|
450
|
+
|
|
451
|
+
#############################################################################
|
|
452
|
+
|
|
453
|
+
def writeFASTA(fasta_seqs, region, fasta_stream, BATCHLOG, fasta_header=False, verbose=False, warning_state=None):
|
|
454
|
+
|
|
455
|
+
fasta_output = {}
|
|
456
|
+
for sp, details in fasta_seqs.items():
|
|
457
|
+
if fasta_header == "species-only":
|
|
458
|
+
header = f">{speciesFromSrc(sp)}"
|
|
459
|
+
else:
|
|
460
|
+
if details['starts'] and details['ends']:
|
|
461
|
+
region_start = min(details['starts'])
|
|
462
|
+
region_end = max(details['ends'])
|
|
463
|
+
else:
|
|
464
|
+
region_start = region['start']
|
|
465
|
+
region_end = region['end']
|
|
466
|
+
strand = list(set(details['strands'])) if details['strands'] else ["."]
|
|
467
|
+
|
|
468
|
+
if len(strand) == 1:
|
|
469
|
+
region_strand = strand[0]
|
|
470
|
+
else:
|
|
471
|
+
region_strand = "."
|
|
472
|
+
appendWarning(
|
|
473
|
+
warning_state,
|
|
474
|
+
f"Multiple strands found for species {sp} in region {region['scaffold']}:{region['start']}-{region['end']}. Using '.' in header.",
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
if fasta_header == "species-coords":
|
|
478
|
+
header = f">{sp}:{region_start}-{region_end}({region_strand})"
|
|
479
|
+
elif fasta_header == "species-coords-id":
|
|
480
|
+
if "id" in region and region["id"]:
|
|
481
|
+
id_str = f"id:{region['id']}"
|
|
482
|
+
header = f">{sp}:{region_start}-{region_end}({region_strand}) {id_str}"
|
|
483
|
+
|
|
484
|
+
fasta_stream.write(f"{header}\n{''.join(details['seq'])}\n")
|
|
485
|
+
|
|
486
|
+
#############################################################################
|
|
487
|
+
|
|
488
|
+
def trimMafBlock(block_text, bed_start, bed_end, BATCHLOG, verbose=False, warning_state=None):
|
|
489
|
+
"""
|
|
490
|
+
Trims a MAF block to exactly the portion overlapping [bed_start, bed_end)
|
|
491
|
+
on the reference sequence. Assumes the reference sequence is given in the
|
|
492
|
+
first "s" line after the header.
|
|
493
|
+
|
|
494
|
+
The MAF "s" line format:
|
|
495
|
+
s <src> <start> <size> <strand> <srcSize> <aligned_sequence>
|
|
496
|
+
|
|
497
|
+
Returns the trimmed block as a string, or None if no overlap occurs.
|
|
498
|
+
"""
|
|
499
|
+
|
|
500
|
+
lines = block_text.splitlines()
|
|
501
|
+
if len(lines) < 2:
|
|
502
|
+
return None # Invalid block
|
|
503
|
+
trimmed_lines = []
|
|
504
|
+
if lines[0].startswith("a"):
|
|
505
|
+
trimmed_lines.append(lines[0])
|
|
506
|
+
else:
|
|
507
|
+
trimmed_lines.append("a")
|
|
508
|
+
|
|
509
|
+
ref_line_found = False
|
|
510
|
+
ref_col_start = None
|
|
511
|
+
ref_col_end = None
|
|
512
|
+
new_ref_start = None
|
|
513
|
+
new_ref_size = None
|
|
514
|
+
for line in lines[1:]:
|
|
515
|
+
|
|
516
|
+
fields = line.split()
|
|
517
|
+
|
|
518
|
+
if not fields or fields[0] != "s":
|
|
519
|
+
trimmed_lines.append(line)
|
|
520
|
+
continue
|
|
521
|
+
|
|
522
|
+
if not ref_line_found:
|
|
523
|
+
ref_line_found = True
|
|
524
|
+
try:
|
|
525
|
+
block_ref_start = int(fields[2])
|
|
526
|
+
block_ref_size = int(fields[3])
|
|
527
|
+
except ValueError:
|
|
528
|
+
BATCHLOG.error("Error parsing reference coordinates in MAF block.")
|
|
529
|
+
ref_seq = fields[6]
|
|
530
|
+
# Determine overlap with the block.
|
|
531
|
+
overlap_start = max(bed_start, block_ref_start)
|
|
532
|
+
overlap_end = min(bed_end, block_ref_start + block_ref_size)
|
|
533
|
+
if overlap_start >= overlap_end:
|
|
534
|
+
return None # No overlap.
|
|
535
|
+
|
|
536
|
+
# FIXED: Map the overlap to alignment columns correctly
|
|
537
|
+
current_ref = block_ref_start
|
|
538
|
+
col_start = None
|
|
539
|
+
col_end = None
|
|
540
|
+
|
|
541
|
+
for i, char in enumerate(ref_seq):
|
|
542
|
+
if char != '-':
|
|
543
|
+
# This is a real reference position
|
|
544
|
+
if current_ref >= overlap_start and col_start is None:
|
|
545
|
+
col_start = i
|
|
546
|
+
current_ref += 1
|
|
547
|
+
if current_ref >= overlap_end: # Check AFTER incrementing
|
|
548
|
+
col_end = i + 1 # Include current position
|
|
549
|
+
break
|
|
550
|
+
elif col_start is not None and col_end is None:
|
|
551
|
+
# We're in our target region and this is a gap - we'll include it
|
|
552
|
+
pass
|
|
553
|
+
|
|
554
|
+
if col_start is None:
|
|
555
|
+
return None
|
|
556
|
+
if col_end is None:
|
|
557
|
+
col_end = len(ref_seq)
|
|
558
|
+
|
|
559
|
+
if col_start is not None:
|
|
560
|
+
extracted_ref_bases = sum(1 for c in ref_seq[col_start:col_end] if c != '-')
|
|
561
|
+
expected_bases = overlap_end - overlap_start
|
|
562
|
+
if extracted_ref_bases != expected_bases:
|
|
563
|
+
appendWarning(
|
|
564
|
+
warning_state,
|
|
565
|
+
f"Expected {expected_bases} bases but extracted {extracted_ref_bases} for region {overlap_start}-{overlap_end}",
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
ref_col_start = col_start
|
|
569
|
+
ref_col_end = col_end
|
|
570
|
+
new_ref_start = overlap_start
|
|
571
|
+
new_ref_size = sum(1 for c in ref_seq[ref_col_start:ref_col_end] if c != '-')
|
|
572
|
+
new_ref_end = new_ref_start + new_ref_size
|
|
573
|
+
new_fields = fields.copy()
|
|
574
|
+
new_fields[2] = str(new_ref_start)
|
|
575
|
+
new_fields[3] = str(new_ref_size)
|
|
576
|
+
new_fields[6] = ref_seq[ref_col_start:ref_col_end]
|
|
577
|
+
trimmed_lines.append(" ".join(new_fields))
|
|
578
|
+
|
|
579
|
+
else:
|
|
580
|
+
species_seq = fields[6]
|
|
581
|
+
offset_bases = sum(1 for c in species_seq[:ref_col_start] if c != '-')
|
|
582
|
+
|
|
583
|
+
trimmed_seq = species_seq[ref_col_start:ref_col_end]
|
|
584
|
+
trimmed_size = sum(1 for c in trimmed_seq if c != '-')
|
|
585
|
+
|
|
586
|
+
original_start = int(fields[2])
|
|
587
|
+
strand = fields[4]
|
|
588
|
+
original_size = int(fields[3])
|
|
589
|
+
new_start = original_start + offset_bases
|
|
590
|
+
# if strand == '+':
|
|
591
|
+
# new_start = original_start + offset_bases
|
|
592
|
+
# else:
|
|
593
|
+
# new_start = original_start + (original_size - offset_bases - trimmed_size)
|
|
594
|
+
|
|
595
|
+
new_fields = fields.copy()
|
|
596
|
+
new_fields[2] = str(new_start)
|
|
597
|
+
new_fields[3] = str(trimmed_size)
|
|
598
|
+
new_fields[6] = trimmed_seq
|
|
599
|
+
trimmed_lines.append(' '.join(new_fields))
|
|
600
|
+
|
|
601
|
+
#print(f"DEBUG: bed_interval=[{bed_start},{bed_end}), block_ref=[{block_ref_start},{block_ref_start + block_ref_size}), extracted_cols=[{ref_col_start}:{ref_col_end}], ref_seq_sample='{ref_seq[ref_col_start:ref_col_end] if ref_col_start is not None else 'None'}'", file=sys.stderr)
|
|
602
|
+
|
|
603
|
+
return "\n".join(trimmed_lines), [new_ref_start, new_ref_end, new_ref_size]
|
|
604
|
+
|
|
605
|
+
#############################################################################
|
|
606
|
+
|
|
607
|
+
def fetchByRegion(
|
|
608
|
+
region,
|
|
609
|
+
header,
|
|
610
|
+
maf_fp,
|
|
611
|
+
index,
|
|
612
|
+
output,
|
|
613
|
+
BATCHLOG,
|
|
614
|
+
single_output=False,
|
|
615
|
+
as_fasta=False,
|
|
616
|
+
fasta_header=False,
|
|
617
|
+
expected_species=None,
|
|
618
|
+
fasta_dedupe="none",
|
|
619
|
+
verbose=False,
|
|
620
|
+
warning_state=None,
|
|
621
|
+
profile_state=None,
|
|
622
|
+
):
|
|
623
|
+
"""
|
|
624
|
+
Worker function to process a single BED region:
|
|
625
|
+
- Opens the MAF file independently.
|
|
626
|
+
- Finds overlapping blocks in the index for region["scaffold"].
|
|
627
|
+
- For each overlapping block, fetches and trims the block to the
|
|
628
|
+
exact [region["start"], region["end"]) interval on the reference.
|
|
629
|
+
- Writes the trimmed blocks to an output file, using region["output_basename"] as filename.
|
|
630
|
+
Returns a message summarizing the work.
|
|
631
|
+
"""
|
|
632
|
+
|
|
633
|
+
scaffold = region["scaffold"]
|
|
634
|
+
bed_start = region["start"]
|
|
635
|
+
bed_end = region["end"]
|
|
636
|
+
out_basename = region["output_basename"]
|
|
637
|
+
region_str = f"{scaffold}\t{bed_start}\t{bed_end}\t{out_basename}"
|
|
638
|
+
region_timer_start = time.perf_counter() if profile_state is not None else None
|
|
639
|
+
# Parse region details
|
|
640
|
+
|
|
641
|
+
block_lengths = [] # Holds lengths of each block written
|
|
642
|
+
block_stats = [] # Holds [new_ref_start, new_ref_end, new_ref_size] for each block
|
|
643
|
+
total_ref_bases = 0 # Total ref bases across all blocks
|
|
644
|
+
|
|
645
|
+
if as_fasta:
|
|
646
|
+
fasta_seqs = defaultdict(dict);
|
|
647
|
+
species_order = expected_species[:] if expected_species else []
|
|
648
|
+
expected_species_set = set(expected_species) if expected_species else set()
|
|
649
|
+
use_species_keys = bool(expected_species) or fasta_dedupe != "none"
|
|
650
|
+
fill_cache = {}
|
|
651
|
+
# For fasta output, hold sequences per species
|
|
652
|
+
|
|
653
|
+
output_filename = os.path.join(output, out_basename + (".fa" if as_fasta else ".maf"))
|
|
654
|
+
out_stream = None
|
|
655
|
+
|
|
656
|
+
if single_output:
|
|
657
|
+
current_blocks = []
|
|
658
|
+
# Single output mode being developed !
|
|
659
|
+
|
|
660
|
+
blocks_written = 0;
|
|
661
|
+
|
|
662
|
+
if scaffold not in index:
|
|
663
|
+
appendWarning(warning_state, f"{scaffold}:{bed_start}-{bed_end} No index entries for scaffold.")
|
|
664
|
+
summary = f"{region_str}\t0\t0\tNA\t0"
|
|
665
|
+
return summary if not single_output else []
|
|
666
|
+
|
|
667
|
+
blocks_written = 0
|
|
668
|
+
|
|
669
|
+
scaffold_index = index[scaffold]
|
|
670
|
+
candidate_idx = bisect.bisect_left(scaffold_index["starts"], bed_start)
|
|
671
|
+
if candidate_idx > 0:
|
|
672
|
+
candidate_idx -= 1
|
|
673
|
+
|
|
674
|
+
scan_timer_start = time.perf_counter() if profile_state is not None else None
|
|
675
|
+
for entry in scaffold_index["entries"][candidate_idx:]:
|
|
676
|
+
if profile_state is not None:
|
|
677
|
+
profile_state["candidate_entries"] += 1
|
|
678
|
+
block_ref_start = entry["ref_start"]
|
|
679
|
+
block_ref_end = block_ref_start + entry["ref_length"]
|
|
680
|
+
|
|
681
|
+
if block_ref_end <= bed_start:
|
|
682
|
+
continue # block before region
|
|
683
|
+
|
|
684
|
+
if block_ref_start >= bed_end:
|
|
685
|
+
break # block after region
|
|
686
|
+
|
|
687
|
+
if bed_start < block_ref_end and bed_end > block_ref_start:
|
|
688
|
+
if profile_state is not None:
|
|
689
|
+
profile_state["overlap_blocks"] += 1
|
|
690
|
+
try:
|
|
691
|
+
read_timer_start = time.perf_counter() if profile_state is not None else None
|
|
692
|
+
maf_fp.seek(entry["offset_start"])
|
|
693
|
+
block_bytes = maf_fp.read(entry["offset_end"] - entry["offset_start"])
|
|
694
|
+
block_text = block_bytes.decode("utf-8", errors="replace")
|
|
695
|
+
if profile_state is not None:
|
|
696
|
+
addProfile(profile_state, "time_read_decode", time.perf_counter() - read_timer_start)
|
|
697
|
+
#print(block_text.splitlines()[1])
|
|
698
|
+
except Exception as e:
|
|
699
|
+
BATCHLOG.error(f"Error fetching block: {e}")
|
|
700
|
+
raise
|
|
701
|
+
|
|
702
|
+
trim_timer_start = time.perf_counter() if profile_state is not None else None
|
|
703
|
+
trimmed_result = trimMafBlock(
|
|
704
|
+
block_text,
|
|
705
|
+
bed_start,
|
|
706
|
+
bed_end,
|
|
707
|
+
BATCHLOG,
|
|
708
|
+
verbose=verbose,
|
|
709
|
+
warning_state=warning_state,
|
|
710
|
+
)
|
|
711
|
+
if profile_state is not None:
|
|
712
|
+
addProfile(profile_state, "time_trim", time.perf_counter() - trim_timer_start)
|
|
713
|
+
if trimmed_result is None:
|
|
714
|
+
continue
|
|
715
|
+
trimmed, trimmed_info = trimmed_result
|
|
716
|
+
|
|
717
|
+
if not single_output and trimmed is not None:
|
|
718
|
+
block_stats.append(trimmed_info)
|
|
719
|
+
total_ref_bases += trimmed_info[2]
|
|
720
|
+
|
|
721
|
+
if blocks_written == 0 and not as_fasta:
|
|
722
|
+
out_stream = open(output_filename, "w", encoding="utf-8")
|
|
723
|
+
out_stream.write(header)
|
|
724
|
+
out_stream.write("## Extracted by mafutils fetch\n")
|
|
725
|
+
out_stream.write("## Source MAF: " + os.path.basename(maf_fp.name) + "\n")
|
|
726
|
+
out_stream.write(f"## Reference region: {region_str}\n")
|
|
727
|
+
|
|
728
|
+
if as_fasta:
|
|
729
|
+
|
|
730
|
+
# Output as fasta
|
|
731
|
+
fasta_block_timer_start = time.perf_counter() if profile_state is not None else None
|
|
732
|
+
return_fasta_seqs, block_len = mafBlockToFasta(
|
|
733
|
+
trimmed,
|
|
734
|
+
region,
|
|
735
|
+
dedupe_mode=fasta_dedupe,
|
|
736
|
+
use_species_keys=use_species_keys
|
|
737
|
+
)
|
|
738
|
+
if profile_state is not None:
|
|
739
|
+
addProfile(profile_state, "time_block_to_fasta", time.perf_counter() - fasta_block_timer_start)
|
|
740
|
+
#fasta_lines[src] = {'header': header, 'seq': seq, 'start': start, 'end': end, 'strand': strand}
|
|
741
|
+
block_lengths.append(block_len)
|
|
742
|
+
|
|
743
|
+
stitch_timer_start = time.perf_counter() if profile_state is not None else None
|
|
744
|
+
if expected_species:
|
|
745
|
+
for sp in expected_species:
|
|
746
|
+
if sp not in return_fasta_seqs:
|
|
747
|
+
return_fasta_seqs[sp] = {
|
|
748
|
+
'seq': getFillString(fill_cache, "N", block_len),
|
|
749
|
+
'start': None,
|
|
750
|
+
'end': None,
|
|
751
|
+
'strand': None
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
# For any new species, add to order and backfill
|
|
755
|
+
for sp in return_fasta_seqs:
|
|
756
|
+
if sp not in species_order:
|
|
757
|
+
species_order.append(sp)
|
|
758
|
+
if sp not in fasta_seqs:
|
|
759
|
+
# Backfill for all previous blocks
|
|
760
|
+
fasta_seqs[sp]['seq'] = [getFillString(fill_cache, "-", l) for l in block_lengths[:-1]]
|
|
761
|
+
fasta_seqs[sp]['starts'] = []
|
|
762
|
+
fasta_seqs[sp]['ends'] = []
|
|
763
|
+
fasta_seqs[sp]['strands'] = []
|
|
764
|
+
|
|
765
|
+
# After establishing all species, append current block or pad as needed
|
|
766
|
+
for sp in species_order:
|
|
767
|
+
if sp in return_fasta_seqs:
|
|
768
|
+
seq = return_fasta_seqs[sp]['seq']
|
|
769
|
+
else:
|
|
770
|
+
fill_char = "N" if sp in expected_species_set else "-"
|
|
771
|
+
seq = getFillString(fill_cache, fill_char, block_len)
|
|
772
|
+
fasta_seqs[sp]['seq'].append(seq)
|
|
773
|
+
if sp in return_fasta_seqs:
|
|
774
|
+
if return_fasta_seqs[sp]['start'] is not None:
|
|
775
|
+
fasta_seqs[sp]['starts'].append(return_fasta_seqs[sp]['start'])
|
|
776
|
+
if return_fasta_seqs[sp]['end'] is not None:
|
|
777
|
+
fasta_seqs[sp]['ends'].append(return_fasta_seqs[sp]['end'])
|
|
778
|
+
if return_fasta_seqs[sp]['strand'] is not None:
|
|
779
|
+
fasta_seqs[sp]['strands'].append(return_fasta_seqs[sp]['strand'])
|
|
780
|
+
if profile_state is not None:
|
|
781
|
+
addProfile(profile_state, "time_fasta_stitch", time.perf_counter() - stitch_timer_start)
|
|
782
|
+
else:
|
|
783
|
+
out_stream.write(trimmed + "\n")
|
|
784
|
+
blocks_written += 1
|
|
785
|
+
elif single_output and trimmed is not None:
|
|
786
|
+
if as_fasta:
|
|
787
|
+
return_fasta_seqs, _ = mafBlockToFasta(
|
|
788
|
+
trimmed,
|
|
789
|
+
region,
|
|
790
|
+
dedupe_mode=fasta_dedupe,
|
|
791
|
+
use_species_keys=use_species_keys
|
|
792
|
+
)
|
|
793
|
+
current_blocks.append(return_fasta_seqs)
|
|
794
|
+
else:
|
|
795
|
+
current_blocks.append(trimmed + "\n")
|
|
796
|
+
|
|
797
|
+
if as_fasta and not single_output and blocks_written > 0:
|
|
798
|
+
out_stream = open(output_filename, "w", encoding="utf-8")
|
|
799
|
+
# Write all fasta sequences to the output
|
|
800
|
+
write_timer_start = time.perf_counter() if profile_state is not None else None
|
|
801
|
+
writeFASTA(
|
|
802
|
+
fasta_seqs,
|
|
803
|
+
region,
|
|
804
|
+
out_stream,
|
|
805
|
+
BATCHLOG,
|
|
806
|
+
fasta_header=fasta_header,
|
|
807
|
+
verbose=verbose,
|
|
808
|
+
warning_state=warning_state,
|
|
809
|
+
)
|
|
810
|
+
if profile_state is not None:
|
|
811
|
+
addProfile(profile_state, "time_write_fasta", time.perf_counter() - write_timer_start)
|
|
812
|
+
|
|
813
|
+
if not blocks_written:
|
|
814
|
+
appendWarning(warning_state, f"{scaffold}:{bed_start}-{bed_end} No overlapping blocks found.")
|
|
815
|
+
|
|
816
|
+
if not single_output:
|
|
817
|
+
if out_stream is not None:
|
|
818
|
+
out_stream.close()
|
|
819
|
+
# --- BEGIN SUMMARY REPORT ---
|
|
820
|
+
# Collect region info
|
|
821
|
+
block_stats.sort()
|
|
822
|
+
|
|
823
|
+
interblock_spaces = [
|
|
824
|
+
block_stats[i][0] - block_stats[i-1][1]
|
|
825
|
+
for i in range(1, len(block_stats))
|
|
826
|
+
] if len(block_stats) > 1 else []
|
|
827
|
+
|
|
828
|
+
num_blocks = len(block_stats)
|
|
829
|
+
blocks_length = sum(info[2] for info in block_stats)
|
|
830
|
+
space_str = str(interblock_spaces) if interblock_spaces else "NA"
|
|
831
|
+
if profile_state is not None:
|
|
832
|
+
profile_state["regions"] += 1
|
|
833
|
+
addProfile(profile_state, "time_index_scan", time.perf_counter() - scan_timer_start)
|
|
834
|
+
addProfile(profile_state, "time_region_total", time.perf_counter() - region_timer_start)
|
|
835
|
+
summary = (f"{region_str}\t{num_blocks}\t{blocks_length}\t{space_str}\t{len(species_order) if as_fasta else 'NA'}")
|
|
836
|
+
return summary
|
|
837
|
+
|
|
838
|
+
else:
|
|
839
|
+
if profile_state is not None:
|
|
840
|
+
profile_state["regions"] += 1
|
|
841
|
+
addProfile(profile_state, "time_index_scan", time.perf_counter() - scan_timer_start)
|
|
842
|
+
addProfile(profile_state, "time_region_total", time.perf_counter() - region_timer_start)
|
|
843
|
+
return current_blocks;
|
|
844
|
+
|
|
845
|
+
#############################################################################
|
|
846
|
+
|
|
847
|
+
def fetchByBatch(
|
|
848
|
+
batch,
|
|
849
|
+
batch_num,
|
|
850
|
+
):
|
|
851
|
+
|
|
852
|
+
BATCHLOG = logging.getLogger("maf_fetch_logger")
|
|
853
|
+
plural = "s" if len(batch) > 1 else ""
|
|
854
|
+
BATCHLOG.info(f"Processing batch {batch_num} with {len(batch)} region{plural}")
|
|
855
|
+
BATCHLOG.debug(f">> (first: {batch[0]['scaffold']}:{batch[0]['start']}-{batch[0]['end']}, last: {batch[-1]['scaffold']}:{batch[-1]['start']}-{batch[-1]['end']})")
|
|
856
|
+
# Initialize batch-specific logger
|
|
857
|
+
|
|
858
|
+
maf_fp = WORKER_MAF_FP
|
|
859
|
+
if maf_fp is None:
|
|
860
|
+
BATCHLOG.error(f"Batch {batch_num}: Worker MAF handle is not initialized.")
|
|
861
|
+
raise RuntimeError("Worker MAF handle is not initialized.")
|
|
862
|
+
|
|
863
|
+
batch_results = [None] * len(batch)
|
|
864
|
+
region_num = 0
|
|
865
|
+
zero_overlap_regions = 0
|
|
866
|
+
success_regions = 0
|
|
867
|
+
warning_state = {"count": 0, "messages": [] if WORKER_VERBOSE else None}
|
|
868
|
+
profile_state = initProfileState() if WORKER_PROFILE else None
|
|
869
|
+
batch_timer_start = time.perf_counter() if WORKER_PROFILE else None
|
|
870
|
+
|
|
871
|
+
ordered_regions = sorted(
|
|
872
|
+
enumerate(batch),
|
|
873
|
+
key=lambda item: (
|
|
874
|
+
item[1]["scaffold"],
|
|
875
|
+
item[1]["start"],
|
|
876
|
+
item[1]["end"],
|
|
877
|
+
item[0],
|
|
878
|
+
),
|
|
879
|
+
)
|
|
880
|
+
|
|
881
|
+
for batch_order, (result_idx, region) in enumerate(ordered_regions, start=1):
|
|
882
|
+
region_num += 1
|
|
883
|
+
|
|
884
|
+
batch_str = f" [batch {batch_num}.{batch_order}]"
|
|
885
|
+
BATCHLOG.debug(f">>>{batch_str} Region {region['scaffold']}:{region['start']}-{region['end']}")
|
|
886
|
+
|
|
887
|
+
region_result = fetchByRegion(
|
|
888
|
+
region,
|
|
889
|
+
WORKER_HEADER,
|
|
890
|
+
maf_fp,
|
|
891
|
+
WORKER_INDEX,
|
|
892
|
+
WORKER_OUTPUT,
|
|
893
|
+
BATCHLOG,
|
|
894
|
+
as_fasta=WORKER_AS_FASTA,
|
|
895
|
+
fasta_header=WORKER_FASTA_HEADER,
|
|
896
|
+
expected_species=WORKER_EXPECTED_SPECIES,
|
|
897
|
+
fasta_dedupe=WORKER_FASTA_DEDUPE,
|
|
898
|
+
verbose=WORKER_VERBOSE,
|
|
899
|
+
warning_state=warning_state,
|
|
900
|
+
profile_state=profile_state,
|
|
901
|
+
)
|
|
902
|
+
batch_results[result_idx] = region_result
|
|
903
|
+
if not WORKER_SINGLE_OUTPUT:
|
|
904
|
+
fields = region_result.split("\t")
|
|
905
|
+
if len(fields) >= 5:
|
|
906
|
+
try:
|
|
907
|
+
if int(fields[4]) == 0:
|
|
908
|
+
zero_overlap_regions += 1
|
|
909
|
+
else:
|
|
910
|
+
success_regions += 1
|
|
911
|
+
except ValueError:
|
|
912
|
+
pass
|
|
913
|
+
if profile_state is not None:
|
|
914
|
+
profile_state["time_batch_total"] = time.perf_counter() - batch_timer_start
|
|
915
|
+
|
|
916
|
+
return {
|
|
917
|
+
"results": batch_results,
|
|
918
|
+
"processed_regions": len(batch_results),
|
|
919
|
+
"zero_overlap_regions": zero_overlap_regions,
|
|
920
|
+
"batch_num": batch_num,
|
|
921
|
+
"success_regions": success_regions,
|
|
922
|
+
"warning_count": warning_state["count"],
|
|
923
|
+
"warning_messages": warning_state["messages"],
|
|
924
|
+
"profile": profile_state,
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
#############################################################################
|
|
928
|
+
|
|
929
|
+
def fetchByScaffold(scaffold, start_end, maf_file, maf_header, maf_compression, out_dir, LOG):
|
|
930
|
+
"""
|
|
931
|
+
Worker function to extract one scaffold from the MAF file.
|
|
932
|
+
start_end is a tuple (start_byte, end_byte).
|
|
933
|
+
"""
|
|
934
|
+
start, end = start_end
|
|
935
|
+
output_path = os.path.join(out_dir, f"{scaffold}.maf")
|
|
936
|
+
|
|
937
|
+
# Open MAF file appropriately
|
|
938
|
+
opener = gzip.open if maf_compression == "gz" else open
|
|
939
|
+
|
|
940
|
+
try:
|
|
941
|
+
with opener(maf_file, "rb") as mfp, open(output_path, "wb") as outfp:
|
|
942
|
+
LOG.info(f">>> Scaffold {scaffold}");
|
|
943
|
+
mfp.seek(start)
|
|
944
|
+
data = mfp.read(end - start)
|
|
945
|
+
outfp.write(maf_header.encode("utf-8"))
|
|
946
|
+
outfp.write(data)
|
|
947
|
+
except Exception as e:
|
|
948
|
+
LOG.error(f"{scaffold}: {e}")
|
|
949
|
+
|
|
950
|
+
return f"{output_path}: Wrote {scaffold}";
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
#############################################################################
|
|
954
|
+
|
|
955
|
+
def run_fetch(args, cmdline="mafutils fetch"):
|
|
956
|
+
|
|
957
|
+
logfilename = os.path.join(args.output, "maf_fetch.log") if os.path.isdir(args.output) else "maf_fetch.log"
|
|
958
|
+
maf_fetch_logger = LOGINIT.configureLogging(log_level="INFO", log_verbosity="BOTH", log_filename=logfilename, logger_name="maf_fetch_logger")
|
|
959
|
+
LOG = logging.getLogger(maf_fetch_logger)
|
|
960
|
+
# Set up logging
|
|
961
|
+
|
|
962
|
+
LOG.info(f"mafutils fetch called as: {cmdline}");
|
|
963
|
+
LOG.info("-" * 40);
|
|
964
|
+
for arg, value in vars(args).items():
|
|
965
|
+
valstr = str(value)
|
|
966
|
+
if len(valstr) > 80:
|
|
967
|
+
valstr = valstr[:77] + "..."
|
|
968
|
+
LOG.info(f"{arg:20} : {valstr}")
|
|
969
|
+
LOG.info("-" * 40 )
|
|
970
|
+
# Log the command line arguments
|
|
971
|
+
|
|
972
|
+
if args.mode not in ("block", "scaffold"):
|
|
973
|
+
LOG.error(f"Invalid mode: '{args.mode}'. Must be 'block' or 'scaffold'.")
|
|
974
|
+
sys.exit(1)
|
|
975
|
+
# Validate mode
|
|
976
|
+
# if args.max_no_overlap_regions < -1:
|
|
977
|
+
# LOG.error("--max-no-overlap-regions must be >= -1.")
|
|
978
|
+
# sys.exit(1)
|
|
979
|
+
# if args.max_no_overlap_fraction < 0.0 or args.max_no_overlap_fraction > 1.0:
|
|
980
|
+
# LOG.error("--max-no-overlap-fraction must be between 0 and 1.")
|
|
981
|
+
# sys.exit(1)
|
|
982
|
+
|
|
983
|
+
maf_compression = COMMON.detectCompression(args.maf_file);
|
|
984
|
+
if not args.single_output:
|
|
985
|
+
os.makedirs(args.output, exist_ok=True);
|
|
986
|
+
# Create output directory if it doesn't exist
|
|
987
|
+
|
|
988
|
+
LOG.info(f"Parsing index file.... {args.index_file}");
|
|
989
|
+
index = parseIndex(args.index_file, LOG, args.mode);
|
|
990
|
+
|
|
991
|
+
LOG.info(f"Parsing BED file...... {args.bed_file}");
|
|
992
|
+
regions = parseBed(args.bed_file, LOG, args.mode);
|
|
993
|
+
|
|
994
|
+
expected_species = parseExpectedSpecies(args)
|
|
995
|
+
if expected_species:
|
|
996
|
+
LOG.info(f"Loaded {len(expected_species)} expected species for FASTA missing-species filling.")
|
|
997
|
+
|
|
998
|
+
LOG.info(f"Getting MAF header.... {args.maf_file}");
|
|
999
|
+
maf_header = getMAFHeader(args.maf_file, maf_compression);
|
|
1000
|
+
|
|
1001
|
+
##############################
|
|
1002
|
+
# SCAFFOLD MODE
|
|
1003
|
+
##############################
|
|
1004
|
+
|
|
1005
|
+
if args.mode == "scaffold":
|
|
1006
|
+
if args.single_output:
|
|
1007
|
+
LOG.error("--single-output cannot be used in scaffold mode.");
|
|
1008
|
+
sys.exit(1)
|
|
1009
|
+
|
|
1010
|
+
if args.fasta:
|
|
1011
|
+
LOG.error("FASTA output not supported in scaffold mode.")
|
|
1012
|
+
sys.exit(1)
|
|
1013
|
+
if expected_species:
|
|
1014
|
+
LOG.error("--expected-species/--expected-species-file can only be used in block mode with --fasta.")
|
|
1015
|
+
sys.exit(1)
|
|
1016
|
+
if args.fasta_dedupe != "none":
|
|
1017
|
+
LOG.error("--fasta-dedupe can only be used in block mode with --fasta.")
|
|
1018
|
+
sys.exit(1)
|
|
1019
|
+
|
|
1020
|
+
LOG.info(f"Running in SCAFFOLD mode with BED + region index");
|
|
1021
|
+
|
|
1022
|
+
# Parse the BED file to get a set of scaffolds to extract
|
|
1023
|
+
scaffold_set = set(region["scaffold"] for region in regions);
|
|
1024
|
+
LOG.info(f"Extracting {len(scaffold_set)} scaffolds from scaffold index: {scaffold_set}");
|
|
1025
|
+
|
|
1026
|
+
# Extract from MAF using the region index for matching scaffolds
|
|
1027
|
+
with ProcessPoolExecutor(max_workers=args.processes) as executor:
|
|
1028
|
+
futures = []
|
|
1029
|
+
for scaffold in scaffold_set:
|
|
1030
|
+
if scaffold not in index:
|
|
1031
|
+
LOG.error(f"Scaffold '{scaffold}' not found in index.")
|
|
1032
|
+
sys.exit(1)
|
|
1033
|
+
start, end = index[scaffold]
|
|
1034
|
+
futures.append(executor.submit(
|
|
1035
|
+
fetchByScaffold,
|
|
1036
|
+
scaffold,
|
|
1037
|
+
(start, end),
|
|
1038
|
+
args.maf_file,
|
|
1039
|
+
maf_header,
|
|
1040
|
+
maf_compression,
|
|
1041
|
+
args.output,
|
|
1042
|
+
LOG
|
|
1043
|
+
))
|
|
1044
|
+
for future in futures:
|
|
1045
|
+
result = future.result()
|
|
1046
|
+
LOG.info(result)
|
|
1047
|
+
return; # Done with scaffold mode
|
|
1048
|
+
|
|
1049
|
+
##############################
|
|
1050
|
+
# BLOCK MODE (default)
|
|
1051
|
+
##############################
|
|
1052
|
+
|
|
1053
|
+
LOG.info(f"Running in BLOCK mode");
|
|
1054
|
+
if expected_species and not args.fasta:
|
|
1055
|
+
LOG.error("--expected-species/--expected-species-file require --fasta.")
|
|
1056
|
+
sys.exit(1)
|
|
1057
|
+
if args.fasta_dedupe != "none" and not args.fasta:
|
|
1058
|
+
LOG.error("--fasta-dedupe requires --fasta.")
|
|
1059
|
+
sys.exit(1)
|
|
1060
|
+
|
|
1061
|
+
num_regions = len(regions);
|
|
1062
|
+
batch_size = pick_chunk_size(num_regions, args.processes);
|
|
1063
|
+
if batch_size >= num_regions:
|
|
1064
|
+
batch_size = 1
|
|
1065
|
+
LOG.info(f"Using batch size of {batch_size} regions for {num_regions} total regions with {args.processes} processes.");
|
|
1066
|
+
batches = list(chunker(regions, size=batch_size))
|
|
1067
|
+
LOG.info(f"Divided {len(regions)} regions into {len(batches)} batches of up to {batch_size} regions each.");
|
|
1068
|
+
# Region batching for parallel processing
|
|
1069
|
+
|
|
1070
|
+
if args.single_output:
|
|
1071
|
+
if args.output == "." or os.path.isdir(args.output):
|
|
1072
|
+
output_file = "maf-fetch.maf"
|
|
1073
|
+
else:
|
|
1074
|
+
if os.path.isdir(args.output):
|
|
1075
|
+
LOG.error("--single-output cannot be used with --outdir. Use current directory only.");
|
|
1076
|
+
sys.exit(1)
|
|
1077
|
+
output_file = args.output
|
|
1078
|
+
LOG.info(f"Using single output file: {output_file}")
|
|
1079
|
+
## Single output mode being developed !
|
|
1080
|
+
|
|
1081
|
+
# Pre-assign output basenames for regions.
|
|
1082
|
+
# For regions missing a 4th column (basename), assign a unique counter per scaffold.
|
|
1083
|
+
region_counter = {}
|
|
1084
|
+
for region in regions:
|
|
1085
|
+
scaffold = region["scaffold"]
|
|
1086
|
+
start = region["start"]
|
|
1087
|
+
end = region["end"]
|
|
1088
|
+
basename_mode = args.basename
|
|
1089
|
+
|
|
1090
|
+
if basename_mode == "id":
|
|
1091
|
+
if region.get("id"):
|
|
1092
|
+
region["output_basename"] = region["id"]
|
|
1093
|
+
else:
|
|
1094
|
+
LOG.error(f"Basename mode set to 'id', but '{region}' lacks an ID (4th column).");
|
|
1095
|
+
elif basename_mode == "count":
|
|
1096
|
+
count = region_counter.get(scaffold, 0) + 1
|
|
1097
|
+
region_counter[scaffold] = count
|
|
1098
|
+
region["output_basename"] = f"{scaffold}.{count}"
|
|
1099
|
+
else: # basename_mode == "coords":
|
|
1100
|
+
region["output_basename"] = f"{scaffold}-{start}-{end}"
|
|
1101
|
+
|
|
1102
|
+
info_outfile = os.path.join(args.output, "maf_fetch_summary.tsv")
|
|
1103
|
+
|
|
1104
|
+
# if args.fasta_header and not args.fasta:
|
|
1105
|
+
# LOG.warning("--fasta-header is only used with --fasta output. Ignoring.");
|
|
1106
|
+
|
|
1107
|
+
# Process each batch in parallel.
|
|
1108
|
+
results = [];
|
|
1109
|
+
total_regions_reported = 0
|
|
1110
|
+
zero_overlap_regions = 0
|
|
1111
|
+
fail_fast_triggered = False
|
|
1112
|
+
fail_fast_message = None
|
|
1113
|
+
profile_totals = initProfileState() if args.profile else None
|
|
1114
|
+
profiled_batches = 0
|
|
1115
|
+
with ProcessPoolExecutor(
|
|
1116
|
+
max_workers=args.processes,
|
|
1117
|
+
initializer=initBatchWorker,
|
|
1118
|
+
initargs=(
|
|
1119
|
+
maf_header,
|
|
1120
|
+
args.maf_file,
|
|
1121
|
+
maf_compression,
|
|
1122
|
+
index,
|
|
1123
|
+
args.output,
|
|
1124
|
+
args.single_output,
|
|
1125
|
+
args.fasta,
|
|
1126
|
+
args.fasta_header,
|
|
1127
|
+
expected_species,
|
|
1128
|
+
args.fasta_dedupe,
|
|
1129
|
+
args.verbose,
|
|
1130
|
+
args.profile,
|
|
1131
|
+
),
|
|
1132
|
+
) as executor, open(info_outfile, "w") as info_out:
|
|
1133
|
+
summary_headers = ["scaffold", "start", "end", "basename", "n.overlapping.blocks", "block.lengths", "interblock.distances", "n.sequences"]
|
|
1134
|
+
info_out.write("\t".join(summary_headers) + "\n")
|
|
1135
|
+
|
|
1136
|
+
futures = {}
|
|
1137
|
+
for batch_num, batch in enumerate(batches, start=1):
|
|
1138
|
+
future = executor.submit(
|
|
1139
|
+
fetchByBatch,
|
|
1140
|
+
batch,
|
|
1141
|
+
batch_num,
|
|
1142
|
+
)
|
|
1143
|
+
futures[future] = batch_num
|
|
1144
|
+
|
|
1145
|
+
while futures:
|
|
1146
|
+
done, _pending = wait(list(futures.keys()), return_when=FIRST_COMPLETED)
|
|
1147
|
+
for future in done:
|
|
1148
|
+
batch_num = futures.pop(future)
|
|
1149
|
+
result = future.result()
|
|
1150
|
+
if args.profile and result["profile"] is not None:
|
|
1151
|
+
profiled_batches += 1
|
|
1152
|
+
for key, value in result["profile"].items():
|
|
1153
|
+
profile_totals[key] += value
|
|
1154
|
+
|
|
1155
|
+
if not args.single_output:
|
|
1156
|
+
for r in result["results"]:
|
|
1157
|
+
info_out.write(r + "\n")
|
|
1158
|
+
total_regions_reported += result["processed_regions"]
|
|
1159
|
+
zero_overlap_regions += result["zero_overlap_regions"]
|
|
1160
|
+
|
|
1161
|
+
if result["warning_count"] > 0:
|
|
1162
|
+
if args.verbose:
|
|
1163
|
+
for warning_message in result["warning_messages"]:
|
|
1164
|
+
LOG.warning(warning_message)
|
|
1165
|
+
else:
|
|
1166
|
+
LOG.warning(
|
|
1167
|
+
"Batch %d produced %d warning%s.",
|
|
1168
|
+
result["batch_num"],
|
|
1169
|
+
result["warning_count"],
|
|
1170
|
+
"" if result["warning_count"] == 1 else "s",
|
|
1171
|
+
)
|
|
1172
|
+
|
|
1173
|
+
zero_overlap_fraction_final_floor = zero_overlap_regions / num_regions if num_regions else 0.0
|
|
1174
|
+
|
|
1175
|
+
LOG.info(
|
|
1176
|
+
"Completed batch %d: %d successful / %d warn / %d total",
|
|
1177
|
+
result["batch_num"],
|
|
1178
|
+
result["success_regions"],
|
|
1179
|
+
result["zero_overlap_regions"],
|
|
1180
|
+
result["processed_regions"],
|
|
1181
|
+
)
|
|
1182
|
+
|
|
1183
|
+
if zero_overlap_regions > MAX_NO_OVERLAP_REGIONS_DEFAULT:
|
|
1184
|
+
fail_fast_triggered = True
|
|
1185
|
+
fail_fast_message = (
|
|
1186
|
+
f"Zero-overlap regions ({zero_overlap_regions}) exceeded hardcoded max "
|
|
1187
|
+
f"({MAX_NO_OVERLAP_REGIONS_DEFAULT}). Failing run."
|
|
1188
|
+
)
|
|
1189
|
+
elif zero_overlap_fraction_final_floor > MAX_NO_OVERLAP_FRACTION_DEFAULT:
|
|
1190
|
+
fail_fast_triggered = True
|
|
1191
|
+
fail_fast_message = (
|
|
1192
|
+
f"Zero-overlap fraction cannot recover below hardcoded max "
|
|
1193
|
+
f"({MAX_NO_OVERLAP_FRACTION_DEFAULT:.6f}); current lower bound is "
|
|
1194
|
+
f"{zero_overlap_fraction_final_floor:.6f}. Failing run."
|
|
1195
|
+
)
|
|
1196
|
+
else:
|
|
1197
|
+
results.append(result["results"])
|
|
1198
|
+
|
|
1199
|
+
if fail_fast_triggered:
|
|
1200
|
+
for pending_future in futures:
|
|
1201
|
+
pending_future.cancel()
|
|
1202
|
+
break
|
|
1203
|
+
|
|
1204
|
+
if fail_fast_triggered:
|
|
1205
|
+
LOG.error(fail_fast_message)
|
|
1206
|
+
sys.exit(2)
|
|
1207
|
+
|
|
1208
|
+
if args.profile and profile_totals is not None and profiled_batches > 0:
|
|
1209
|
+
LOG.info("Profile summary across %d batch(es):", profiled_batches)
|
|
1210
|
+
LOG.info(
|
|
1211
|
+
"Profile regions=%d candidate_entries=%d overlap_blocks=%d",
|
|
1212
|
+
profile_totals["regions"],
|
|
1213
|
+
profile_totals["candidate_entries"],
|
|
1214
|
+
profile_totals["overlap_blocks"],
|
|
1215
|
+
)
|
|
1216
|
+
LOG.info(
|
|
1217
|
+
"Profile time_batch_total=%.3fs time_region_total=%.3fs time_index_scan=%.3fs "
|
|
1218
|
+
"time_read_decode=%.3fs time_trim=%.3fs time_block_to_fasta=%.3fs "
|
|
1219
|
+
"time_fasta_stitch=%.3fs time_write_fasta=%.3fs",
|
|
1220
|
+
profile_totals.get("time_batch_total", 0.0),
|
|
1221
|
+
profile_totals["time_region_total"],
|
|
1222
|
+
profile_totals["time_index_scan"],
|
|
1223
|
+
profile_totals["time_read_decode"],
|
|
1224
|
+
profile_totals["time_trim"],
|
|
1225
|
+
profile_totals["time_block_to_fasta"],
|
|
1226
|
+
profile_totals["time_fasta_stitch"],
|
|
1227
|
+
profile_totals["time_write_fasta"],
|
|
1228
|
+
)
|
|
1229
|
+
|
|
1230
|
+
if not args.single_output and total_regions_reported > 0 and zero_overlap_regions > 0:
|
|
1231
|
+
zero_overlap_fraction = zero_overlap_regions / total_regions_reported
|
|
1232
|
+
LOG.warning(
|
|
1233
|
+
"%d of %d regions had no overlapping MAF blocks (%.6f).",
|
|
1234
|
+
zero_overlap_regions,
|
|
1235
|
+
total_regions_reported,
|
|
1236
|
+
zero_overlap_fraction,
|
|
1237
|
+
)
|
|
1238
|
+
if zero_overlap_regions == total_regions_reported:
|
|
1239
|
+
LOG.error("No regions overlapped any MAF block. Failing run.")
|
|
1240
|
+
sys.exit(2)
|
|
1241
|
+
if (
|
|
1242
|
+
MAX_NO_OVERLAP_REGIONS_DEFAULT >= 0
|
|
1243
|
+
and zero_overlap_regions > MAX_NO_OVERLAP_REGIONS_DEFAULT
|
|
1244
|
+
):
|
|
1245
|
+
LOG.error(
|
|
1246
|
+
"Zero-overlap regions (%d) exceeded hardcoded max (%d). Failing run.",
|
|
1247
|
+
zero_overlap_regions,
|
|
1248
|
+
MAX_NO_OVERLAP_REGIONS_DEFAULT,
|
|
1249
|
+
)
|
|
1250
|
+
sys.exit(2)
|
|
1251
|
+
if zero_overlap_fraction > MAX_NO_OVERLAP_FRACTION_DEFAULT:
|
|
1252
|
+
LOG.error(
|
|
1253
|
+
"Zero-overlap fraction (%.6f) exceeded hardcoded max (%.6f). Failing run.",
|
|
1254
|
+
zero_overlap_fraction,
|
|
1255
|
+
MAX_NO_OVERLAP_FRACTION_DEFAULT,
|
|
1256
|
+
)
|
|
1257
|
+
sys.exit(2)
|
|
1258
|
+
|
|
1259
|
+
# if args.single_output:
|
|
1260
|
+
# with open(output_file, "w", encoding="utf-8") as out_stream:
|
|
1261
|
+
# if not args.fasta:
|
|
1262
|
+
# out_stream.write(maf_header) # Write header to single output file
|
|
1263
|
+
|
|
1264
|
+
# total_blocks = 0
|
|
1265
|
+
# for block_list in results:
|
|
1266
|
+
# if block_list: # block_list is now a list of strings
|
|
1267
|
+
# for block in block_list:
|
|
1268
|
+
# out_stream.write(block)
|
|
1269
|
+
# total_blocks += 1
|
|
1270
|
+
|
|
1271
|
+
# LOG.info(f"Single output file written: {output_file} ({total_blocks} blocks)")
|
|
1272
|
+
|
|
1273
|
+
|
|
1274
|
+
def fetch_command(
|
|
1275
|
+
maf_file: Annotated[str, typer.Argument(help="Input MAF file (.maf or .maf.gz)")],
|
|
1276
|
+
index_file: Annotated[str, typer.Argument(help="Index file (block-level .idx or scaffold-level .idx)")],
|
|
1277
|
+
bed_file: Annotated[str, typer.Argument(help="BED file with regions or scaffold names to extract")],
|
|
1278
|
+
basename: Annotated[BasenameMode, typer.Option("--basename", "-b", help="Basename strategy: 'id' (BED 4th col if present), 'coords' (scaffold-start-end), or 'count' (numbered per scaffold)")] = BasenameMode.coords,
|
|
1279
|
+
output: Annotated[str, typer.Option("--output", "-o", help="Output directory (default: current directory); if --single-output is used, this should instead be a filename and will default to maf-fetch.maf in the current directory.")] = ".",
|
|
1280
|
+
fasta: Annotated[bool, typer.Option("--fasta", "-f", help="Output region(s) in FASTA format instead of MAF.")] = False,
|
|
1281
|
+
fasta_header: Annotated[FastaHeaderMode, typer.Option("--fasta-header", "-fh", help="Controls the FASTA header format: 'species-coords-id' (default, includes species, coordinates, and region ID if present),'species-coords' (species and coordinates only), or 'species-only' (just the species name).")] = FastaHeaderMode.species_coords,
|
|
1282
|
+
expected_species: Annotated[str, typer.Option("--expected-species", help="Comma-separated species list expected in FASTA output; missing species are filled per block with Ns.")] = "",
|
|
1283
|
+
expected_species_file: Annotated[str, typer.Option("--expected-species-file", help="File with one expected species name per line for FASTA missing-species filling.")] = "",
|
|
1284
|
+
fasta_dedupe: Annotated[FastaDedupeMode, typer.Option("--fasta-dedupe", help="When outputting FASTA, collapse duplicate species per block; 'most-seq' keeps the copy with most non-gap bases.")] = FastaDedupeMode.none,
|
|
1285
|
+
processes: Annotated[int, typer.Option("--processes", "-p", help="Number of parallel processes to use (default: 1)")] = 1,
|
|
1286
|
+
mode: Annotated[FetchMode, typer.Option("--mode", "-m", help="Mode: 'block' to trim by regions, 'scaffold' to extract whole scaffolds (default: block)")] = FetchMode.block,
|
|
1287
|
+
single_output: Annotated[bool, typer.Option("--single-output", hidden=True)] = False,
|
|
1288
|
+
verbose: Annotated[bool, typer.Option("--verbose", help="Print every warning line after each batch completes.")] = False,
|
|
1289
|
+
profile: Annotated[bool, typer.Option("--profile", help="Log aggregate timing breakdowns for internal fetch steps.")] = False,
|
|
1290
|
+
) -> None:
|
|
1291
|
+
args = SimpleNamespace(
|
|
1292
|
+
maf_file=maf_file,
|
|
1293
|
+
index_file=index_file,
|
|
1294
|
+
bed_file=bed_file,
|
|
1295
|
+
basename=basename.value,
|
|
1296
|
+
output=output,
|
|
1297
|
+
fasta=fasta,
|
|
1298
|
+
fasta_header=fasta_header.value,
|
|
1299
|
+
expected_species=expected_species,
|
|
1300
|
+
expected_species_file=expected_species_file,
|
|
1301
|
+
fasta_dedupe=fasta_dedupe.value,
|
|
1302
|
+
processes=processes,
|
|
1303
|
+
mode=mode.value,
|
|
1304
|
+
single_output=single_output,
|
|
1305
|
+
verbose=verbose,
|
|
1306
|
+
profile=profile,
|
|
1307
|
+
)
|
|
1308
|
+
cmdline = "mafutils fetch"
|
|
1309
|
+
if len(sys.argv) > 2:
|
|
1310
|
+
cmdline += " " + " ".join(sys.argv[2:])
|
|
1311
|
+
run_fetch(args, cmdline=cmdline)
|