diff --git a/README.md b/README.md index 021126a..56815a5 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. @@ -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. diff --git a/environment.yml b/environment.yml index 337341c..92348a3 100644 --- a/environment.yml +++ b/environment.yml @@ -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 @@ -13,5 +23,6 @@ dependencies: - python>=3.10 - pip - cd-hit + - fasttree - pip: - -e . diff --git a/src/candy/alignment/mafft.py b/src/candy/alignment/mafft.py index ccd931b..9a8a340 100644 --- a/src/candy/alignment/mafft.py +++ b/src/candy/alignment/mafft.py @@ -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 diff --git a/src/candy/cli.py b/src/candy/cli.py index b5a124c..8d640d8 100644 --- a/src/candy/cli.py +++ b/src/candy/cli.py @@ -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'." ), @@ -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), @@ -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: diff --git a/src/candy/config.py b/src/candy/config.py index 6dae5b3..ee5a030 100644 --- a/src/candy/config.py +++ b/src/candy/config.py @@ -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" @@ -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) diff --git a/src/candy/pipeline.py b/src/candy/pipeline.py index 8b6791f..a889335 100644 --- a/src/candy/pipeline.py +++ b/src/candy/pipeline.py @@ -17,8 +17,6 @@ from __future__ import annotations import logging -import platform -import subprocess from dataclasses import dataclass from pathlib import Path @@ -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__) @@ -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 @@ -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) diff --git a/src/candy/platform_utils.py b/src/candy/platform_utils.py new file mode 100644 index 0000000..ff0018b --- /dev/null +++ b/src/candy/platform_utils.py @@ -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() diff --git a/tests/test_cli.py b/tests/test_cli.py index 0995a94..ff09ccf 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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" diff --git a/tests/test_config.py b/tests/test_config.py index fedcee6..3a8d653 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,6 +1,15 @@ +from unittest.mock import patch + import pytest -from candy.config import DEFAULT_DATABASE_PREFERENCE, reorder_database_preference +from candy.config import ( + DEFAULT_DATABASE_PREFERENCE, + CAZyFamilyInput, + PipelineConfig, + Taxonomy, + default_tree_tool, + reorder_database_preference, +) def test_reorder_moves_named_databases_to_front_in_given_order(): @@ -35,3 +44,37 @@ def test_reorder_deduplicates_repeated_names(): def test_reorder_raises_on_unknown_database_name(): with pytest.raises(ValueError, match="Unknown database name"): reorder_database_preference(["NOTAREALDB"]) + + +def test_default_tree_tool_is_fasttree_when_veryfasttree_has_no_wheel(): + with patch("candy.config.is_macos_without_native_veryfasttree_wheel", return_value=True): + assert default_tree_tool() == "fasttree" + + +def test_default_tree_tool_is_veryfasttree_otherwise(): + with patch("candy.config.is_macos_without_native_veryfasttree_wheel", return_value=False): + assert default_tree_tool() == "veryfasttree" + + +def test_pipeline_config_tree_tool_defaults_via_platform_detection(): + # PipelineConfig() should pick up the platform-aware default automatically, + # not just a fixed literal -- Python API users benefit without needing to + # know about this quirk at all. + with patch("candy.config.is_macos_without_native_veryfasttree_wheel", return_value=True): + config = PipelineConfig( + input=CAZyFamilyInput(enzyme_class="GH", family_number=5, email="a@b.com", taxonomy=Taxonomy.ALL), + jobname="job", + output_dir=".", + ) + assert config.tree_tool == "fasttree" + + +def test_pipeline_config_tree_tool_explicit_value_overrides_default(): + with patch("candy.config.is_macos_without_native_veryfasttree_wheel", return_value=True): + config = PipelineConfig( + input=CAZyFamilyInput(enzyme_class="GH", family_number=5, email="a@b.com", taxonomy=Taxonomy.ALL), + jobname="job", + output_dir=".", + tree_tool="veryfasttree", + ) + assert config.tree_tool == "veryfasttree" diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 897c483..32ffa22 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -8,7 +8,6 @@ """ import xml.etree.ElementTree as ET -from types import SimpleNamespace from unittest.mock import patch import pandas as pd @@ -17,68 +16,22 @@ 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"), - ): +def test_warn_if_running_under_rosetta_warns_when_detected(caplog): + with patch("candy.pipeline.is_apple_silicon_under_rosetta", return_value=True): 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"), - ): +def test_warn_if_running_under_rosetta_silent_when_not_detected(caplog): + with patch("candy.pipeline.is_apple_silicon_under_rosetta", return_value=False): 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: name = "stub" diff --git a/tests/test_platform_utils.py b/tests/test_platform_utils.py new file mode 100644 index 0000000..5f890b3 --- /dev/null +++ b/tests/test_platform_utils.py @@ -0,0 +1,82 @@ +from types import SimpleNamespace +from unittest.mock import patch + +from candy.platform_utils import is_apple_silicon_under_rosetta, is_macos_without_native_veryfasttree_wheel + + +def test_is_apple_silicon_under_rosetta_true_when_translated(): + with patch("candy.platform_utils.platform.system", return_value="Darwin"), patch( + "candy.platform_utils.platform.machine", return_value="x86_64" + ), patch( + "candy.platform_utils.subprocess.run", + return_value=SimpleNamespace(stdout="1\n"), + ): + assert is_apple_silicon_under_rosetta() is True + + +def test_is_apple_silicon_under_rosetta_false_on_native_arm64(): + with patch("candy.platform_utils.platform.system", return_value="Darwin"), patch( + "candy.platform_utils.platform.machine", return_value="arm64" + ), patch("candy.platform_utils.subprocess.run") as mock_run: + assert is_apple_silicon_under_rosetta() is False + + mock_run.assert_not_called() + + +def test_is_apple_silicon_under_rosetta_false_on_genuine_intel_mac(): + # On a real Intel Mac, `sysctl -n sysctl.proc_translated` reports "0" + # (the key exists but the process isn't translated). + with patch("candy.platform_utils.platform.system", return_value="Darwin"), patch( + "candy.platform_utils.platform.machine", return_value="x86_64" + ), patch( + "candy.platform_utils.subprocess.run", + return_value=SimpleNamespace(stdout="0\n"), + ): + assert is_apple_silicon_under_rosetta() is False + + +def test_is_apple_silicon_under_rosetta_false_on_non_macos(): + with patch("candy.platform_utils.platform.system", return_value="Windows"), patch( + "candy.platform_utils.subprocess.run" + ) as mock_run: + assert is_apple_silicon_under_rosetta() is False + + mock_run.assert_not_called() + + +def test_is_apple_silicon_under_rosetta_tolerates_missing_sysctl(): + # Defensive: sysctl.proc_translated not existing (or sysctl missing + # entirely) must never crash the caller over a best-effort check. + with patch("candy.platform_utils.platform.system", return_value="Darwin"), patch( + "candy.platform_utils.platform.machine", return_value="x86_64" + ), patch("candy.platform_utils.subprocess.run", side_effect=OSError("no such command")): + assert is_apple_silicon_under_rosetta() is False # must not raise + + +def test_is_macos_without_native_veryfasttree_wheel_true_on_native_arm64(): + # No macOS arm64 wheel exists for veryfasttree at all (as of 4.0.4.1). + with patch("candy.platform_utils.platform.system", return_value="Darwin"), patch( + "candy.platform_utils.platform.machine", return_value="arm64" + ): + assert is_macos_without_native_veryfasttree_wheel() is True + + +def test_is_macos_without_native_veryfasttree_wheel_true_under_rosetta(): + # A wheel exists for macOS x86_64 and installs fine, but running it + # translated is a known SIGILL risk -- same class of bug as FAMSA. + with patch("candy.platform_utils.platform.system", return_value="Darwin"), patch( + "candy.platform_utils.platform.machine", return_value="x86_64" + ), patch("candy.platform_utils.is_apple_silicon_under_rosetta", return_value=True): + assert is_macos_without_native_veryfasttree_wheel() is True + + +def test_is_macos_without_native_veryfasttree_wheel_false_on_genuine_intel_mac(): + with patch("candy.platform_utils.platform.system", return_value="Darwin"), patch( + "candy.platform_utils.platform.machine", return_value="x86_64" + ), patch("candy.platform_utils.is_apple_silicon_under_rosetta", return_value=False): + assert is_macos_without_native_veryfasttree_wheel() is False + + +def test_is_macos_without_native_veryfasttree_wheel_false_on_non_macos(): + with patch("candy.platform_utils.platform.system", return_value="Linux"): + assert is_macos_without_native_veryfasttree_wheel() is False