Skip to content
Open
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
20 changes: 14 additions & 6 deletions fastembed/common/model_management.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import os
import time
import json
import os
import shutil
import tarfile
import time
from copy import deepcopy
from pathlib import Path
from typing import Any, TypeVar, Generic
from typing import Any, Generic, TypeVar

import requests
from huggingface_hub import snapshot_download, model_info, list_repo_tree
from huggingface_hub import HfApi, snapshot_download
from huggingface_hub.hf_api import RepoFile
from huggingface_hub.utils import (
RepositoryNotFoundError,
Expand All @@ -17,6 +17,7 @@
)
from loguru import logger
from tqdm import tqdm

from fastembed.common.model_description import BaseModelDescription

T = TypeVar("T", bound=BaseModelDescription)
Expand Down Expand Up @@ -214,6 +215,9 @@ def _save_file_metadata(model_dir: Path, meta: dict[str, dict[str, int | str]])
snapshot_dir = Path(cache_dir) / f"models--{hf_source_repo.replace('/', '--')}"
metadata_file = snapshot_dir / cls.METADATA_FILE

hf_endpoint = kwargs.pop("endpoint", None) or os.environ.get("HF_ENDPOINT") or None
hf_api = HfApi(endpoint=hf_endpoint)

if local_files_only:
disable_progress_bars()
if metadata_file.exists():
Expand All @@ -228,12 +232,15 @@ def _save_file_metadata(model_dir: Path, meta: dict[str, dict[str, int | str]])
allow_patterns=allow_patterns,
cache_dir=cache_dir,
local_files_only=local_files_only,
endpoint=hf_endpoint,
**kwargs,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)
return result

repo_revision = model_info(hf_source_repo).sha
repo_tree = list(list_repo_tree(hf_source_repo, revision=repo_revision, repo_type="model"))
repo_revision = hf_api.model_info(hf_source_repo).sha
repo_tree = list(
hf_api.list_repo_tree(hf_source_repo, revision=repo_revision, repo_type="model")
)

allowed_extensions = {".json", ".onnx", ".txt"}
repo_files = (
Expand All @@ -260,6 +267,7 @@ def _save_file_metadata(model_dir: Path, meta: dict[str, dict[str, int | str]])
allow_patterns=allow_patterns,
cache_dir=cache_dir,
local_files_only=local_files_only,
endpoint=hf_endpoint,
**kwargs,
)

Expand Down
131 changes: 129 additions & 2 deletions tests/test_common.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import os
from unittest.mock import MagicMock, patch

import numpy as np

from fastembed import (
TextEmbedding,
SparseTextEmbedding,
ImageEmbedding,
LateInteractionMultimodalEmbedding,
LateInteractionTextEmbedding,
SparseTextEmbedding,
TextEmbedding,
)
from fastembed.common.model_management import ModelManagement
from fastembed.common.utils import last_token_pooling


Expand All @@ -33,6 +37,129 @@ def test_text_list_supported_models():
assert "hf" in description["sources"] or "url" in description["sources"]


def _run_download_with_mocks(tmp_path, extra_env, local_files_only=False, download_kwargs=None):
"""Run download_files_from_huggingface with all network calls mocked out.

Uses autospec=True so mocks are checked against the real huggingface_hub
signatures - a kwarg that the real API doesn't accept (e.g. passing
`endpoint` to a bound HfApi method instead of its constructor) raises a
TypeError here, the same as it would against the real library. The
instance returned by the autospecced HfApi mock (mock_hf_api_cls.return_value)
is kept as-is rather than replaced, so its methods stay spec-checked too.
"""
with (
patch.dict(os.environ, extra_env),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
patch("fastembed.common.model_management.HfApi", autospec=True) as mock_hf_api_cls,
patch(
"fastembed.common.model_management.snapshot_download",
autospec=True,
return_value=str(tmp_path),
) as mock_sd,
# skip post-download metadata verification so the function completes cleanly
patch.object(ModelManagement, "METADATA_FILE", "__nonexistent__"),
):
if "HF_ENDPOINT" not in extra_env:
os.environ.pop("HF_ENDPOINT", None)
mock_hf_api_instance = mock_hf_api_cls.return_value
mock_hf_api_instance.model_info.return_value = MagicMock(sha="abc123")
mock_hf_api_instance.list_repo_tree.return_value = []

