Skip to content

Commit 88791a1

Browse files
author
vsc20958
committed
.
1 parent 2493da2 commit 88791a1

1 file changed

Lines changed: 77 additions & 21 deletions

File tree

recmpox/recmpox.py

Lines changed: 77 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,37 @@
4949
NCBI_EFETCH = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi"
5050

5151

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+
5283
def fetch_nucleotide_fasta(accession: str, out_path: Path) -> bool:
5384
"""Fetch nucleotide by NCBI accession; save as FASTA. Returns True on success.
5485
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:
307338
def _run_squirrel(clade: Optional[str], squirrel_in: Path, squirrel_out: Path, expected_aln: Path) -> None:
308339
"""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)."""
309340
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)
310345
if clade == "cladei":
311346
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)]
313348
elif clade == "cladeii":
314349
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)]
316351
else:
317352
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)]
319354
env = os.environ.copy()
320355
env["PYTHONNOUSERSITE"] = "1"
321356
try:
322357
result = subprocess.run(cmd, capture_output=True, text=True, timeout=3600, env=env)
323358
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():
330371
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)
332381
except FileNotFoundError as e:
333382
logger.error("Squirrel not found (install with: conda install -c bioconda squirrel): %s", e)
334383
sys.exit(1)
@@ -1349,20 +1398,22 @@ def _normalize_ref_label(s: str) -> str:
13491398
logger.error("Could not resolve ref2: %s", args.ref2)
13501399
sys.exit(1)
13511400

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("/", "_"))
13561405

13571406
# --- Step 1: Align ref Ia + ref Ib ONLY to find diagnostic SNPs and indels ---
13581407
squirrel_out_refs = work_dir / "squirrel_out_refs"
13591408
squirrel_out_refs.mkdir(parents=True, exist_ok=True)
13601409
squirrel_in_refs = work_dir / "squirrel_input_refs_only.fa"
13611410
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")
13661417
logger.info("Step 1: Built %s (ref Ia + ref Ib only) to find diagnostic sites", squirrel_in_refs)
13671418

13681419
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]:
14351486
squirrel_out_queries = work_dir / "squirrel_out_queries"
14361487
squirrel_out_queries.mkdir(parents=True, exist_ok=True)
14371488
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)
14381491
with open(squirrel_in_queries, "w") as out:
14391492
# 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")
14411497
for i in range(0, len(ref_ia_seq), 80):
14421498
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")
14441500
for i in range(0, len(ref_ib_seq), 80):
14451501
out.write(ref_ib_seq[i : i + 80] + "\n")
1446-
with open(queries_fa) as f:
1502+
with open(queries_fa_sanitized) as f:
14471503
out.write(f.read())
14481504
logger.info("Step 2: Built %s (ref Ia + ref Ib + consensus genomes) to classify at diagnostic positions", squirrel_in_queries)
14491505

0 commit comments

Comments
 (0)