Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 4 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand Down Expand Up @@ -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`

Expand Down
40 changes: 40 additions & 0 deletions src/candy/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
from __future__ import annotations

import logging
import platform
import subprocess
from dataclasses import dataclass
from pathlib import Path

Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
65 changes: 64 additions & 1 deletion tests/test_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading