From e28d94a99eebb0410f4b14f6d3ee500766745ba1 Mon Sep 17 00:00:00 2001 From: Alex Windels Date: Tue, 11 Aug 2026 10:35:43 +0200 Subject: [PATCH] Auto-provision FastTree so macOS doesn't need conda at all A real user upgraded past the previous fixes (#10, #11) and hit the next wall: CANDy correctly defaults to --tree-tool fasttree on their Mac now, but fasttree itself still required conda -- which they don't have installed. Close that gap the same way MMseqs2 already avoids needing conda: auto-provision the binary on first use. New candy.phylogenetics.fasttree_download module, pinned to a specific commit of github.com/morgannprice/fasttree for reproducibility: - Linux/Windows: download the precompiled binary published directly in that repository. - macOS: no precompiled binary is published upstream at all, but FastTree.c is a single, dependency-free C file -- download it and compile it locally with whatever C compiler is already present (Xcode Command Line Tools' clang on essentially every real Mac), deliberately using the upstream-documented flags that avoid the OpenMP requirement that broke veryfasttree's own build (no -fopenmp, only -fopenmp-simd, which doesn't need the libomp runtime). Resolution order matches MMseqs2's established pattern: PATH, then the local cache, then auto-download/compile (disable via CANDY_NO_AUTO_DOWNLOAD, same as before). FastTreeBuilder now delegates to this instead of requiring PATH + a hard failure pointing at conda. Verified for real: the Windows download path was exercised live (not just mocked) against the actual pinned commit -- downloaded FastTree.exe and ran it successfully (FastTree 2.2.0, exit code 0, real --help output). Co-Authored-By: Claude Sonnet 5 --- README.md | 11 +- environment.yml | 7 +- src/candy/phylogenetics/fasttree.py | 29 ++-- src/candy/phylogenetics/fasttree_download.py | 172 +++++++++++++++++++ tests/test_alignment_and_phylogenetics.py | 14 +- tests/test_fasttree_download.py | 169 ++++++++++++++++++ 6 files changed, 371 insertions(+), 31 deletions(-) create mode 100644 src/candy/phylogenetics/fasttree_download.py create mode 100644 tests/test_fasttree_download.py diff --git a/README.md b/README.md index 56815a5..fbf85bc 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ 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. **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. +- **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), so CANDy skips it there entirely and defaults `--tree-tool` to [FastTree](https://github.com/morgannprice/fasttree) instead, which auto-downloads (Linux/Windows) or auto-compiles from a single dependency-free C file (macOS) on first use, cached afterward -- no conda needed there either. See below. ### Apple Silicon (M1/M2/M3/M4) setup @@ -70,21 +70,22 @@ Either way, verify before running a real job: python3 -c "import platform; print(platform.machine())" # should print "arm64", not "x86_64" ``` -**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): +**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 `pip`/`uv` would otherwise try to build it from source 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 hit this at all as of `candy-cazyme` 3.0.4+**: on a Mac without a working native `veryfasttree` build, CANDy both skips it as an install-time dependency entirely (so nothing tries to build it) and defaults `--tree-tool` to `fasttree` instead -- which auto-downloads a precompiled binary (Linux/Windows) or, on macOS specifically (no precompiled binary is published upstream), auto-compiles one from a single dependency-free C source file using whatever C compiler is already on your machine (Xcode Command Line Tools' `clang`, already present on essentially every real Mac). No conda needed. This happens once and is cached, the same way MMseqs2 auto-downloads itself. + +If that auto-compile step ever fails (e.g. genuinely no compiler on PATH), the error message tells you to run `xcode-select --install`, or you can still fall back to the bundled conda environment: ```bash conda env create -f environment.yml conda activate candy -candy GH173 --email you@example.com --tree # --tree-tool defaults to fasttree here automatically ``` -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 force `veryfasttree` anyway (e.g. you've solved the OpenMP build issue yourself), `pip install veryfasttree` explicitly and pass `--tree-tool veryfasttree`. 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. -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. +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`, and `--tree-tool fasttree` will require `FastTree`, already on PATH. ## Usage diff --git a/environment.yml b/environment.yml index 92348a3..01e485f 100644 --- a/environment.yml +++ b/environment.yml @@ -8,8 +8,11 @@ # - 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. +# under Rosetta translation) -- but CANDy auto-downloads/compiles +# its own copy of FastTree there when needed (see +# candy.phylogenetics.fasttree_download), so this conda package +# is only needed as a fallback (e.g. no C compiler available) or +# if you specifically want the conda-provided build instead. # 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 diff --git a/src/candy/phylogenetics/fasttree.py b/src/candy/phylogenetics/fasttree.py index c3642be..163ccf0 100644 --- a/src/candy/phylogenetics/fasttree.py +++ b/src/candy/phylogenetics/fasttree.py @@ -3,31 +3,26 @@ import logging from pathlib import Path -from candy.external_tools import MissingDependencyError, find_binary, run_tool +from candy.external_tools import run_tool +from candy.phylogenetics.fasttree_download import resolve_fasttree_binary logger = logging.getLogger(__name__) -# Different packagers ship this binary under different casings -# (bioconda historically used 'FastTree', some distros use 'fasttree'). -_CANDIDATE_NAMES = ["FastTree", "fasttree"] - class FastTreeBuilder: + """Phylogenetics via FastTree. + + Prefers a ``FastTree``/``fasttree`` already on PATH (e.g. from the + bundled conda environment); otherwise transparently downloads a + precompiled binary (Linux/Windows) or compiles one from source + (macOS, which has no precompiled binary upstream) and caches it -- see + :mod:`candy.phylogenetics.fasttree_download`. + """ + name = "fasttree" def build_tree(self, alignment_fasta: Path, output_newick: Path) -> Path: - binary = self._locate_binary() + binary = resolve_fasttree_binary() logger.info("Building phylogenetic tree with FastTree.") run_tool([binary, str(alignment_fasta)], stdout_path=output_newick) return output_newick - - def _locate_binary(self) -> str: - for candidate in _CANDIDATE_NAMES: - path = find_binary(candidate) - if path is not None: - return path - raise MissingDependencyError( - f"Required external tool 'FastTree' was not found on PATH " - f"(tried: {', '.join(_CANDIDATE_NAMES)}). Install it via the bundled conda " - "environment: `conda env create -f environment.yml && conda activate candy`." - ) diff --git a/src/candy/phylogenetics/fasttree_download.py b/src/candy/phylogenetics/fasttree_download.py new file mode 100644 index 0000000..67c409e --- /dev/null +++ b/src/candy/phylogenetics/fasttree_download.py @@ -0,0 +1,172 @@ +"""Locate, download, or compile a FastTree binary automatically. + +FastTree became CANDy's default ``--tree-tool`` on Macs where VeryFastTree +has no working native build (see :mod:`candy.platform_utils`), but until +now the only way to actually get a ``FastTree`` binary was the bundled +conda environment -- a real, unwelcome extra dependency for anyone who +doesn't already have conda installed (confirmed by a real user hitting +exactly that wall). + +Upstream (https://github.com/morgannprice/fasttree) publishes precompiled +binaries for Linux and Windows directly in the repository, and FastTree.c +itself is a single, dependency-free C file (no CMake, no OpenMP requirement +for the plain single-threaded build) that compiles in about a second with +whatever C compiler is already on the machine -- Xcode Command Line Tools +on macOS, build-essential-equivalent on Linux. That makes a "just pip +install and run" story possible here too: on first use, if a binary isn't +already on PATH, fetch a precompiled one (Linux/Windows) or fetch the +source and compile it locally (macOS, which has no precompiled binary +upstream), then cache the result and reuse it on every later run. + +Pinned to a specific commit for reproducibility; downloads only ever come +from the official morgannprice/fasttree GitHub repository over HTTPS, and +the whole thing can be disabled with the CANDY_NO_AUTO_DOWNLOAD environment +variable (falling back to requiring `FastTree` on PATH, e.g. via the +bundled conda environment). +""" + +from __future__ import annotations + +import logging +import os +import platform +import stat +import subprocess +from pathlib import Path + +import requests +from platformdirs import user_cache_dir + +from candy.external_tools import MissingDependencyError, find_binary + +logger = logging.getLogger(__name__) + +FASTTREE_REF = "a5a2723ea1e64faf3da7ea514521cfa348891add" +_RAW_BASE_URL = f"https://raw.githubusercontent.com/morgannprice/fasttree/{FASTTREE_REF}" + +NO_AUTO_DOWNLOAD_ENV = "CANDY_NO_AUTO_DOWNLOAD" + +_COMPILER_CANDIDATES = ["cc", "clang", "gcc"] +_COMPILE_FLAGS = ["-O3", "-fopenmp-simd", "-funsafe-math-optimizations", "-march=native"] + + +def _cache_dir() -> Path: + return Path(user_cache_dir("candy")) / "fasttree" / FASTTREE_REF + + +def _binary_name() -> str: + return "FastTree.exe" if platform.system() == "Windows" else "FastTree" + + +def _cached_binary() -> Path | None: + candidate = _cache_dir() / _binary_name() + return candidate if candidate.is_file() else None + + +def _download(url: str, dest: Path) -> None: + dest.parent.mkdir(parents=True, exist_ok=True) + with requests.get(url, stream=True, timeout=120) as response: + response.raise_for_status() + with open(dest, "wb") as f: + for chunk in response.iter_content(chunk_size=1 << 20): + f.write(chunk) + + +def _make_executable(path: Path) -> None: + if platform.system() != "Windows": + path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + +def _provision_precompiled(cache_dir: Path) -> Path: + asset = "FastTree.exe" if platform.system() == "Windows" else "FastTree" + binary_path = cache_dir / _binary_name() + logger.info( + "Downloading FastTree (%s) for phylogenetics -- this happens once and is cached at %s", + FASTTREE_REF[:12], cache_dir, + ) + _download(f"{_RAW_BASE_URL}/{asset}", binary_path) + _make_executable(binary_path) + return binary_path + + +def _find_compiler() -> str | None: + for candidate in _COMPILER_CANDIDATES: + path = find_binary(candidate) + if path: + return path + return None + + +def _provision_by_compiling(cache_dir: Path) -> Path: + compiler_path = _find_compiler() + if compiler_path is None: + raise MissingDependencyError( + "No C compiler was found on PATH to build FastTree from source (tried: " + f"{', '.join(_COMPILER_CANDIDATES)}). On macOS, install Xcode Command Line Tools: " + "`xcode-select --install`. Alternatively, install FastTree yourself (e.g. via " + "`conda env create -f environment.yml`) or set CANDY_NO_AUTO_DOWNLOAD=1 and provide it " + "on PATH another way." + ) + + cache_dir.mkdir(parents=True, exist_ok=True) + source_path = cache_dir / "FastTree.c" + binary_path = cache_dir / _binary_name() + logger.info( + "No precompiled FastTree is available for macOS -- compiling it from source with %s " + "(one-time, cached at %s).", + compiler_path, cache_dir, + ) + _download(f"{_RAW_BASE_URL}/FastTree.c", source_path) + + result = subprocess.run( + [compiler_path, *_COMPILE_FLAGS, "-o", str(binary_path), str(source_path), "-lm"], + capture_output=True, + text=True, + ) + if result.returncode != 0 or not binary_path.is_file(): + raise MissingDependencyError( + f"Compiling FastTree from source failed:\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}" + ) + _make_executable(binary_path) + return binary_path + + +def resolve_fasttree_binary() -> str: + """Return a path to a usable FastTree binary, downloading/compiling one if needed. + + Resolution order: PATH, then the local cache, then (unless disabled via + the CANDY_NO_AUTO_DOWNLOAD env var) automatic provisioning for the + current platform -- a precompiled download on Linux/Windows, or a local + from-source compile on macOS (where no precompiled binary is published + upstream). + """ + for name in ("FastTree", "fasttree"): + on_path = find_binary(name) + if on_path: + return on_path + + cached = _cached_binary() + if cached: + return str(cached) + + if os.environ.get(NO_AUTO_DOWNLOAD_ENV): + raise MissingDependencyError( + "FastTree was not found on PATH and automatic download is disabled " + f"({NO_AUTO_DOWNLOAD_ENV} is set). Install it yourself (e.g. via " + "`conda env create -f environment.yml`) or unset that variable." + ) + + cache_dir = _cache_dir() + try: + if platform.system() == "Darwin": + binary = _provision_by_compiling(cache_dir) + else: + binary = _provision_precompiled(cache_dir) + except requests.RequestException as exc: + raise MissingDependencyError( + f"Failed to download FastTree automatically: {exc}. Install it yourself (e.g. via " + "`conda env create -f environment.yml`) or check your network connection. You can also " + f"set {NO_AUTO_DOWNLOAD_ENV}=1 to disable this download attempt." + ) from exc + + return str(binary) diff --git a/tests/test_alignment_and_phylogenetics.py b/tests/test_alignment_and_phylogenetics.py index dc192f9..979eabe 100644 --- a/tests/test_alignment_and_phylogenetics.py +++ b/tests/test_alignment_and_phylogenetics.py @@ -18,16 +18,16 @@ def test_mafft_aligner_invokes_binary_with_stdout_redirect(tmp_path): assert kwargs["stdout_path"] == output_fasta -def test_fasttree_builder_falls_back_to_lowercase_binary_name(tmp_path): +def test_fasttree_builder_uses_resolved_binary(tmp_path): + # Binary resolution itself (PATH, cache, auto-download/compile) is + # tested in tests/test_fasttree_download.py; this just verifies + # FastTreeBuilder wires the resolved path through to run_tool correctly. alignment = tmp_path / "aligned.fasta" output = tmp_path / "tree.nwk" - def fake_find_binary(name): - return "/usr/bin/fasttree" if name == "fasttree" else None - - with patch("candy.phylogenetics.fasttree.find_binary", side_effect=fake_find_binary), patch( - "candy.phylogenetics.fasttree.run_tool" - ) as mock_run: + with patch( + "candy.phylogenetics.fasttree.resolve_fasttree_binary", return_value="/usr/bin/fasttree" + ), patch("candy.phylogenetics.fasttree.run_tool") as mock_run: FastTreeBuilder().build_tree(alignment, output) args, kwargs = mock_run.call_args diff --git a/tests/test_fasttree_download.py b/tests/test_fasttree_download.py new file mode 100644 index 0000000..75ae622 --- /dev/null +++ b/tests/test_fasttree_download.py @@ -0,0 +1,169 @@ +import os +import subprocess +from unittest.mock import patch + +import pytest + +from candy.external_tools import MissingDependencyError +from candy.phylogenetics import fasttree_download as m + + +def test_prefers_binary_already_on_path(): + with patch("candy.phylogenetics.fasttree_download.find_binary", return_value="/usr/bin/FastTree"): + assert m.resolve_fasttree_binary() == "/usr/bin/FastTree" + + +def test_falls_back_to_lowercase_binary_name_on_path(): + def fake_find_binary(name): + return "/usr/bin/fasttree" if name == "fasttree" else None + + with patch("candy.phylogenetics.fasttree_download.find_binary", side_effect=fake_find_binary): + assert m.resolve_fasttree_binary() == "/usr/bin/fasttree" + + +def test_uses_cached_binary_if_present(tmp_path): + cached = tmp_path / m._binary_name() + cached.write_text("fake binary") + + with patch("candy.phylogenetics.fasttree_download.find_binary", return_value=None), patch( + "candy.phylogenetics.fasttree_download._cache_dir", return_value=tmp_path + ): + assert m.resolve_fasttree_binary() == str(cached) + + +def test_raises_when_no_auto_download_env_set_and_nothing_available(tmp_path): + with patch("candy.phylogenetics.fasttree_download.find_binary", return_value=None), patch( + "candy.phylogenetics.fasttree_download._cache_dir", return_value=tmp_path + ), patch.dict(os.environ, {m.NO_AUTO_DOWNLOAD_ENV: "1"}): + with pytest.raises(MissingDependencyError, match="automatic download is disabled"): + m.resolve_fasttree_binary() + + +def test_download_failure_raises_missing_dependency_error(tmp_path): + import requests + + with patch("candy.phylogenetics.fasttree_download.find_binary", return_value=None), patch( + "candy.phylogenetics.fasttree_download._cache_dir", return_value=tmp_path + ), patch("candy.phylogenetics.fasttree_download.platform.system", return_value="Linux"), patch( + "candy.phylogenetics.fasttree_download.requests.get", + side_effect=requests.ConnectionError("no network"), + ): + os.environ.pop(m.NO_AUTO_DOWNLOAD_ENV, None) + with pytest.raises(MissingDependencyError, match="Failed to download FastTree"): + m.resolve_fasttree_binary() + + +def test_downloads_precompiled_binary_on_linux(tmp_path): + def fake_download(url, dest): + assert url == f"{m._RAW_BASE_URL}/FastTree" + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text("fake linux binary") + + with patch("candy.phylogenetics.fasttree_download.find_binary", return_value=None), patch( + "candy.phylogenetics.fasttree_download._cache_dir", return_value=tmp_path + ), patch("candy.phylogenetics.fasttree_download.platform.system", return_value="Linux"), patch( + "candy.phylogenetics.fasttree_download._download", side_effect=fake_download + ): + os.environ.pop(m.NO_AUTO_DOWNLOAD_ENV, None) + binary = m.resolve_fasttree_binary() + + assert binary == str(tmp_path / "FastTree") + assert (tmp_path / "FastTree").read_text() == "fake linux binary" + + +def test_downloads_precompiled_exe_on_windows(tmp_path): + def fake_download(url, dest): + assert url == f"{m._RAW_BASE_URL}/FastTree.exe" + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text("fake windows binary") + + with patch("candy.phylogenetics.fasttree_download.find_binary", return_value=None), patch( + "candy.phylogenetics.fasttree_download._cache_dir", return_value=tmp_path + ), patch("candy.phylogenetics.fasttree_download.platform.system", return_value="Windows"), patch( + "candy.phylogenetics.fasttree_download._download", side_effect=fake_download + ): + os.environ.pop(m.NO_AUTO_DOWNLOAD_ENV, None) + binary = m.resolve_fasttree_binary() + + assert binary == str(tmp_path / "FastTree.exe") + + +def test_compiles_from_source_on_macos(tmp_path): + def fake_download(url, dest): + assert url == f"{m._RAW_BASE_URL}/FastTree.c" + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text("/* fake FastTree.c */") + + def fake_run(cmd, **kwargs): + # simulate the compiler actually producing the output binary + out_index = cmd.index("-o") + from pathlib import Path + + Path(cmd[out_index + 1]).write_text("fake compiled binary") + from types import SimpleNamespace + + return SimpleNamespace(returncode=0, stdout="", stderr="") + + with patch("candy.phylogenetics.fasttree_download.find_binary", side_effect=lambda name: ( + None if name in ("FastTree", "fasttree") else ("/usr/bin/cc" if name == "cc" else None) + )), patch("candy.phylogenetics.fasttree_download._cache_dir", return_value=tmp_path), patch( + "candy.phylogenetics.fasttree_download.platform.system", return_value="Darwin" + ), patch("candy.phylogenetics.fasttree_download._download", side_effect=fake_download), patch( + "candy.phylogenetics.fasttree_download.subprocess.run", side_effect=fake_run + ): + os.environ.pop(m.NO_AUTO_DOWNLOAD_ENV, None) + binary = m.resolve_fasttree_binary() + + assert binary == str(tmp_path / "FastTree") + assert (tmp_path / "FastTree").read_text() == "fake compiled binary" + + +def test_macos_without_a_c_compiler_raises_clear_error(tmp_path): + with patch("candy.phylogenetics.fasttree_download.find_binary", return_value=None), patch( + "candy.phylogenetics.fasttree_download._cache_dir", return_value=tmp_path + ), patch("candy.phylogenetics.fasttree_download.platform.system", return_value="Darwin"): + os.environ.pop(m.NO_AUTO_DOWNLOAD_ENV, None) + with pytest.raises(MissingDependencyError, match="No C compiler was found"): + m.resolve_fasttree_binary() + + +def test_macos_compile_failure_raises_clear_error_with_compiler_output(tmp_path): + from types import SimpleNamespace + + def fake_download(url, dest): + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text("/* fake FastTree.c */") + + def fake_run(cmd, **kwargs): + return SimpleNamespace(returncode=1, stdout="", stderr="clang: error: no such file or directory: 'NOTFOUND'") + + with patch("candy.phylogenetics.fasttree_download.find_binary", side_effect=lambda name: ( + "/usr/bin/cc" if name == "cc" else None + )), patch("candy.phylogenetics.fasttree_download._cache_dir", return_value=tmp_path), patch( + "candy.phylogenetics.fasttree_download.platform.system", return_value="Darwin" + ), patch("candy.phylogenetics.fasttree_download._download", side_effect=fake_download), patch( + "candy.phylogenetics.fasttree_download.subprocess.run", side_effect=fake_run + ): + os.environ.pop(m.NO_AUTO_DOWNLOAD_ENV, None) + with pytest.raises(MissingDependencyError, match="Compiling FastTree from source failed"): + m.resolve_fasttree_binary() + + +@pytest.mark.integration +def test_real_download_and_run_fasttree(tmp_path): + """Exercises the real network download (and, on macOS, compile) against the pinned commit. + + Marked integration since it needs network access (and, on macOS, a C + compiler); skipped by default runs. Confirmed manually during + development that the pinned FastTree.exe download works end-to-end on + Windows (FastTree 2.2.0, exit code 0, real --help output). + """ + with patch("candy.phylogenetics.fasttree_download.find_binary", return_value=None), patch( + "candy.phylogenetics.fasttree_download._cache_dir", return_value=tmp_path + ): + os.environ.pop(m.NO_AUTO_DOWNLOAD_ENV, None) + binary = m.resolve_fasttree_binary() + + result = subprocess.run([binary, "-help"], capture_output=True, text=True) + assert result.returncode == 0 + assert "FastTree" in (result.stdout + result.stderr)