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
58 changes: 39 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,38 +14,50 @@ That's it for most users -- CANDy's default toolchain is fully bundled:

- **Clustering**: [MMseqs2](https://github.com/soedinglab/MMseqs2) -- auto-downloaded and cached on first use (no conda needed). On Linux/macOS this just works. On **Windows**, MMseqs2's clustering workflows internally need a POSIX shell; the official Windows build handles this itself by installing a small helper (`busybox`) the first time it runs, which may ask for administrator permission **once** -- never again after that. (This mirrors upstream: MMseqs2's own docs list WSL as the recommended Windows path and this static build as the fallback for anyone who can't use WSL.)
- **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.
- **Phylogenetics**: [VeryFastTree](https://github.com/citiususc/veryfasttree) via [`veryfasttree`](https://github.com/citiususc/veryfasttree-python) -- also a real pip dependency, no download needed. **Except on Apple Silicon Macs**: `veryfasttree` has no `macOS arm64` wheel at all (as of 4.0.4.1) and its from-source build fails on stock macOS (an upstream OpenMP-detection bug). CANDy detects this automatically and defaults `--tree-tool` to [FastTree](http://www.microbesonline.org/fasttree/) instead there -- see below.

### Apple Silicon (M1/M2/M3/M4) setup

FAMSA and VeryFastTree ship native SIMD code. If your Python is x86_64 running under Rosetta 2 translation instead of native `arm64`, that's a known cause of a silent `illegal hardware instruction` crash during `--tree`, and can also make other native calls (e.g. the Gemini curation backend) unreliable. CANDy detects this at startup and logs a warning, but it's worth fixing properly rather than working around it -- **pip can't do this for you**: by the time `pip install` runs, the interpreter architecture is already fixed, so no package (including this one) can retroactively fix it. Two ways to get a correct native setup:
There are two distinct, independent issues here -- you may hit either, both, or neither depending on your setup:

**Option A -- [`uv`](https://docs.astral.sh/uv/) (recommended, one-time setup):** `uv` manages isolated Python installs and always defaults to the native architecture.
**1. `--tree` crashes with `illegal hardware instruction`, no traceback.** This means your Python itself is x86_64 running under Rosetta 2 translation instead of native `arm64` -- FAMSA ships native SIMD code, and Rosetta's emulation of some CPU instructions is a known cause of exactly this crash (it can also make other native calls, e.g. Gemini curation, unreliable). CANDy logs a warning about this at startup if detected, but **pip can't fix it for you**: by the time `pip install` runs, the interpreter architecture is already fixed.

**Step 0, always do this first:** confirm your *terminal itself* is native, not just your hardware:

```bash
# only run the first line if you don't already have uv
curl -LsSf https://astral.sh/uv/install.sh | sh
uv tool install candy-cazyme
arch # must print "arm64", not "i386"
```

`uv tool install` gives you a `candy` command backed by its own isolated, native-arm64 Python -- no venv/PATH management needed. (Skip straight to Option B if `arch` below still reports `i386` after this -- see the Terminal note.)
If it prints `i386`, your terminal app (Terminal/iTerm) is launching under Rosetta -- and anything installed from it, including tools like `uv` that are supposed to auto-detect the native architecture, will get fooled into installing x86_64 builds too. Fix this first: quit the app, Finder > select it > `Cmd+I` > uncheck "Open using Rosetta" > relaunch, then re-run `arch` to confirm.

**Option B -- Homebrew, manually:**
Once `arch` says `arm64`, two ways to get a correct native Python:

**Option A -- [`uv`](https://docs.astral.sh/uv/) (recommended):** `uv` manages isolated Python installs and defaults to the native architecture -- *once its own install wasn't done under a translated shell* (see Step 0).

```bash
curl -LsSf https://astral.sh/uv/install.sh | sh # only if you don't already have uv
uv python install 3.12
uv tool install --python 3.12 "candy-cazyme[gemini]"
```

If you already ran `uv tool install` before fixing Step 0, it will have cached an x86_64 environment -- force it to redo the install natively:

```bash
# 1. Confirm your shell itself is running natively (should print "arm64"):
arch
uv tool uninstall candy-cazyme
uv tool install --force --python 3.12 "candy-cazyme[gemini]"
```

This gives you a `candy` command backed by its own isolated, native-arm64 Python -- no venv/PATH management needed.

# 2. If it printed "i386", your terminal app is running under Rosetta --
# quit it, then in Finder: select the app (Terminal/iTerm) > Cmd+I >
# uncheck "Open using Rosetta" > relaunch it, and re-run `arch`.
**Option B -- Homebrew, manually:**

# 3. Install (or confirm) Homebrew at the Apple Silicon prefix, /opt/homebrew
# (a pre-existing Homebrew at /usr/local is the Intel-only one):
```bash
# Install (or confirm) Homebrew at the Apple Silicon prefix, /opt/homebrew
# (a pre-existing Homebrew at /usr/local is the Intel-only one):
/opt/homebrew/bin/brew --version || arch -arm64 /bin/bash -c \
"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

# 4. Install Python from that prefix and use it explicitly:
# Install Python from that prefix and use it explicitly:
/opt/homebrew/bin/brew install python@3.12
/opt/homebrew/bin/python3.12 -m venv .venv
source .venv/bin/activate
Expand All @@ -58,13 +70,17 @@ Either way, verify before running a real job:
python3 -c "import platform; print(platform.machine())" # should print "arm64", not "x86_64"
```

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:
**2. `--tree` fails to install or build `veryfasttree` (e.g. a CMake/OpenMP compiler error).** This is unrelated to Rosetta -- `veryfasttree` simply has no `macOS arm64` wheel at all, for any Python version, so it always falls back to a from-source build there, and that build fails on stock macOS due to an upstream bug (`find_package(OpenMP)` fails, since Apple's Clang has no OpenMP support out of the box, and `veryfasttree`'s CMake fallback for that case is itself broken). **You shouldn't normally hit this**: CANDy detects a Mac without a working native `veryfasttree` build and automatically defaults `--tree-tool` to `fasttree` instead, which *does* have a real `arm64` conda-forge/bioconda build (no compiling anything). That needs the bundled conda environment (a real, one-time dependency for this one platform):

```bash
conda env create -f environment.yml # only needed for CD-HIT as a clustering alternative to MMseqs2
conda env create -f environment.yml
conda activate candy
candy GH173 --email you@example.com --tree # --tree-tool defaults to fasttree here automatically
```
and pass `--clustering-software cd-hit` / build a `PipelineConfig` with `alignment_tool="mafft"`, `tree_tool="fasttree"`.

If you'd rather force `veryfasttree` anyway (e.g. you've solved the OpenMP build issue yourself), pass `--tree-tool veryfasttree` explicitly.

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), `environment.yml` provides CD-HIT and FastTree (`conda env create -f environment.yml && conda activate candy`, then `--clustering-software cd-hit --tree-tool fasttree`); MAFFT isn't included there (no `osx-arm64` build -- see above) and needs a separate install, e.g. `brew install mafft` on Intel Mac/Linux, then `--alignment-tool mafft`.

To also enable automated Gemini-based domain-name curation, see [Domain-name curation](#domain-name-curation) below.

Expand All @@ -87,6 +103,10 @@ candy GH173 --email you@example.com --db-preference PFAM,SMART

# Analyse your own FASTA file instead
candy my_sequences.fasta --tree

# Pick a specific MSA/phylogenetics backend explicitly (see Installation for
# when you'd want to -- e.g. --tree-tool fasttree needs the conda environment)
candy GH173 --email you@example.com --tree --alignment-tool mafft --tree-tool fasttree
```

`--email` falls back to the `CANDY_EMAIL` environment variable, then an interactive prompt, so `export CANDY_EMAIL=you@example.com` once and just run `candy GH173` from then on. Run `candy --help` for the full list of options.
Expand Down
19 changes: 15 additions & 4 deletions environment.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
# Optional. CANDy's default toolchain (MMseqs2 for clustering, FAMSA for MSA,
# VeryFastTree for phylogenetics) ships via `pip install candy-cazyme` alone --
# MSA/phylogenetics are bundled Python packages, and MMseqs2 is auto-downloaded
# on first use. This environment is only needed if you specifically want
# CD-HIT as the clustering backend instead of MMseqs2: CD-HIT has no official
# Windows build and no pip-installable bindings, so (unlike MMseqs2) it can't
# be auto-downloaded and must come from conda/bioconda.
# on first use. This environment provides the non-default alternatives that
# have no pip-installable bindings:
# - cd-hit: clustering alternative to MMseqs2 (--clustering-software cd-hit).
# No official Windows build and no pip bindings at all.
# - fasttree: phylogenetics alternative to VeryFastTree (--tree-tool fasttree).
# This is also CANDy's *default* on a Mac where VeryFastTree has
# no working native build (Apple Silicon, or an x86_64 Python
# under Rosetta translation) -- see the README's Apple Silicon
# setup section.
# MAFFT (the alternative to FAMSA, --alignment-tool mafft) is deliberately
# NOT included here: bioconda has no osx-arm64 build for it, which would
# break `conda env create` on Apple Silicon entirely -- exactly the platform
# that needs this file for fasttree. Install MAFFT separately if you want it
# (e.g. `brew install mafft` on Intel Mac/Linux, or see mafft.cbrc.jp).
name: candy
channels:
- bioconda
Expand All @@ -13,5 +23,6 @@ dependencies:
- python>=3.10
- pip
- cd-hit
- fasttree
- pip:
- -e .
9 changes: 8 additions & 1 deletion src/candy/alignment/mafft.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,14 @@ class MafftAligner:
name = "mafft"

def align(self, input_fasta: Path, output_fasta: Path) -> Path:
binary = require_binary("mafft")
binary = require_binary(
"mafft",
hint=(
"Install it separately and ensure it's on PATH (e.g. `brew install mafft` on "
"Intel Mac/Linux, or see https://mafft.cbrc.jp/alignment/software/) -- it isn't "
"in CANDy's conda environment.yml, which has no macOS arm64 build for it."
),
)
logger.info("Aligning %s with MAFFT.", input_fasta)
run_tool([binary, str(input_fasta)], stdout_path=output_fasta)
return output_fasta
19 changes: 19 additions & 0 deletions src/candy/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,18 @@ def main(
+ ", ".join(DEFAULT_DATABASE_PREFERENCE) + ".",
),
build_tree: bool = typer.Option(False, "--tree/--no-tree", help="Run MSA + phylogenetics + iTOL export."),
alignment_tool: Optional[str] = typer.Option(
None,
help="MSA backend: 'famsa' (default; bundled, no setup needed) or 'mafft' (must be installed "
"separately and be on PATH -- not available via CANDy's conda environment.yml, which has no "
"macOS arm64 build for it; see README).",
),
tree_tool: Optional[str] = typer.Option(
None,
help="Phylogenetics backend: 'veryfasttree' or 'fasttree'. Defaults to 'veryfasttree', except "
"on a Mac where it has no working native build (Apple Silicon, or Rosetta translation), where "
"it defaults to 'fasttree' instead (needs the conda environment; see README).",
),
curation_backend: str = typer.Option(
"manual", help="Domain-name curation backend: 'manual' or 'gemini'."
),
Expand Down Expand Up @@ -145,6 +157,12 @@ def main(
else:
database_preference = list(DEFAULT_DATABASE_PREFERENCE)

tool_kwargs = {}
if alignment_tool:
tool_kwargs["alignment_tool"] = alignment_tool
if tree_tool:
tool_kwargs["tree_tool"] = tree_tool

config = PipelineConfig(
input=pipeline_input,
jobname=_sanitize_jobname(jobname or default_jobname),
Expand All @@ -158,6 +176,7 @@ def main(
curation=CurationConfig(backend=curation_backend, api_key=curation_api_key, model=curation_model),
blast_identity_threshold=blast_identity,
build_tree=build_tree,
**tool_kwargs,
)

try:
Expand Down
15 changes: 14 additions & 1 deletion src/candy/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,19 @@
from enum import Enum
from pathlib import Path

from candy.platform_utils import is_macos_without_native_veryfasttree_wheel


def default_tree_tool() -> str:
"""'fasttree' on a Mac where veryfasttree has no reliable wheel, else 'veryfasttree'.

fasttree itself needs the conda environment (see README), but that's a
real, one-time, clearly-reported dependency -- preferable to defaulting
everyone on the affected Macs into a from-source build that's known to
fail, or a translated binary that's known to crash.
"""
return "fasttree" if is_macos_without_native_veryfasttree_wheel() else "veryfasttree"


class Taxonomy(str, Enum):
ALL = "All"
Expand Down Expand Up @@ -167,7 +180,7 @@ class PipelineConfig:

build_tree: bool = False
alignment_tool: str = "famsa"
tree_tool: str = "veryfasttree"
tree_tool: str = field(default_factory=default_tree_tool)

def __post_init__(self) -> None:
self.output_dir = Path(self.output_dir)
Expand Down
18 changes: 3 additions & 15 deletions src/candy/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,6 @@
from __future__ import annotations

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

Expand All @@ -31,6 +29,7 @@
from candy.config import CAZyFamilyInput, CustomFastaInput, PipelineConfig, Taxonomy
from candy.curation import get_curation_backend
from candy.phylogenetics import get_tree_builder
from candy.platform_utils import is_apple_silicon_under_rosetta

logger = logging.getLogger(__name__)

Expand All @@ -54,7 +53,7 @@


def _warn_if_running_under_rosetta() -> None:
"""Detect x86_64 Python running via Rosetta 2 translation on Apple Silicon.
"""Warn about 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
Expand All @@ -64,18 +63,7 @@ def _warn_if_running_under_rosetta() -> None:
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":
if is_apple_silicon_under_rosetta():
logger.warning(_ROSETTA_WARNING)


Expand Down
47 changes: 47 additions & 0 deletions src/candy/platform_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""OS/CPU-architecture detection helpers.

Split out on its own so both ``pipeline.py`` (which warns about a translated
interpreter) and ``config.py`` (which picks a platform-appropriate default
tree-building backend) can share the same detection logic without a
circular import between those two modules.
"""

from __future__ import annotations

import platform
import subprocess


def is_apple_silicon_under_rosetta() -> bool:
"""True if this is x86_64 Python running via Rosetta 2 translation on Apple Silicon."""
if platform.system() != "Darwin" or platform.machine() != "x86_64":
return False
try:
translated = subprocess.run(
["sysctl", "-n", "sysctl.proc_translated"],
capture_output=True,
text=True,
timeout=5,
)
except (OSError, subprocess.SubprocessError):
return False
return translated.stdout.strip() == "1"


def is_macos_without_native_veryfasttree_wheel() -> bool:
"""True if ``veryfasttree`` has no reliable prebuilt wheel on this Mac.

``veryfasttree`` (as of 4.0.4.1) only publishes a macOS wheel for
x86_64 -- there is no ``arm64`` build at all. That makes this true on
two distinct occasions: genuine Apple Silicon hardware (no wheel
exists, so installing it falls back to compiling from source, which
fails on stock macOS due to an upstream OpenMP-detection bug in
veryfasttree's own CMakeLists.txt), and x86_64 Python running under
Rosetta 2 translation on Apple Silicon (a wheel *does* exist and
installs fine, but running translated native SIMD code is a known
cause of a silent 'illegal hardware instruction' crash -- the same
class of bug already seen with FAMSA on a translated interpreter).
"""
if platform.system() != "Darwin":
return False
return platform.machine() == "arm64" or is_apple_silicon_under_rosetta()
50 changes: 50 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,3 +255,53 @@ def fake_run_pipeline(config):

assert result.exit_code == 0, result.output
assert captured["config"].domain_cleaning.database_preference == DEFAULT_DATABASE_PREFERENCE


def test_cli_tree_tool_and_alignment_tool_default_to_platform_detection(tmp_path, monkeypatch):
# Not passing --tree-tool/--alignment-tool should leave PipelineConfig's
# own platform-aware default_factory in charge, not a fixed CLI default.
captured = {}

def fake_run_pipeline(config):
captured["config"] = config
return _fake_pipeline_result(tmp_path)

monkeypatch.setattr("candy.cli.run_pipeline", fake_run_pipeline)
monkeypatch.setattr("candy.config.is_macos_without_native_veryfasttree_wheel", lambda: True)

result = runner.invoke(app, ["GH173", "--email", "you@example.com"])

assert result.exit_code == 0, result.output
assert captured["config"].tree_tool == "fasttree"
assert captured["config"].alignment_tool == "famsa"


def test_cli_explicit_tree_tool_overrides_platform_default(tmp_path, monkeypatch):
captured = {}

def fake_run_pipeline(config):
captured["config"] = config
return _fake_pipeline_result(tmp_path)

monkeypatch.setattr("candy.cli.run_pipeline", fake_run_pipeline)
monkeypatch.setattr("candy.config.is_macos_without_native_veryfasttree_wheel", lambda: True)

result = runner.invoke(app, ["GH173", "--email", "you@example.com", "--tree-tool", "veryfasttree"])

assert result.exit_code == 0, result.output
assert captured["config"].tree_tool == "veryfasttree"


def test_cli_explicit_alignment_tool_is_passed_through(tmp_path, monkeypatch):
captured = {}

def fake_run_pipeline(config):
captured["config"] = config
return _fake_pipeline_result(tmp_path)

monkeypatch.setattr("candy.cli.run_pipeline", fake_run_pipeline)

result = runner.invoke(app, ["GH173", "--email", "you@example.com", "--alignment-tool", "mafft"])

assert result.exit_code == 0, result.output
assert captured["config"].alignment_tool == "mafft"
Loading
Loading