|
49 | 49 | NCBI_EFETCH = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi" |
50 | 50 |
|
51 | 51 |
|
| 52 | +def _safe_fasta_id(raw_id: str) -> str: |
| 53 | + """ |
| 54 | + Make a FASTA ID safe for external tools (notably Squirrel), which rejects |
| 55 | + some special characters (e.g. ':'). Keep only [A-Za-z0-9_.-], replace the |
| 56 | + rest with '_' and collapse repeats. |
| 57 | + """ |
| 58 | + s = (raw_id or "").strip() |
| 59 | + # Prefer the first token (drop long NCBI descriptions) |
| 60 | + s = s.split()[0] if s else "seq" |
| 61 | + s = re.sub(r"[^A-Za-z0-9_.-]+", "_", s) |
| 62 | + s = re.sub(r"_+", "_", s).strip("_") |
| 63 | + return s or "seq" |
| 64 | + |
| 65 | + |
| 66 | +def _sanitize_fasta_ids(in_path: Path, out_path: Path) -> None: |
| 67 | + """Rewrite FASTA with safe IDs; keep sequences unchanged.""" |
| 68 | + seen: Dict[str, int] = {} |
| 69 | + out_path.parent.mkdir(parents=True, exist_ok=True) |
| 70 | + with open(in_path) as inp, open(out_path, "w") as out: |
| 71 | + for line in inp: |
| 72 | + if line.startswith(">"): |
| 73 | + raw = line[1:].strip() |
| 74 | + base = _safe_fasta_id(raw) |
| 75 | + n = seen.get(base, 0) + 1 |
| 76 | + seen[base] = n |
| 77 | + safe = base if n == 1 else f"{base}_{n}" |
| 78 | + out.write(f">{safe}\n") |
| 79 | + else: |
| 80 | + out.write(line) |
| 81 | + |
| 82 | + |
52 | 83 | def fetch_nucleotide_fasta(accession: str, out_path: Path) -> bool: |
53 | 84 | """Fetch nucleotide by NCBI accession; save as FASTA. Returns True on success. |
54 | 85 | SSL certificate verification is disabled to work in restricted networks (proxy/firewall). |
@@ -307,28 +338,46 @@ def concatenate_fasta_dir(input_dir: Path, out_path: Path) -> Path: |
307 | 338 | def _run_squirrel(clade: Optional[str], squirrel_in: Path, squirrel_out: Path, expected_aln: Path) -> None: |
308 | 339 | """Run Squirrel on squirrel_in, output to squirrel_out; expect expected_aln to exist afterward. If clade is None, run Squirrel without --clade (mixed I/II).""" |
309 | 340 | if not expected_aln.exists(): |
| 341 | + # Put Squirrel temp workdir on the same filesystem as outputs to avoid |
| 342 | + # Snakemake mtime/clock-skew issues on some shared filesystems. |
| 343 | + tempdir = squirrel_out / "tmp" |
| 344 | + tempdir.mkdir(parents=True, exist_ok=True) |
310 | 345 | if clade == "cladei": |
311 | 346 | logger.info("Running Squirrel (--clade cladei) to align...") |
312 | | - cmd = ["squirrel", "--clade", "cladei", str(squirrel_in), "-o", str(squirrel_out)] |
| 347 | + cmd = ["squirrel", "--clade", "cladei", str(squirrel_in), "-o", str(squirrel_out), "--tempdir", str(tempdir)] |
313 | 348 | elif clade == "cladeii": |
314 | 349 | logger.info("Running Squirrel (Clade II default) to align...") |
315 | | - cmd = ["squirrel", str(squirrel_in), "-o", str(squirrel_out)] |
| 350 | + cmd = ["squirrel", str(squirrel_in), "-o", str(squirrel_out), "--tempdir", str(tempdir)] |
316 | 351 | else: |
317 | 352 | logger.info("Running Squirrel (no --clade; mixed or default reference) to align...") |
318 | | - cmd = ["squirrel", str(squirrel_in), "-o", str(squirrel_out)] |
| 353 | + cmd = ["squirrel", str(squirrel_in), "-o", str(squirrel_out), "--tempdir", str(tempdir)] |
319 | 354 | env = os.environ.copy() |
320 | 355 | env["PYTHONNOUSERSITE"] = "1" |
321 | 356 | try: |
322 | 357 | result = subprocess.run(cmd, capture_output=True, text=True, timeout=3600, env=env) |
323 | 358 | if result.returncode != 0: |
324 | | - logger.error("Squirrel failed (exit %s). Install with: conda install -c bioconda squirrel", result.returncode) |
325 | | - if result.stderr: |
326 | | - for line in result.stderr.strip().splitlines(): |
327 | | - logger.error("Squirrel stderr: %s", line) |
328 | | - if result.stdout: |
329 | | - for line in result.stdout.strip().splitlines(): |
| 359 | + # Squirrel uses Snakemake; on some shared filesystems you can get a non-zero exit due to |
| 360 | + # "clock skew" / coarse timestamp resolution even though the expected alignment file was |
| 361 | + # successfully written. If the expected output exists and stderr indicates this case, |
| 362 | + # treat it as a warning and continue. |
| 363 | + stderr_txt = (result.stderr or "").strip() |
| 364 | + stdout_txt = (result.stdout or "").strip() |
| 365 | + is_clock_skew = ("clock skew" in stderr_txt.lower()) or ("older modification time" in stderr_txt.lower()) |
| 366 | + if expected_aln.exists() and expected_aln.stat().st_size > 0 and is_clock_skew: |
| 367 | + logger.warning("Squirrel reported a filesystem mtime/clock-skew error (exit %s) but produced %s; continuing.", result.returncode, expected_aln) |
| 368 | + for line in stderr_txt.splitlines(): |
| 369 | + logger.warning("Squirrel stderr: %s", line) |
| 370 | + for line in stdout_txt.splitlines(): |
330 | 371 | logger.info("Squirrel stdout: %s", line) |
331 | | - sys.exit(1) |
| 372 | + else: |
| 373 | + logger.error("Squirrel failed (exit %s).", result.returncode) |
| 374 | + if stderr_txt: |
| 375 | + for line in stderr_txt.splitlines(): |
| 376 | + logger.error("Squirrel stderr: %s", line) |
| 377 | + if stdout_txt: |
| 378 | + for line in stdout_txt.splitlines(): |
| 379 | + logger.info("Squirrel stdout: %s", line) |
| 380 | + sys.exit(1) |
332 | 381 | except FileNotFoundError as e: |
333 | 382 | logger.error("Squirrel not found (install with: conda install -c bioconda squirrel): %s", e) |
334 | 383 | sys.exit(1) |
@@ -1349,20 +1398,22 @@ def _normalize_ref_label(s: str) -> str: |
1349 | 1398 | logger.error("Could not resolve ref2: %s", args.ref2) |
1350 | 1399 | sys.exit(1) |
1351 | 1400 |
|
1352 | | - ref_ia_id = load_ref_sequence(ref_ia_path)[0] |
1353 | | - ref_ib_id = load_ref_sequence(ref_ib_path)[0] |
1354 | | - ref_ia_key = ref_ia_id.replace("/", "_") |
1355 | | - ref_ib_key = ref_ib_id.replace("/", "_") |
| 1401 | + ref_ia_id, ref_ia_unaligned = load_ref_sequence(ref_ia_path) |
| 1402 | + ref_ib_id, ref_ib_unaligned = load_ref_sequence(ref_ib_path) |
| 1403 | + ref_ia_key = _safe_fasta_id(ref_ia_id.replace("/", "_")) |
| 1404 | + ref_ib_key = _safe_fasta_id(ref_ib_id.replace("/", "_")) |
1356 | 1405 |
|
1357 | 1406 | # --- Step 1: Align ref Ia + ref Ib ONLY to find diagnostic SNPs and indels --- |
1358 | 1407 | squirrel_out_refs = work_dir / "squirrel_out_refs" |
1359 | 1408 | squirrel_out_refs.mkdir(parents=True, exist_ok=True) |
1360 | 1409 | squirrel_in_refs = work_dir / "squirrel_input_refs_only.fa" |
1361 | 1410 | with open(squirrel_in_refs, "w") as out: |
1362 | | - with open(ref_ia_path) as f: |
1363 | | - out.write(f.read()) |
1364 | | - with open(ref_ib_path) as f: |
1365 | | - out.write(f.read()) |
| 1411 | + out.write(f">{ref_ia_key}\n") |
| 1412 | + for i in range(0, len(ref_ia_unaligned), 80): |
| 1413 | + out.write(ref_ia_unaligned[i : i + 80] + "\n") |
| 1414 | + out.write(f">{ref_ib_key}\n") |
| 1415 | + for i in range(0, len(ref_ib_unaligned), 80): |
| 1416 | + out.write(ref_ib_unaligned[i : i + 80] + "\n") |
1366 | 1417 | logger.info("Step 1: Built %s (ref Ia + ref Ib only) to find diagnostic sites", squirrel_in_refs) |
1367 | 1418 |
|
1368 | 1419 | aln_refs_stem = squirrel_in_refs.stem + ".aln.fasta" |
@@ -1435,15 +1486,20 @@ def find_ref_key(ref_key: str, keys: List[str]) -> Optional[str]: |
1435 | 1486 | squirrel_out_queries = work_dir / "squirrel_out_queries" |
1436 | 1487 | squirrel_out_queries.mkdir(parents=True, exist_ok=True) |
1437 | 1488 | squirrel_in_queries = work_dir / "squirrel_input_ref_and_queries.fa" |
| 1489 | + queries_fa_sanitized = work_dir / "queries_sanitized.fa" |
| 1490 | + _sanitize_fasta_ids(Path(queries_fa), queries_fa_sanitized) |
1438 | 1491 | with open(squirrel_in_queries, "w") as out: |
1439 | 1492 | # Use aligned refs from step 1 (ref_ia_seq, ref_ib_seq) so step 2 alignment has same coordinates |
1440 | | - out.write(f">{ref_ia_aln_key}\n") |
| 1493 | + # IMPORTANT: Squirrel is strict about FASTA IDs (no special characters like ':'). |
| 1494 | + # The step-1 alignment keys can include the full NCBI description (spaces->underscores), |
| 1495 | + # which may contain ':' and break Squirrel. Use safe, short IDs here. |
| 1496 | + out.write(f">{ref_ia_key}\n") |
1441 | 1497 | for i in range(0, len(ref_ia_seq), 80): |
1442 | 1498 | out.write(ref_ia_seq[i : i + 80] + "\n") |
1443 | | - out.write(f">{ref_ib_aln_key}\n") |
| 1499 | + out.write(f">{ref_ib_key}\n") |
1444 | 1500 | for i in range(0, len(ref_ib_seq), 80): |
1445 | 1501 | out.write(ref_ib_seq[i : i + 80] + "\n") |
1446 | | - with open(queries_fa) as f: |
| 1502 | + with open(queries_fa_sanitized) as f: |
1447 | 1503 | out.write(f.read()) |
1448 | 1504 | logger.info("Step 2: Built %s (ref Ia + ref Ib + consensus genomes) to classify at diagnostic positions", squirrel_in_queries) |
1449 | 1505 |
|
|
0 commit comments