diff --git a/mellea/backends/adapters/adapter.py b/mellea/backends/adapters/adapter.py index 43ada203b..2f876bb94 100644 --- a/mellea/backends/adapters/adapter.py +++ b/mellea/backends/adapters/adapter.py @@ -18,6 +18,7 @@ import contextlib import pathlib import re +import tempfile import warnings from typing import Literal, TypeAlias, TypeVar, cast @@ -751,8 +752,18 @@ def from_hub( ) -> list["EmbeddedIntrinsicAdapter"]: """Load embedded adapters from a Granite Switch model on Hugging Face Hub. - Downloads `adapter_index.json` and the `io_configs/` directory, then - delegates to :meth:`from_model_directory`. + Downloads `adapter_index.json` and the `io_configs/` directory into a + self-contained local directory, then delegates to + :meth:`from_model_directory`. + + `huggingface_hub.snapshot_download`'s default cache-backed snapshot + directory populates `io_configs/` with symlinks that resolve into a + sibling `blobs/` directory *outside* the snapshot root. That breaks the + contract `from_model_directory` expects (a self-contained model + directory) and trips its path-escape check. To satisfy that contract, + the files are downloaded directly into a temporary directory (via + `local_dir`) instead, so `io_configs/` contains real files rather than + symlinks escaping the directory. Args: repo_id (str): Hugging Face Hub repository ID @@ -780,22 +791,24 @@ def from_hub( 'Hugging Face Hub. Please install it with: pip install "mellea[switch]"' ) from e - local_root = huggingface_hub.snapshot_download( - repo_id=repo_id, - allow_patterns=["adapter_index.json", "io_configs/**"], - cache_dir=cache_dir, - revision=revision, - ) - try: - return EmbeddedIntrinsicAdapter.from_model_directory( - local_root, intrinsic_name=intrinsic_name + with tempfile.TemporaryDirectory() as local_dir: + downloaded_dir = huggingface_hub.snapshot_download( + repo_id=repo_id, + allow_patterns=["adapter_index.json", "io_configs/**"], + cache_dir=cache_dir, + local_dir=local_dir, + revision=revision, ) - except ValueError as e: - if intrinsic_name is not None: - raise ValueError( - f"No adapter found for adapter function '{intrinsic_name}' in {repo_id}" - ) from e - raise ValueError(f"No adapters found in {repo_id}") from e + try: + return EmbeddedIntrinsicAdapter.from_model_directory( + downloaded_dir, intrinsic_name=intrinsic_name + ) + except ValueError as e: + if intrinsic_name is not None: + raise ValueError( + f"No adapter found for adapter function '{intrinsic_name}' in {repo_id}" + ) from e + raise ValueError(f"No adapters found in {repo_id}") from e @staticmethod def from_source( diff --git a/test/backends/test_adapters/test_embedded_adapter.py b/test/backends/test_adapters/test_embedded_adapter.py index 600f0d7e0..dee92594f 100644 --- a/test/backends/test_adapters/test_embedded_adapter.py +++ b/test/backends/test_adapters/test_embedded_adapter.py @@ -6,7 +6,7 @@ import json import os import pathlib -from unittest.mock import MagicMock, patch +from unittest.mock import ANY, MagicMock, patch import pytest import yaml @@ -308,6 +308,7 @@ def test_downloads_and_delegates(self, model_dir): repo_id="ibm-granite/granite-switch-micro", allow_patterns=["adapter_index.json", "io_configs/**"], cache_dir="/tmp/test-cache", + local_dir=ANY, revision="test-rev", ) assert len(adapters) == 2 @@ -324,11 +325,21 @@ def test_filter_single_intrinsic(self, model_dir): repo_id="ibm-granite/granite-switch-micro", allow_patterns=["adapter_index.json", "io_configs/**"], cache_dir=None, + local_dir=ANY, revision="main", ) assert len(adapters) == 1 assert adapters[0].intrinsic_name == "citations" + def test_from_hub_requests_local_dir(self, model_dir): + """from_hub requests local_dir to avoid symlinks escaping model directory.""" + with patch( + "huggingface_hub.snapshot_download", return_value=str(model_dir) + ) as mock_dl: + EmbeddedIntrinsicAdapter.from_hub("ibm-granite/granite-switch-micro") + _, kwargs = mock_dl.call_args + assert "local_dir" in kwargs and kwargs["local_dir"] is not None + def test_missing_huggingface_hub_raises(self): with patch.dict("sys.modules", {"huggingface_hub": None}): with pytest.raises(ImportError, match="huggingface_hub is required"): @@ -377,6 +388,7 @@ def test_hub_passes_revision_and_cache(self, model_dir): repo_id="ibm-granite/granite-switch-micro", allow_patterns=["adapter_index.json", "io_configs/**"], cache_dir="/tmp/cache", + local_dir=ANY, revision="v2", )