diff --git a/README.md b/README.md index 318013a..da94e27 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ That's it for most users -- CANDy's default toolchain is fully bundled: - **MSA**: [FAMSA](https://github.com/refresh-bio/FAMSA) via [`pyfamsa`](https://github.com/althonos/pyfamsa) -- a real pip dependency, runs in-process, no download needed. - **Phylogenetics**: [VeryFastTree](https://github.com/citiususc/veryfasttree) via [`veryfasttree`](https://github.com/citiususc/veryfasttree-python) -- also a real pip dependency, no download needed. + **Apple Silicon (M1/M2/M3/M4) users:** make sure you're using a native `arm64` Python (e.g. Homebrew installed at `/opt/homebrew`, not an older Intel-only install at `/usr/local`). FAMSA and VeryFastTree ship native SIMD code, and running an x86_64 Python under Rosetta 2 translation is a known cause of a silent `illegal hardware instruction` crash during `--tree`. CANDy detects this at startup and logs a warning, but installing natively avoids it entirely. + If you'd rather use the original CD-HIT/MAFFT/FastTree tools instead (e.g. to reproduce results bit-for-bit against the published notebook), a conda environment with those is still provided: ```bash @@ -24,11 +26,7 @@ conda activate candy ``` and pass `--clustering-software cd-hit` / build a `PipelineConfig` with `alignment_tool="mafft"`, `tree_tool="fasttree"`. -To also enable automated Gemini-based domain-name curation: - -```bash -pip install -e ".[gemini]" -``` +To also enable automated Gemini-based domain-name curation, see [Domain-name curation](#domain-name-curation) below. If you'd rather not have CANDy download anything automatically (e.g. air-gapped environments), set `CANDY_NO_AUTO_DOWNLOAD=1` -- clustering will then require `mmseqs`/`cd-hit` already on PATH. @@ -64,7 +62,7 @@ It repeats this until every domain is grouped; type `STOP` at the `Domain name:` To skip this entirely, use Gemini to curate automatically instead: -1. Install the extra: `pip install -e ".[gemini]"` +1. Install the extra: `pip install "candy-cazyme[gemini]"` (the quotes matter in zsh, macOS's default shell -- without them, `[gemini]` is parsed as a glob pattern) 2. Get a free API key at [aistudio.google.com/app/api-keys](https://aistudio.google.com/app/api-keys) 3. Run with `--curation-backend gemini --curation-api-key YOUR_KEY`, or set it once via `$env:GOOGLE_API_KEY="YOUR_KEY"` (PowerShell) / `export GOOGLE_API_KEY=YOUR_KEY` (bash) and just pass `--curation-backend gemini` diff --git a/src/candy/pipeline.py b/src/candy/pipeline.py index a4e94b4..6695df9 100644 --- a/src/candy/pipeline.py +++ b/src/candy/pipeline.py @@ -17,6 +17,8 @@ from __future__ import annotations import logging +import platform +import subprocess from dataclasses import dataclass from pathlib import Path @@ -39,6 +41,42 @@ "doi: 10.1371/journal.pone.0306410. PMID: 38990885; PMCID: PMC11238990." ) +_ROSETTA_WARNING = ( + "This Python is running under Rosetta 2 translation (x86_64 on Apple Silicon " + "hardware). CANDy's bundled FAMSA and VeryFastTree backends ship native SIMD " + "code, and Rosetta's emulation of some CPU instructions is a known cause of a " + "silent 'illegal hardware instruction' crash partway through --tree (no Python " + "traceback, since the process dies below the interpreter). If that happens, " + "install a native arm64 Python (e.g. via Homebrew at /opt/homebrew, not " + "/usr/local) and reinstall CANDy there." +) + + +def _warn_if_running_under_rosetta() -> None: + """Detect x86_64 Python running via Rosetta 2 translation on Apple Silicon. + + Surfaced by a real run: FAMSA's alignment step crashed with a SIGILL and + no Python traceback at all (just the shell's own "illegal hardware + instruction" message), on a Mac whose Python was an Intel-prefix + (/usr/local) Homebrew build -- the classic setup for an x86_64 Python + still installed from before an Apple Silicon upgrade, now running under + Rosetta. Warning about this *before* the (potentially long) clustering + and curation stages run is much more useful than the crash itself. + """ + if platform.system() != "Darwin" or platform.machine() != "x86_64": + return + try: + translated = subprocess.run( + ["sysctl", "-n", "sysctl.proc_translated"], + capture_output=True, + text=True, + timeout=5, + ) + except (OSError, subprocess.SubprocessError): + return + if translated.stdout.strip() == "1": + logger.warning(_ROSETTA_WARNING) + @dataclass class PipelineResult: @@ -91,6 +129,8 @@ def _unique_jobname(output_dir: Path, jobname: str) -> str: def run_pipeline(config: PipelineConfig) -> PipelineResult: print(f"\n{CITATION}\n") + if config.build_tree: + _warn_if_running_under_rosetta() config.jobname = _unique_jobname(config.output_dir, config.jobname) jobname_dir = config.output_dir / config.jobname jobname_dir.mkdir(parents=True, exist_ok=True) diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 7d8dc8f..6d2b42d 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -8,12 +8,75 @@ """ import xml.etree.ElementTree as ET +from types import SimpleNamespace from unittest.mock import patch import pandas as pd from candy.config import CAZyFamilyInput, ClusteringConfig, CustomFastaInput, PipelineConfig, Taxonomy -from candy.pipeline import run_pipeline +from candy.pipeline import _warn_if_running_under_rosetta, run_pipeline + + +def test_warn_if_running_under_rosetta_warns_when_translated(caplog): + with patch("candy.pipeline.platform.system", return_value="Darwin"), patch( + "candy.pipeline.platform.machine", return_value="x86_64" + ), patch( + "candy.pipeline.subprocess.run", + return_value=SimpleNamespace(stdout="1\n"), + ): + with caplog.at_level("WARNING"): + _warn_if_running_under_rosetta() + + assert any("Rosetta" in record.message for record in caplog.records) + + +def test_warn_if_running_under_rosetta_silent_when_native_arm64(caplog): + with patch("candy.pipeline.platform.system", return_value="Darwin"), patch( + "candy.pipeline.platform.machine", return_value="arm64" + ), patch("candy.pipeline.subprocess.run") as mock_run: + with caplog.at_level("WARNING"): + _warn_if_running_under_rosetta() + + mock_run.assert_not_called() + assert caplog.records == [] + + +def test_warn_if_running_under_rosetta_silent_on_genuine_intel_mac(caplog): + # On a real Intel Mac, `sysctl -n sysctl.proc_translated` reports "0" + # (the key exists but the process isn't translated). + with patch("candy.pipeline.platform.system", return_value="Darwin"), patch( + "candy.pipeline.platform.machine", return_value="x86_64" + ), patch( + "candy.pipeline.subprocess.run", + return_value=SimpleNamespace(stdout="0\n"), + ): + with caplog.at_level("WARNING"): + _warn_if_running_under_rosetta() + + assert caplog.records == [] + + +def test_warn_if_running_under_rosetta_silent_on_non_macos(caplog): + with patch("candy.pipeline.platform.system", return_value="Windows"), patch( + "candy.pipeline.subprocess.run" + ) as mock_run: + with caplog.at_level("WARNING"): + _warn_if_running_under_rosetta() + + mock_run.assert_not_called() + assert caplog.records == [] + + +def test_warn_if_running_under_rosetta_tolerates_missing_sysctl(caplog): + # Defensive: sysctl.proc_translated not existing (or sysctl missing + # entirely) must never crash the pipeline over a best-effort warning. + with patch("candy.pipeline.platform.system", return_value="Darwin"), patch( + "candy.pipeline.platform.machine", return_value="x86_64" + ), patch("candy.pipeline.subprocess.run", side_effect=OSError("no such command")): + with caplog.at_level("WARNING"): + _warn_if_running_under_rosetta() # must not raise + + assert caplog.records == [] class StubCurationBackend: