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