88import re
99import shutil
1010from pathlib import Path
11- from typing import Iterable , Sequence
11+ from typing import Sequence
1212
1313from tqdm import tqdm
1414
15-
16- # Regex to capture all product qualifiers
15+ # Regex to capture all product qualifiers on a line
1716PRODUCT_RE = re .compile (r'/product="([^"]*)"' , re .IGNORECASE )
1817
1918
20- def _iter_gbk_files (root : Path , exts : Sequence [str ]) -> Iterable [Path ]:
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
19+ def _iter_gbk_files (root : Path , exts : Sequence [str ]):
20+ """Greedily walk the tree and yield matching files as we see them."""
21+ exts = tuple (ext .lower () for ext in exts )
22+ root = str (root )
23+ for dirpath , _dirnames , filenames in os .walk (root ):
24+ for name in filenames :
25+ # simple suffix match, case-insensitive
26+ lower_name = name .lower ()
27+ if lower_name .endswith (exts ):
28+ yield Path (dirpath ) / name
2629
2730
28- def _file_matches (
29- gbk_path : str ,
30- products : Sequence [str ],
31- ) -> dict | None :
31+ def _file_matches (gbk_path : Path , products : Sequence [str ]) -> dict | None :
3232 """
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).
33+ Return match info if the GBK contains cand_cluster/protocluster and has a product
34+ EXACTLY equal to one of the given search terms (case-insensitive).
35+ Stream + early exit.
3536 """
36- path = Path (gbk_path )
37+ has_cluster = False
38+ matched : set [str ] = set ()
3739
3840 try :
39- text = path .read_text (errors = "ignore" )
40- except Exception as exc :
41- return {"error" : f"read failed: { exc } " , "file" : gbk_path }
42-
43- lower = text .lower ()
44-
45- # quick skip
46- if "cand_cluster" not in lower and "protocluster" not in lower :
47- return None
48-
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 )
41+ with gbk_path .open ("r" , errors = "ignore" ) as fh :
42+ for line in fh :
43+ lower = line .lower ()
44+
45+ # detect cluster keywords
46+ if not has_cluster and ("cand_cluster" in lower or "protocluster" in lower ):
47+ has_cluster = True
48+ if matched :
49+ return {
50+ "file" : gbk_path .name ,
51+ "path" : str (gbk_path ),
52+ "matched_products" : sorted (matched ),
53+ }
54+
55+ # detect product qualifiers
56+ if '/product="' in line :
57+ for m in PRODUCT_RE .finditer (line ):
58+ prod_val = m .group (1 ).strip ().lower ()
59+ if prod_val in products :
60+ matched .add (prod_val )
61+ if has_cluster :
62+ return {
63+ "file" : gbk_path .name ,
64+ "path" : str (gbk_path ),
65+ "matched_products" : sorted (matched ),
66+ }
67+
68+ if has_cluster and matched :
69+ return {
70+ "file" : gbk_path .name ,
71+ "path" : str (gbk_path ),
72+ "matched_products" : sorted (matched ),
73+ }
5574
56- if not matched :
5775 return None
5876
59- return {
60- "file" : path .name ,
61- "path" : str (path ),
62- "matched_products" : matched ,
63- }
77+ except Exception as exc :
78+ return {"error" : f"read failed: { exc } " , "file" : str (gbk_path ), "detail" : str (exc )}
6479
6580
6681def cli () -> argparse .Namespace :
@@ -72,7 +87,7 @@ def cli() -> argparse.Namespace:
7287 "--products" ,
7388 nargs = "+" ,
7489 required = True ,
75- help = ' Exact product terms to match (case-insensitive)' ,
90+ help = " Exact product terms to match (case-insensitive)" ,
7691 )
7792 parser .add_argument (
7893 "--exts" ,
@@ -88,43 +103,40 @@ def main() -> None:
88103 input_dir : Path = args .input_dir
89104 output_dir : Path = args .output_dir
90105
91- # normalize to lowercase for exact matching
92106 products = [p .lower () for p in args .products ]
93107
94108 if not input_dir .exists ():
95109 raise SystemExit (f"Input directory does not exist: { input_dir } " )
96110
97- files = list (_iter_gbk_files (input_dir , args .exts ))
98- if not files :
99- raise SystemExit ("No GenBank files found." )
100-
101111 output_dir .mkdir (parents = True , exist_ok = True )
102112
103113 copied = 0
104114 errors = 0
115+ scanned = 0
116+
117+ # No total: just chew through files as we discover them
118+ with tqdm (desc = "Scanning GBKs" , unit = "file" ) as pbar :
119+ for path in _iter_gbk_files (input_dir , args .exts ):
120+ scanned += 1
121+ result = _file_matches (path , products )
122+
123+ if result :
124+ if "error" in result :
125+ errors += 1
126+ else :
127+ try :
128+ shutil .copy2 (result ["path" ], output_dir / result ["file" ])
129+ copied += 1
130+ except Exception :
131+ errors += 1
105132
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 )
109133 pbar .update (1 )
110-
111- if not result :
112- continue
113-
114- if "error" in result :
115- errors += 1
116- 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-
125134 pbar .set_postfix (copied = copied , errors = errors )
126135
127- print (f"\n Done. Copied: { copied } , errors: { errors } , total scanned: { len (files )} " )
136+ if scanned == 0 :
137+ print ("No GenBank files found with the given extensions." )
138+ else :
139+ print (f"\n Done. Copied: { copied } , errors: { errors } , total scanned: { scanned } " )
128140
129141
130142if __name__ == "__main__" :
0 commit comments