1- """Filter GenBank files by antiSMASH cand_cluster/protocluster product and copy matches."""
1+ #!/usr/bin/env python3
2+ """Filter GenBank files by antiSMASH cand_cluster/protocluster product, EXACT product match, and copy matches."""
23
34from __future__ import annotations
45
56import argparse
67import os
78import re
89import shutil
9- import sys
10- from concurrent .futures import ProcessPoolExecutor
1110from pathlib import Path
1211from typing import Iterable , Sequence
1312
1413from tqdm import tqdm
1514
1615
16+ # Regex to capture all product qualifiers
17+ PRODUCT_RE = re .compile (r'/product="([^"]*)"' , re .IGNORECASE )
18+
19+
1720def _iter_gbk_files (root : Path , exts : Sequence [str ]) -> Iterable [Path ]:
18- for ext in exts :
19- yield from root .rglob (f"*{ ext } " )
21+ """Yield all files with specified extensions."""
22+ exts = {e .lower () for e in exts }
23+ for p in root .rglob ("*" ):
24+ if p .is_file () and p .suffix .lower () in exts :
25+ yield p
2026
2127
2228def _file_matches (
2329 gbk_path : str ,
2430 products : Sequence [str ],
2531) -> dict | None :
2632 """
27- Return match info if the GBK contains cand_cluster/protocluster with desired product.
28- Uses lightweight text search to avoid full parsing overhead .
33+ Return match info if the GBK contains cand_cluster/protocluster and has a product EXACTLY equal
34+ to one of the given search terms (case-insensitive) .
2935 """
3036 path = Path (gbk_path )
37+
3138 try :
3239 text = path .read_text (errors = "ignore" )
33- except Exception as exc : # noqa: BLE001
40+ except Exception as exc :
3441 return {"error" : f"read failed: { exc } " , "file" : gbk_path }
3542
3643 lower = text .lower ()
44+
45+ # quick skip
3746 if "cand_cluster" not in lower and "protocluster" not in lower :
3847 return None
3948
40- matched : set [str ] = set ()
41- for prod in products :
42- pat = re .compile (r'/product="[^"]*' + re .escape (prod .lower ()) + r'[^"]*"' , re .IGNORECASE )
43- if pat .search (text ):
44- matched .add (prod )
49+ # extract all /product="..."`
50+ found_products = PRODUCT_RE .findall (text )
51+ found_products_norm = {fp .strip ().lower () for fp in found_products }
52+
53+ # find exact matches
54+ matched = sorted (fp for fp in found_products_norm if fp in products )
4555
4656 if not matched :
4757 return None
4858
4959 return {
5060 "file" : path .name ,
5161 "path" : str (path ),
52- "matched_products" : sorted ( matched ) ,
62+ "matched_products" : matched ,
5363 }
5464
5565
@@ -62,34 +72,23 @@ def cli() -> argparse.Namespace:
6272 "--products" ,
6373 nargs = "+" ,
6474 required = True ,
65- help = 'Product substrings to match (e.g., "NRPS", "T1PKS")' ,
66- )
67- parser .add_argument (
68- "-w" ,
69- "--workers" ,
70- type = int ,
71- default = os .cpu_count () or 1 ,
72- help = "Number of worker processes" ,
75+ help = 'Exact product terms to match (case-insensitive)' ,
7376 )
7477 parser .add_argument (
7578 "--exts" ,
7679 nargs = "+" ,
7780 default = [".gbk" , ".gbff" , ".gb" ],
7881 help = "File extensions to include" ,
7982 )
80- parser .add_argument (
81- "--chunksize" ,
82- type = int ,
83- default = 16 ,
84- help = "Chunksize for process pool mapping" ,
85- )
8683 return parser .parse_args ()
8784
8885
8986def main () -> None :
9087 args = cli ()
9188 input_dir : Path = args .input_dir
9289 output_dir : Path = args .output_dir
90+
91+ # normalize to lowercase for exact matching
9392 products = [p .lower () for p in args .products ]
9493
9594 if not input_dir .exists ():
@@ -101,38 +100,31 @@ def main() -> None:
101100
102101 output_dir .mkdir (parents = True , exist_ok = True )
103102
104- total = len (files )
105103 copied = 0
106104 errors = 0
107105
108- with ProcessPoolExecutor (max_workers = args .workers ) as executor :
109- with tqdm (total = total , desc = "Scanning GBKs" , unit = "file" ) as pbar :
110- for result in executor .map (
111- _file_matches ,
112- (str (p ) for p in files ),
113- [products ] * len (files ),
114- chunksize = args .chunksize ,
115- ):
116- pbar .update (1 )
117-
118- if not result :
119- continue
120-
121- if isinstance (result , dict ) and "error" in result :
122- errors += 1
123- pbar .set_postfix (copied = copied , errors = errors )
124- continue
125-
126- src_path = Path (result ["path" ])
127- dst_path = output_dir / src_path .name
128- try :
129- shutil .copy2 (src_path , dst_path )
130- copied += 1
131- except Exception as exc : # noqa: BLE001
132- errors += 1
133- result = {"error" : f"copy failed: { exc } " , "file" : str (src_path )}
106+ with tqdm (total = len (files ), desc = "Scanning GBKs" , unit = "file" ) as pbar :
107+ for path in files :
108+ result = _file_matches (str (path ), products )
109+ pbar .update (1 )
110+
111+ if not result :
112+ continue
134113
114+ if "error" in result :
115+ errors += 1
135116 pbar .set_postfix (copied = copied , errors = errors )
117+ continue
118+
119+ try :
120+ shutil .copy2 (result ["path" ], output_dir / result ["file" ])
121+ copied += 1
122+ except Exception as exc :
123+ errors += 1
124+
125+ pbar .set_postfix (copied = copied , errors = errors )
126+
127+ print (f"\n Done. Copied: { copied } , errors: { errors } , total scanned: { len (files )} " )
136128
137129
138130if __name__ == "__main__" :
0 commit comments