ModelManagement.download_files_from_huggingface(
hf_source_repo="test-org/test-model",
cache_dir=str(tmp_path),
extra_patterns=["*.onnx"],
local_files_only=local_files_only,
**(download_kwargs or {}),
)
return mock_hf_api_cls, mock_hf_api_instance, mock_sd


def test_hf_endpoint_forwarded_to_hub_calls(tmp_path):
"""HF_ENDPOINT env var must be forwarded to HfApi and snapshot_download."""
custom_endpoint = "https://hf-mirror.example.com"
mock_hf_api_cls, mock_hf_api_instance, mock_sd = _run_download_with_mocks(
tmp_path, {"HF_ENDPOINT": custom_endpoint}
)

_, api_kwargs = mock_hf_api_cls.call_args
assert api_kwargs.get("endpoint") == custom_endpoint, (
f"HfApi should be constructed with endpoint={custom_endpoint!r}, got {api_kwargs}"
)
mock_hf_api_instance.model_info.assert_called_once()
mock_hf_api_instance.list_repo_tree.assert_called_once()

_, sd_kwargs = mock_sd.call_args
assert sd_kwargs.get("endpoint") == custom_endpoint, (
f"snapshot_download should receive endpoint={custom_endpoint!r}, got {sd_kwargs}"
)


def test_no_hf_endpoint_no_extra_kwarg(tmp_path):
"""When HF_ENDPOINT is not set, endpoint must be None for HfApi and snapshot_download."""
env_without_hf_endpoint = {k: v for k, v in os.environ.items() if k != "HF_ENDPOINT"}
with patch.dict(os.environ, env_without_hf_endpoint, clear=True):
mock_hf_api_cls, _, mock_sd = _run_download_with_mocks(tmp_path, {})

_, api_kwargs = mock_hf_api_cls.call_args
assert api_kwargs.get("endpoint") is None, (
f"HfApi endpoint should be None when HF_ENDPOINT is unset, got {api_kwargs}"
)

_, sd_kwargs = mock_sd.call_args
assert sd_kwargs.get("endpoint") is None, (
f"snapshot_download endpoint should be None when HF_ENDPOINT is unset, got {sd_kwargs}"
)


def test_empty_hf_endpoint_normalized_to_none(tmp_path):
"""An empty-string HF_ENDPOINT must be treated as unset, not forwarded as ''."""
mock_hf_api_cls, _, mock_sd = _run_download_with_mocks(tmp_path, {"HF_ENDPOINT": ""})

_, api_kwargs = mock_hf_api_cls.call_args
assert api_kwargs.get("endpoint") is None, (
f"empty HF_ENDPOINT should normalize to None, got {api_kwargs}"
)

_, sd_kwargs = mock_sd.call_args
assert sd_kwargs.get("endpoint") is None, (
f"empty HF_ENDPOINT should normalize to None, got {sd_kwargs}"
)


def test_explicit_endpoint_kwarg_takes_precedence(tmp_path):
"""An explicit `endpoint` kwarg must win over HF_ENDPOINT and not collide with it."""
explicit_endpoint = "https://explicit.example.com"
mock_hf_api_cls, _, mock_sd = _run_download_with_mocks(
tmp_path,
{"HF_ENDPOINT": "https://from-env.example.com"},
download_kwargs={"endpoint": explicit_endpoint},
)

_, api_kwargs = mock_hf_api_cls.call_args
assert api_kwargs.get("endpoint") == explicit_endpoint, (
f"explicit endpoint kwarg should take precedence, got {api_kwargs}"
)

_, sd_kwargs = mock_sd.call_args
assert sd_kwargs.get("endpoint") == explicit_endpoint, (
f"explicit endpoint kwarg should take precedence, got {sd_kwargs}"
)


def test_hf_endpoint_forwarded_on_local_files_only_path(tmp_path):
"""HF_ENDPOINT must also be forwarded on the local_files_only snapshot_download call."""
custom_endpoint = "https://hf-mirror.example.com"
_, _, mock_sd = _run_download_with_mocks(
tmp_path, {"HF_ENDPOINT": custom_endpoint}, local_files_only=True
)

_, sd_kwargs = mock_sd.call_args
assert sd_kwargs.get("endpoint") == custom_endpoint, (
f"snapshot_download should receive endpoint={custom_endpoint!r} on the local_files_only path, "
f"got {sd_kwargs}"
)


def test_last_token_pooling():
token_embeddings = np.array(
[
Expand Down