diff --git a/RELEASE.md b/RELEASE.md index f5873b2..16ce235 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,19 +1,29 @@ -# v0.4.5 +# v0.4.6 # Changelog Huggingface Vision filter release notes +## [Unreleased] + +## v0.4.6 - 2026-04-27 + +### Changed +- Split the generic "not compatible" catch-all in both `ObjectDetectionBackend.load()` and `ImageClassificationBackend.load()` into targeted exception branches for `ImportError` (timm missing), `GatedRepoError`, `RepositoryNotFoundError`, `RevisionNotFoundError`, `HfHubHTTPError`, `ValueError`, and a generic fallback. Every message now includes `repr(e)` and preserves the original traceback via `raise ... from e`. +- Extracted the shared exception handling into `filter_huggingface_vision/backends/_hf_load_errors.py` as a `hf_load_error_handler` context manager so future `huggingface_hub` error types only need to be added in one place. +- Added `LocalEntryNotFoundError` / `EntryNotFoundError` coverage (offline or shared-cache race), 401-aware branch on `HfHubHTTPError` (with `HF_TOKEN` hint), and an allowlist of missing-dep hints (`timm`, `sentencepiece`, `detectron2`, `torchvision`). +- Extended the same error-handling wrapper to `GroundingDinoBackend` and `OwlVitBackend` so every `from_pretrained` call in this filter produces the same actionable errors. +- Pinned `huggingface-hub>=0.23` and switched the exception import to `huggingface_hub.utils` (stable across the whole supported range). +- Deduplicated the `make_hf_error` test helper into `tests/_hf_test_utils.py` (shared util); declared `pythonpath = ["tests"]` in `pyproject.toml` so the bare import is documented rather than implicit. +- Synced `uv.lock` with `pyproject.toml`'s existing `openfilter[all]>=0.1.27` pin. + ## v0.4.5 - 2026-04-23 ### Changed - Bump openfilter SDK, align CI workflow with shared release gate (source-paths) - - Fix release workflow secret names: `PYPI_API_TOKEN` → `PLAINSIGHT_PYPI_TOKEN`, `DOCKERHUB_TOKEN` → `DOCKERHUB_ACCESS_TOKEN` (org-level secret names). Without this the PyPI / Docker Hub tokens resolved to empty and no package has been published since the migration. - Bump openfilter dependency to `>=0.1.30`. -## [Unreleased] - ## v0.4.4 - 2026-04-20 ### Changed diff --git a/VERSION b/VERSION index a423f7f..68aa5c5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.4.5 +v0.4.6 \ No newline at end of file diff --git a/filter_huggingface_vision/backends/_hf_load_errors.py b/filter_huggingface_vision/backends/_hf_load_errors.py new file mode 100644 index 0000000..ea60a1c --- /dev/null +++ b/filter_huggingface_vision/backends/_hf_load_errors.py @@ -0,0 +1,102 @@ +"""Shared HuggingFace Hub load-error handler for vision backends.""" +from contextlib import contextmanager + +# Import from huggingface_hub.utils — this path has been stable since hub ~0.16 +# and is available at the declared floor (>=0.23), unlike huggingface_hub.errors +# which was introduced later. Both modules expose the same classes. +from huggingface_hub.utils import ( + EntryNotFoundError, + GatedRepoError, + HfHubHTTPError, + LocalEntryNotFoundError, + RepositoryNotFoundError, + RevisionNotFoundError, +) + +# Allowlist of known optional dependencies that produce ImportError. +# Maps lower-cased dep name → install hint shown to the operator. +_MISSING_DEP_HINTS = { + "timm": "pip install timm (needed by DETR / Conditional DETR)", + "sentencepiece": "pip install sentencepiece", + "detectron2": "pip install detectron2 — see upstream install guide", + "torchvision": "pip install torchvision", +} + + +@contextmanager +def hf_load_error_handler(model_id: str, revision: str, task: str): + """Wraps AutoImageProcessor + AutoModelFor*.from_pretrained calls with + targeted, actionable error messages. + + Catch order matters: + - GatedRepoError before RepositoryNotFoundError (it is a subclass). + - RepositoryNotFoundError and RevisionNotFoundError before HfHubHTTPError + (both are subclasses). + - (LocalEntryNotFoundError, EntryNotFoundError) before HfHubHTTPError + and before the `except ValueError` fallback: on huggingface-hub 0.23 + (our declared floor, with the `requests` backbone) both ARE subclasses + of HfHubHTTPError, and LocalEntryNotFoundError is additionally a + subclass of ValueError, so any reorder would silently reroute them to + a less helpful branch. (Hub >=1.0 flattened the hierarchy; keeping the + order preserves correct behavior on both eras.) + """ + try: + yield + except ImportError as e: + msg = str(e).lower() + for dep, hint in _MISSING_DEP_HINTS.items(): + if dep in msg: + raise ImportError( + f"Model '{model_id}' requires '{dep}': {hint}. " + f"Original: {repr(e)}" + ) from e + raise + except GatedRepoError as e: + raise RuntimeError( + f"Access to '{model_id}' requires accepting its license on the Hub. " + f"Accept it with an authenticated token " + f"(set HF_TOKEN environment variable). Detail: {repr(e)}" + ) from e + except RepositoryNotFoundError as e: + raise RuntimeError( + f"HuggingFace repository '{model_id}' not found. " + f"Check the model ID or your HF_TOKEN if the repo is gated. " + f"Detail: {repr(e)}" + ) from e + except RevisionNotFoundError as e: + raise RuntimeError( + f"Revision '{revision}' not found in '{model_id}'. " + f"Check the revision (commit SHA, tag, or branch). " + f"Detail: {repr(e)}" + ) from e + # Catch order is load-bearing here: at hub 0.23 (our declared floor) these + # are subclasses of HfHubHTTPError, and LocalEntryNotFoundError is also a + # subclass of ValueError; do not reorder this branch below either of them. + except (LocalEntryNotFoundError, EntryNotFoundError) as e: + raise RuntimeError( + f"Model entry for '{model_id}@{revision}' could not be resolved: the " + f"repository may be missing the expected file, or the local cache may " + f"be incomplete. If running offline, pre-download the model with " + f"`huggingface-cli download {model_id} --revision {revision}` before " + f"starting the filter; if concurrent workers share a cache, retry " + f"after the first worker completes. Detail: {repr(e)}" + ) from e + except HfHubHTTPError as e: + status = getattr(getattr(e, "response", None), "status_code", None) + if status == 401: + raise RuntimeError( + f"Unauthorized loading '{model_id}@{revision}' — the model may be " + f"gated or require an HF_TOKEN. Set HF_TOKEN and retry. Detail: {repr(e)}" + ) from e + raise RuntimeError( + f"Could not download '{model_id}@{revision}' from HuggingFace Hub: {repr(e)}" + ) from e + except ValueError as e: + raise RuntimeError( + f"Model '{model_id}@{revision}' could not be loaded for {task}. " + f"Detail: {repr(e)}" + ) from e + except Exception as e: + raise RuntimeError( + f"Unexpected failure loading '{model_id}@{revision}' for {task}: {repr(e)}" + ) from e diff --git a/filter_huggingface_vision/backends/grounding_dino.py b/filter_huggingface_vision/backends/grounding_dino.py index 3380406..787491a 100644 --- a/filter_huggingface_vision/backends/grounding_dino.py +++ b/filter_huggingface_vision/backends/grounding_dino.py @@ -4,6 +4,7 @@ from filter_huggingface_vision.utils import get_config_value, resolve_device +from ._hf_load_errors import hf_load_error_handler from .base import VisionBackend logger = logging.getLogger(__name__) @@ -77,12 +78,13 @@ def load(self, config): model_id = get_config_value(config, "model_id") revision = (get_config_value(config, "revision") or "").strip() or "main" # Never allow trust_remote_code at load time (security); filter normalize_config rejects it, backend enforces it if used directly. - self._processor = AutoProcessor.from_pretrained( - model_id, revision=revision, trust_remote_code=False - ) - self._model = AutoModelForZeroShotObjectDetection.from_pretrained( - model_id, revision=revision, trust_remote_code=False - ) + with hf_load_error_handler(model_id, revision, "open-vocabulary detection (grounding-dino)"): + self._processor = AutoProcessor.from_pretrained( + model_id, revision=revision, trust_remote_code=False + ) + self._model = AutoModelForZeroShotObjectDetection.from_pretrained( + model_id, revision=revision, trust_remote_code=False + ) self._model = self._model.to(self._device) self._model.eval() self._revision = revision diff --git a/filter_huggingface_vision/backends/image_classification.py b/filter_huggingface_vision/backends/image_classification.py index 4230622..84f0888 100644 --- a/filter_huggingface_vision/backends/image_classification.py +++ b/filter_huggingface_vision/backends/image_classification.py @@ -4,6 +4,7 @@ from filter_huggingface_vision.utils import get_config_value, resolve_device +from ._hf_load_errors import hf_load_error_handler from .base import VisionBackend logger = logging.getLogger(__name__) @@ -48,19 +49,13 @@ def load(self, config): if not revision: raise ValueError("revision is required and must be non-empty.") # Never allow trust_remote_code at load time (security); filter normalize_config rejects it, backend enforces it if used directly. - try: + with hf_load_error_handler(model_id, revision, "image classification"): self._processor = AutoImageProcessor.from_pretrained( model_id, revision=revision, trust_remote_code=False ) self._model = AutoModelForImageClassification.from_pretrained( model_id, revision=revision, trust_remote_code=False ) - except Exception as e: - raise RuntimeError( - f"Model {model_id} (revision={revision}) is not compatible with " - "AutoImageProcessor + AutoModelForImageClassification. " - "Use a model supported by the Transformers image-classification API." - ) from e self._model = self._model.to(self._device) self._model.eval() diff --git a/filter_huggingface_vision/backends/object_detection.py b/filter_huggingface_vision/backends/object_detection.py index 85b4148..63ff9ce 100644 --- a/filter_huggingface_vision/backends/object_detection.py +++ b/filter_huggingface_vision/backends/object_detection.py @@ -4,6 +4,7 @@ from filter_huggingface_vision.utils import get_config_value, resolve_device +from ._hf_load_errors import hf_load_error_handler from .base import VisionBackend logger = logging.getLogger(__name__) @@ -79,25 +80,13 @@ def load(self, config): if not revision: raise ValueError("revision is required and must be non-empty.") # Never allow trust_remote_code at load time (security); filter normalize_config rejects it, backend enforces it if used directly. - try: + with hf_load_error_handler(model_id, revision, "object detection"): self._processor = AutoImageProcessor.from_pretrained( model_id, revision=revision, trust_remote_code=False ) self._model = AutoModelForObjectDetection.from_pretrained( model_id, revision=revision, trust_remote_code=False ) - except ImportError as e: - if "timm" in str(e).lower(): - raise ImportError( - f"DETR/Conditional DETR models (e.g. {model_id}) require the timm library. " - "Install it with: pip install timm" - ) from e - raise - except Exception as e: - raise RuntimeError( - f"Model {model_id} (revision={revision}) is not compatible with AutoImageProcessor + AutoModelForObjectDetection. " - "Use a model supported by the Transformers object-detection API, or enable fallback when available." - ) from e self._model = self._model.to(self._device) self._model.eval() diff --git a/filter_huggingface_vision/backends/owlvit.py b/filter_huggingface_vision/backends/owlvit.py index 7c09cbd..48dfcad 100644 --- a/filter_huggingface_vision/backends/owlvit.py +++ b/filter_huggingface_vision/backends/owlvit.py @@ -4,6 +4,7 @@ from filter_huggingface_vision.utils import get_config_value, resolve_device +from ._hf_load_errors import hf_load_error_handler from .base import VisionBackend logger = logging.getLogger(__name__) @@ -69,12 +70,13 @@ def load(self, config): model_id = get_config_value(config, "model_id") revision = (get_config_value(config, "revision") or "").strip() or "main" # Never allow trust_remote_code at load time (security); filter normalize_config rejects it, backend enforces it if used directly. - self._processor = OwlViTProcessor.from_pretrained( - model_id, revision=revision, trust_remote_code=False - ) - self._model = OwlViTForObjectDetection.from_pretrained( - model_id, revision=revision, trust_remote_code=False - ) + with hf_load_error_handler(model_id, revision, "zero-shot detection (owl-vit)"): + self._processor = OwlViTProcessor.from_pretrained( + model_id, revision=revision, trust_remote_code=False + ) + self._model = OwlViTForObjectDetection.from_pretrained( + model_id, revision=revision, trust_remote_code=False + ) self._model = self._model.to(self._device) self._model.eval() self._revision = revision diff --git a/pyproject.toml b/pyproject.toml index 2a0c197..62fc6e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ dependencies = [ "timm", "sentencepiece", "pillow", - "huggingface-hub", + "huggingface-hub>=0.23", "python-dotenv", ] @@ -48,6 +48,9 @@ dev = [ "pytest-cov==7.0.0", ] +[tool.pytest.ini_options] +pythonpath = ["tests"] + [tool.black] skip-string-normalization = true diff --git a/tests/_hf_test_utils.py b/tests/_hf_test_utils.py new file mode 100644 index 0000000..ca6f2c5 --- /dev/null +++ b/tests/_hf_test_utils.py @@ -0,0 +1,14 @@ +"""Shared test utilities for HuggingFace Hub error-handling tests.""" +from unittest.mock import MagicMock + + +def make_hf_error(cls, message, status_code=404): + """Build a huggingface_hub error instance with a minimal mock response + attached, suitable for use in backend load-error tests. + + Pass `status_code` when a test needs to simulate something other than 404 + (e.g. 401 for gated access, 503 for transient Hub failures). + """ + mock_response = MagicMock() + mock_response.status_code = status_code + return cls(message, response=mock_response) diff --git a/tests/test_grounding_dino.py b/tests/test_grounding_dino.py index 81233e7..9131ebf 100644 --- a/tests/test_grounding_dino.py +++ b/tests/test_grounding_dino.py @@ -2,6 +2,7 @@ """Tests for open-vocabulary object detection task (Grounding DINO, no model download).""" import unittest +from unittest.mock import patch from filter_huggingface_vision.backends.grounding_dino import ( _normalize_results as _normalize_results_grounding, @@ -10,6 +11,7 @@ FilterHuggingfaceVision, FilterHuggingfaceVisionConfig, ) +from _hf_test_utils import make_hf_error class TestNormalizeResultsGrounding(unittest.TestCase): @@ -78,5 +80,50 @@ def test_normalize_config_accepts_open_vocabulary_grounding_with_text_labels(sel self.assertIn("person", config.text_labels[0][0]) +class TestGroundingDinoBackendLoadErrors(unittest.TestCase): + """Load-error surface for GroundingDinoBackend (mirrors object_detection / image_classification).""" + + _CONFIG = {"model_id": "org/model", "revision": "abc123", "device": "cpu"} + + def _load_backend(self): + from filter_huggingface_vision.backends.grounding_dino import ( + GroundingDinoBackend, + ) + + GroundingDinoBackend().load(self._CONFIG) + + def test_repository_not_found_error_message(self): + from huggingface_hub.utils import RepositoryNotFoundError + + exc = make_hf_error(RepositoryNotFoundError, "org/model") + with patch("transformers.AutoProcessor.from_pretrained", side_effect=exc): + with self.assertRaises(RuntimeError) as ctx: + self._load_backend() + msg = str(ctx.exception) + self.assertIn("org/model", msg) + self.assertIn("not found", msg) + self.assertIs(ctx.exception.__cause__, exc) + + def test_revision_not_found_error_message(self): + from huggingface_hub.utils import RevisionNotFoundError + + exc = make_hf_error(RevisionNotFoundError, "abc123") + with patch("transformers.AutoProcessor.from_pretrained", side_effect=exc): + with self.assertRaises(RuntimeError) as ctx: + self._load_backend() + self.assertIn("abc123", str(ctx.exception)) + + def test_value_error_is_wrapped(self): + with patch( + "transformers.AutoProcessor.from_pretrained", + side_effect=ValueError("unrecognized"), + ): + with self.assertRaises(RuntimeError) as ctx: + self._load_backend() + msg = str(ctx.exception) + self.assertIn("org/model", msg) + self.assertIn("open-vocabulary detection (grounding-dino)", msg) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_image_classification.py b/tests/test_image_classification.py index e3fc4bd..f3617e0 100644 --- a/tests/test_image_classification.py +++ b/tests/test_image_classification.py @@ -2,7 +2,7 @@ """Tests for image classification task (no model download).""" import unittest -from unittest.mock import patch +from unittest.mock import MagicMock, patch from filter_huggingface_vision.backends.image_classification import ( _logits_to_classifications, @@ -11,6 +11,7 @@ FilterHuggingfaceVision, FilterHuggingfaceVisionConfig, ) +from _hf_test_utils import make_hf_error class TestLogitsToClassifications(unittest.TestCase): @@ -205,5 +206,189 @@ def test_normalize_config_image_classification_rejects_invalid_top_k(self): ) +class TestImageClassificationBackendLoadErrors(unittest.TestCase): + """Unit tests for structured error messages on ImageClassificationBackend.load() failures.""" + + _CONFIG = {"model_id": "org/model", "revision": "abc123", "device": "cpu"} + + def _load_backend(self): + from filter_huggingface_vision.backends.image_classification import ( + ImageClassificationBackend, + ) + + ImageClassificationBackend().load(self._CONFIG) + + # --- ImportError branches (S4 allowlist) --- + + def test_timm_import_error_gives_actionable_message(self): + with patch( + "transformers.AutoImageProcessor.from_pretrained", + side_effect=ImportError("No module named 'timm'"), + ): + with self.assertRaises(ImportError) as ctx: + self._load_backend() + self.assertIn("timm", str(ctx.exception)) + self.assertIn("pip install timm", str(ctx.exception)) + + def test_sentencepiece_import_error_gives_actionable_message(self): + with patch( + "transformers.AutoImageProcessor.from_pretrained", + side_effect=ImportError("No module named 'sentencepiece'"), + ): + with self.assertRaises(ImportError) as ctx: + self._load_backend() + self.assertIn("sentencepiece", str(ctx.exception)) + self.assertIn("pip install sentencepiece", str(ctx.exception)) + + def test_unknown_import_error_is_reraised_unchanged(self): + original = ImportError("No module named 'some_other_dep'") + with patch( + "transformers.AutoImageProcessor.from_pretrained", + side_effect=original, + ): + with self.assertRaises(ImportError) as ctx: + self._load_backend() + self.assertIs(ctx.exception, original) + + # --- HuggingFace Hub error branches --- + + def test_repository_not_found_error_message(self): + from huggingface_hub.utils import RepositoryNotFoundError + + exc = make_hf_error(RepositoryNotFoundError, "org/model") + with patch("transformers.AutoImageProcessor.from_pretrained", side_effect=exc): + with self.assertRaises(RuntimeError) as ctx: + self._load_backend() + msg = str(ctx.exception) + self.assertIn("org/model", msg) + self.assertIn("not found", msg) + + def test_repository_not_found_on_model_download_gives_actionable_message(self): + from huggingface_hub.utils import RepositoryNotFoundError + + exc = make_hf_error(RepositoryNotFoundError, "org/model") + with patch( + "transformers.AutoImageProcessor.from_pretrained", + return_value=MagicMock(), + ): + with patch( + "transformers.AutoModelForImageClassification.from_pretrained", + side_effect=exc, + ): + with self.assertRaises(RuntimeError) as ctx: + self._load_backend() + msg = str(ctx.exception) + self.assertIn("org/model", msg) + self.assertIn("not found", msg) + self.assertIs(ctx.exception.__cause__, exc) + + def test_revision_not_found_error_message(self): + from huggingface_hub.utils import RevisionNotFoundError + + exc = make_hf_error(RevisionNotFoundError, "abc123") + with patch("transformers.AutoImageProcessor.from_pretrained", side_effect=exc): + with self.assertRaises(RuntimeError) as ctx: + self._load_backend() + msg = str(ctx.exception) + self.assertIn("abc123", msg) + self.assertIn("Revision", msg) + + def test_gated_repo_error_message(self): + from huggingface_hub.utils import GatedRepoError + + exc = make_hf_error(GatedRepoError, "org/model") + with patch("transformers.AutoImageProcessor.from_pretrained", side_effect=exc): + with self.assertRaises(RuntimeError) as ctx: + self._load_backend() + msg = str(ctx.exception) + self.assertIn("license", msg) + self.assertIn("HF_TOKEN", msg) + + def test_hf_hub_http_error_includes_repr(self): + from huggingface_hub.utils import HfHubHTTPError + + exc = make_hf_error(HfHubHTTPError, "503 Service Unavailable") + with patch("transformers.AutoImageProcessor.from_pretrained", side_effect=exc): + with self.assertRaises(RuntimeError) as ctx: + self._load_backend() + msg = str(ctx.exception) + self.assertIn("org/model", msg) + # The point of this test is the "includes_repr" guarantee: assert the + # full repr is present rather than a substring that happens to overlap + # with the surrounding human-readable copy. + self.assertIn(repr(exc), msg) + + def test_hf_hub_http_401_hints_at_hf_token(self): + from huggingface_hub.utils import HfHubHTTPError + + exc = make_hf_error(HfHubHTTPError, "401 Unauthorized", status_code=401) + with patch("transformers.AutoImageProcessor.from_pretrained", side_effect=exc): + with self.assertRaises(RuntimeError) as ctx: + self._load_backend() + msg = str(ctx.exception) + self.assertIn("HF_TOKEN", msg) + self.assertIn("gated", msg) + + def test_local_entry_not_found_error_message(self): + from huggingface_hub.utils import LocalEntryNotFoundError + + exc = LocalEntryNotFoundError("cache miss for org/model") + with patch("transformers.AutoImageProcessor.from_pretrained", side_effect=exc): + with self.assertRaises(RuntimeError) as ctx: + self._load_backend() + msg = str(ctx.exception) + self.assertIn("cache", msg) + self.assertIn("org/model", msg) + + def test_entry_not_found_error_message(self): + from huggingface_hub.utils import EntryNotFoundError + + exc = EntryNotFoundError("entry not found for org/model") + with patch("transformers.AutoImageProcessor.from_pretrained", side_effect=exc): + with self.assertRaises(RuntimeError) as ctx: + self._load_backend() + msg = str(ctx.exception) + self.assertIn("cache", msg) + self.assertIn("org/model", msg) + + # --- ValueError / config-parse branch --- + + def test_value_error_gives_incompatibility_message_with_repr(self): + with patch( + "transformers.AutoImageProcessor.from_pretrained", + side_effect=ValueError("unrecognized architecture"), + ): + with self.assertRaises(RuntimeError) as ctx: + self._load_backend() + msg = str(ctx.exception) + self.assertIn("image classification", msg) + self.assertIn("unrecognized architecture", msg) + + # --- Fallback branch --- + + def test_unexpected_exception_includes_repr(self): + with patch( + "transformers.AutoImageProcessor.from_pretrained", + side_effect=OSError("disk full"), + ): + with self.assertRaises(RuntimeError) as ctx: + self._load_backend() + msg = str(ctx.exception) + self.assertIn("Unexpected", msg) + self.assertIn("disk full", msg) + + # --- Chained cause is preserved --- + + def test_original_cause_is_chained(self): + original = ValueError("root cause") + with patch( + "transformers.AutoImageProcessor.from_pretrained", + side_effect=original, + ): + with self.assertRaises(RuntimeError) as ctx: + self._load_backend() + self.assertIs(ctx.exception.__cause__, original) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_object_detection.py b/tests/test_object_detection.py index 1a5fe0d..f58b915 100644 --- a/tests/test_object_detection.py +++ b/tests/test_object_detection.py @@ -2,12 +2,14 @@ """Tests for closed-vocabulary object detection task (no model download).""" import unittest +from unittest.mock import MagicMock, patch from filter_huggingface_vision.backends.object_detection import _normalize_detections from filter_huggingface_vision.filter import ( FilterHuggingfaceVision, FilterHuggingfaceVisionConfig, ) +from _hf_test_utils import make_hf_error class TestNormalizeDetections(unittest.TestCase): @@ -103,5 +105,189 @@ def test_normalize_config_accepts_valid_config(self): self.assertEqual(config.threshold, 0.3) +class TestObjectDetectionBackendLoadErrors(unittest.TestCase): + """Unit tests for structured error messages on ObjectDetectionBackend.load() failures.""" + + _CONFIG = {"model_id": "org/model", "revision": "abc123", "device": "cpu"} + + def _load_backend(self): + from filter_huggingface_vision.backends.object_detection import ( + ObjectDetectionBackend, + ) + + ObjectDetectionBackend().load(self._CONFIG) + + # --- ImportError branches (S4 allowlist) --- + + def test_timm_import_error_gives_actionable_message(self): + with patch( + "transformers.AutoImageProcessor.from_pretrained", + side_effect=ImportError("No module named 'timm'"), + ): + with self.assertRaises(ImportError) as ctx: + self._load_backend() + self.assertIn("timm", str(ctx.exception)) + self.assertIn("pip install timm", str(ctx.exception)) + + def test_sentencepiece_import_error_gives_actionable_message(self): + with patch( + "transformers.AutoImageProcessor.from_pretrained", + side_effect=ImportError("No module named 'sentencepiece'"), + ): + with self.assertRaises(ImportError) as ctx: + self._load_backend() + self.assertIn("sentencepiece", str(ctx.exception)) + self.assertIn("pip install sentencepiece", str(ctx.exception)) + + def test_unknown_import_error_is_reraised_unchanged(self): + original = ImportError("No module named 'some_other_dep'") + with patch( + "transformers.AutoImageProcessor.from_pretrained", + side_effect=original, + ): + with self.assertRaises(ImportError) as ctx: + self._load_backend() + self.assertIs(ctx.exception, original) + + # --- HuggingFace Hub error branches --- + + def test_repository_not_found_error_message(self): + from huggingface_hub.utils import RepositoryNotFoundError + + exc = make_hf_error(RepositoryNotFoundError, "org/model") + with patch("transformers.AutoImageProcessor.from_pretrained", side_effect=exc): + with self.assertRaises(RuntimeError) as ctx: + self._load_backend() + msg = str(ctx.exception) + self.assertIn("org/model", msg) + self.assertIn("not found", msg) + + def test_repository_not_found_on_model_download_gives_actionable_message(self): + from huggingface_hub.utils import RepositoryNotFoundError + + exc = make_hf_error(RepositoryNotFoundError, "org/model") + with patch( + "transformers.AutoImageProcessor.from_pretrained", + return_value=MagicMock(), + ): + with patch( + "transformers.AutoModelForObjectDetection.from_pretrained", + side_effect=exc, + ): + with self.assertRaises(RuntimeError) as ctx: + self._load_backend() + msg = str(ctx.exception) + self.assertIn("org/model", msg) + self.assertIn("not found", msg) + self.assertIs(ctx.exception.__cause__, exc) + + def test_revision_not_found_error_message(self): + from huggingface_hub.utils import RevisionNotFoundError + + exc = make_hf_error(RevisionNotFoundError, "abc123") + with patch("transformers.AutoImageProcessor.from_pretrained", side_effect=exc): + with self.assertRaises(RuntimeError) as ctx: + self._load_backend() + msg = str(ctx.exception) + self.assertIn("abc123", msg) + self.assertIn("Revision", msg) + + def test_gated_repo_error_message(self): + from huggingface_hub.utils import GatedRepoError + + exc = make_hf_error(GatedRepoError, "org/model") + with patch("transformers.AutoImageProcessor.from_pretrained", side_effect=exc): + with self.assertRaises(RuntimeError) as ctx: + self._load_backend() + msg = str(ctx.exception) + self.assertIn("license", msg) + self.assertIn("HF_TOKEN", msg) + + def test_hf_hub_http_error_includes_repr(self): + from huggingface_hub.utils import HfHubHTTPError + + exc = make_hf_error(HfHubHTTPError, "503 Service Unavailable") + with patch("transformers.AutoImageProcessor.from_pretrained", side_effect=exc): + with self.assertRaises(RuntimeError) as ctx: + self._load_backend() + msg = str(ctx.exception) + self.assertIn("org/model", msg) + # The point of this test is the "includes_repr" guarantee: assert the + # full repr is present rather than a substring that happens to overlap + # with the surrounding human-readable copy. + self.assertIn(repr(exc), msg) + + def test_hf_hub_http_401_hints_at_hf_token(self): + from huggingface_hub.utils import HfHubHTTPError + + exc = make_hf_error(HfHubHTTPError, "401 Unauthorized", status_code=401) + with patch("transformers.AutoImageProcessor.from_pretrained", side_effect=exc): + with self.assertRaises(RuntimeError) as ctx: + self._load_backend() + msg = str(ctx.exception) + self.assertIn("HF_TOKEN", msg) + self.assertIn("gated", msg) + + def test_local_entry_not_found_error_message(self): + from huggingface_hub.utils import LocalEntryNotFoundError + + exc = LocalEntryNotFoundError("cache miss for org/model") + with patch("transformers.AutoImageProcessor.from_pretrained", side_effect=exc): + with self.assertRaises(RuntimeError) as ctx: + self._load_backend() + msg = str(ctx.exception) + self.assertIn("cache", msg) + self.assertIn("org/model", msg) + + def test_entry_not_found_error_message(self): + from huggingface_hub.utils import EntryNotFoundError + + exc = EntryNotFoundError("entry not found for org/model") + with patch("transformers.AutoImageProcessor.from_pretrained", side_effect=exc): + with self.assertRaises(RuntimeError) as ctx: + self._load_backend() + msg = str(ctx.exception) + self.assertIn("cache", msg) + self.assertIn("org/model", msg) + + # --- ValueError / config-parse branch --- + + def test_value_error_gives_incompatibility_message_with_repr(self): + with patch( + "transformers.AutoImageProcessor.from_pretrained", + side_effect=ValueError("unrecognized architecture"), + ): + with self.assertRaises(RuntimeError) as ctx: + self._load_backend() + msg = str(ctx.exception) + self.assertIn("object detection", msg) + self.assertIn("unrecognized architecture", msg) + + # --- Fallback branch --- + + def test_unexpected_exception_includes_repr(self): + with patch( + "transformers.AutoImageProcessor.from_pretrained", + side_effect=OSError("disk full"), + ): + with self.assertRaises(RuntimeError) as ctx: + self._load_backend() + msg = str(ctx.exception) + self.assertIn("Unexpected", msg) + self.assertIn("disk full", msg) + + # --- Chained cause is preserved --- + + def test_original_cause_is_chained(self): + original = ValueError("root cause") + with patch( + "transformers.AutoImageProcessor.from_pretrained", + side_effect=original, + ): + with self.assertRaises(RuntimeError) as ctx: + self._load_backend() + self.assertIs(ctx.exception.__cause__, original) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_owlvit.py b/tests/test_owlvit.py index 1b0d2d7..56252b1 100644 --- a/tests/test_owlvit.py +++ b/tests/test_owlvit.py @@ -2,6 +2,7 @@ """Tests for open-vocabulary object detection task (OWL-ViT, no model download).""" import unittest +from unittest.mock import patch from filter_huggingface_vision.backends.owlvit import ( _normalize_results as _normalize_results_owlvit, @@ -10,6 +11,7 @@ FilterHuggingfaceVision, FilterHuggingfaceVisionConfig, ) +from _hf_test_utils import make_hf_error class TestNormalizeResultsOwlvit(unittest.TestCase): @@ -89,5 +91,48 @@ def test_normalize_config_accepts_open_vocabulary_with_text_labels(self): self.assertIn("cat", config.text_labels[0][0]) +class TestOwlVitBackendLoadErrors(unittest.TestCase): + """Load-error surface for OwlVitBackend (mirrors object_detection / image_classification).""" + + _CONFIG = {"model_id": "org/model", "revision": "abc123", "device": "cpu"} + + def _load_backend(self): + from filter_huggingface_vision.backends.owlvit import OwlVitBackend + + OwlVitBackend().load(self._CONFIG) + + def test_repository_not_found_error_message(self): + from huggingface_hub.utils import RepositoryNotFoundError + + exc = make_hf_error(RepositoryNotFoundError, "org/model") + with patch("transformers.OwlViTProcessor.from_pretrained", side_effect=exc): + with self.assertRaises(RuntimeError) as ctx: + self._load_backend() + msg = str(ctx.exception) + self.assertIn("org/model", msg) + self.assertIn("not found", msg) + self.assertIs(ctx.exception.__cause__, exc) + + def test_revision_not_found_error_message(self): + from huggingface_hub.utils import RevisionNotFoundError + + exc = make_hf_error(RevisionNotFoundError, "abc123") + with patch("transformers.OwlViTProcessor.from_pretrained", side_effect=exc): + with self.assertRaises(RuntimeError) as ctx: + self._load_backend() + self.assertIn("abc123", str(ctx.exception)) + + def test_value_error_is_wrapped(self): + with patch( + "transformers.OwlViTProcessor.from_pretrained", + side_effect=ValueError("bad config"), + ): + with self.assertRaises(RuntimeError) as ctx: + self._load_backend() + msg = str(ctx.exception) + self.assertIn("org/model", msg) + self.assertIn("zero-shot detection (owl-vit)", msg) + + if __name__ == "__main__": unittest.main() diff --git a/uv.lock b/uv.lock index e14805e..74db112 100644 --- a/uv.lock +++ b/uv.lock @@ -582,9 +582,9 @@ requires-dist = [ { name = "black", marker = "extra == 'dev'", specifier = "==26.3.1" }, { name = "build", marker = "extra == 'dev'", specifier = "==1.4.0" }, { name = "flake8", marker = "extra == 'dev'", specifier = "==7.3.0" }, - { name = "huggingface-hub" }, + { name = "huggingface-hub", specifier = ">=0.23" }, { name = "isort", marker = "extra == 'dev'", specifier = "==7.0.0" }, - { name = "openfilter", extras = ["all"], specifier = ">=0.1.26" }, + { name = "openfilter", extras = ["all"], specifier = ">=0.1.27" }, { name = "pillow" }, { name = "pytest", marker = "extra == 'dev'", specifier = "==9.0.3" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = "==7.0.0" },