From 84a5debf5de61b6d2d5b61dbf7641c560bdfa379 Mon Sep 17 00:00:00 2001 From: Rui Andrada Date: Thu, 23 Apr 2026 16:50:13 -0300 Subject: [PATCH 1/9] fix(backends): surface real cause on HF model load failures (not just "not compatible") MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the single `except Exception` catch block in both backend `load()` methods into five distinct, actionable branches. Applies to both `object_detection.py` (AutoModelForObjectDetection) and `image_classification.py` (AutoModelForImageClassification). Exception branches added (in catch order): 1. ImportError + "timm" → clear "pip install timm" hint. 2. ImportError (other) → re-raise unchanged so missing-dep errors surface. 3. GatedRepoError → "accept license with authenticated token" message (caught before RepositoryNotFoundError because it is a subclass of it). 4. RepositoryNotFoundError → model ID / HF_TOKEN hint. 5. RevisionNotFoundError → revision (SHA/tag/branch) hint. 6. HfHubHTTPError → generic download failure with repr(e). 7. ValueError → architecture/config mismatch message with repr(e). 8. Exception (fallback) → generic message now includes repr(e) so the real cause is always visible in logs. All raises use `from e` to preserve the original traceback. HF Hub exceptions are imported at module level via `from huggingface_hub import errors as _hf_errors` (huggingface_hub 1.10.1, where these classes live in `huggingface_hub.errors`). New test class `TestObjectDetectionBackendLoadErrors` and `TestImageClassificationBackendLoadErrors` added to the respective test files; each class covers all eight branches by mocking `transformers.AutoImageProcessor.from_pretrained`. All 75 tests pass; flake8 reports no issues. Also syncs uv.lock with pyproject.toml's existing `openfilter[all]>=0.1.27` pin (lockfile was one patch behind on main). Signed-off-by: Rui Andrada --- .../backends/image_classification.py | 38 +++++- .../backends/object_detection.py | 30 ++++- tests/test_image_classification.py | 125 +++++++++++++++++- tests/test_object_detection.py | 124 +++++++++++++++++ uv.lock | 2 +- 5 files changed, 312 insertions(+), 7 deletions(-) diff --git a/filter_huggingface_vision/backends/image_classification.py b/filter_huggingface_vision/backends/image_classification.py index 4230622..ea7c068 100644 --- a/filter_huggingface_vision/backends/image_classification.py +++ b/filter_huggingface_vision/backends/image_classification.py @@ -2,6 +2,8 @@ import logging +from huggingface_hub import errors as _hf_errors + from filter_huggingface_vision.utils import get_config_value, resolve_device from .base import VisionBackend @@ -55,11 +57,41 @@ def load(self, config): self._model = AutoModelForImageClassification.from_pretrained( model_id, revision=revision, trust_remote_code=False ) + except ImportError as e: + if "timm" in str(e).lower(): + raise ImportError( + f"This model (e.g. {model_id}) requires the timm library. " + "Install it with: pip install timm" + ) from e + raise + except _hf_errors.GatedRepoError as e: + raise RuntimeError( + f"Access to '{model_id}' requires accepting its license on the Hub. " + "Accept it with an authenticated token." + ) from e + except _hf_errors.RepositoryNotFoundError as e: + raise RuntimeError( + f"HuggingFace repository '{model_id}' not found. " + "Check the model ID or your HF_TOKEN if the repo is gated." + ) from e + except _hf_errors.RevisionNotFoundError as e: + raise RuntimeError( + f"Revision '{revision}' not found in '{model_id}'. " + "Check the revision (commit SHA, tag, or branch)." + ) from e + except _hf_errors.HfHubHTTPError as 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}' could not be loaded as an image-classification model " + "(AutoImageProcessor + AutoModelForImageClassification). " + f"Check that the model's architecture exposes an image-classification head: {repr(e)}" + ) from e 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." + f"Unexpected failure loading '{model_id}@{revision}' for image classification: {repr(e)}" ) from e self._model = self._model.to(self._device) diff --git a/filter_huggingface_vision/backends/object_detection.py b/filter_huggingface_vision/backends/object_detection.py index 85b4148..5df2408 100644 --- a/filter_huggingface_vision/backends/object_detection.py +++ b/filter_huggingface_vision/backends/object_detection.py @@ -2,6 +2,8 @@ import logging +from huggingface_hub import errors as _hf_errors + from filter_huggingface_vision.utils import get_config_value, resolve_device from .base import VisionBackend @@ -93,10 +95,34 @@ def load(self, config): "Install it with: pip install timm" ) from e raise + except _hf_errors.GatedRepoError as e: + raise RuntimeError( + f"Access to '{model_id}' requires accepting its license on the Hub. " + "Accept it with an authenticated token." + ) from e + except _hf_errors.RepositoryNotFoundError as e: + raise RuntimeError( + f"HuggingFace repository '{model_id}' not found. " + "Check the model ID or your HF_TOKEN if the repo is gated." + ) from e + except _hf_errors.RevisionNotFoundError as e: + raise RuntimeError( + f"Revision '{revision}' not found in '{model_id}'. " + "Check the revision (commit SHA, tag, or branch)." + ) from e + except _hf_errors.HfHubHTTPError as 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}' could not be loaded as an object-detection model " + "(AutoImageProcessor + AutoModelForObjectDetection). " + f"Check that the model's architecture exposes an object-detection head: {repr(e)}" + ) from e 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." + f"Unexpected failure loading '{model_id}@{revision}' for object detection: {repr(e)}" ) from e self._model = self._model.to(self._device) diff --git a/tests/test_image_classification.py b/tests/test_image_classification.py index e3fc4bd..ef50031 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, @@ -205,5 +205,128 @@ 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 --- + + 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_non_timm_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 _make_hf_error(self, cls, message): + mock_response = MagicMock() + mock_response.status_code = 404 + return cls(message, response=mock_response) + + def test_repository_not_found_error_message(self): + from huggingface_hub import errors as _hf_errors + + exc = self._make_hf_error(_hf_errors.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_revision_not_found_error_message(self): + from huggingface_hub import errors as _hf_errors + + exc = self._make_hf_error(_hf_errors.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 import errors as _hf_errors + + exc = self._make_hf_error(_hf_errors.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) + + def test_hf_hub_http_error_includes_repr(self): + from huggingface_hub import errors as _hf_errors + + exc = self._make_hf_error(_hf_errors.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) + self.assertIn("HuggingFace Hub", 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..aa922a2 100644 --- a/tests/test_object_detection.py +++ b/tests/test_object_detection.py @@ -2,6 +2,7 @@ """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 ( @@ -103,5 +104,128 @@ 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 --- + + 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_non_timm_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 _make_hf_error(self, cls, message): + mock_response = MagicMock() + mock_response.status_code = 404 + return cls(message, response=mock_response) + + def test_repository_not_found_error_message(self): + from huggingface_hub import errors as _hf_errors + + exc = self._make_hf_error(_hf_errors.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_revision_not_found_error_message(self): + from huggingface_hub import errors as _hf_errors + + exc = self._make_hf_error(_hf_errors.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 import errors as _hf_errors + + exc = self._make_hf_error(_hf_errors.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) + + def test_hf_hub_http_error_includes_repr(self): + from huggingface_hub import errors as _hf_errors + + exc = self._make_hf_error(_hf_errors.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) + self.assertIn("HuggingFace Hub", 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/uv.lock b/uv.lock index e14805e..fa9bc51 100644 --- a/uv.lock +++ b/uv.lock @@ -584,7 +584,7 @@ requires-dist = [ { name = "flake8", marker = "extra == 'dev'", specifier = "==7.3.0" }, { name = "huggingface-hub" }, { 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" }, From ec9ac7df1a32db43df1c4c8e399b7f4f631a1078 Mon Sep 17 00:00:00 2001 From: Rui Andrada Date: Thu, 23 Apr 2026 17:19:14 -0300 Subject: [PATCH 2/9] test(backends): anchor model-download path with model-side patch tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer nit from PR #21 — keeps both from_pretrained call sites covered if the single try/except ever gets split. Signed-off-by: Rui Andrada --- tests/test_image_classification.py | 19 +++++++++++++++++++ tests/test_object_detection.py | 19 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/tests/test_image_classification.py b/tests/test_image_classification.py index ef50031..caffa14 100644 --- a/tests/test_image_classification.py +++ b/tests/test_image_classification.py @@ -257,6 +257,25 @@ def test_repository_not_found_error_message(self): 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 import errors as _hf_errors + + exc = self._make_hf_error(_hf_errors.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 import errors as _hf_errors diff --git a/tests/test_object_detection.py b/tests/test_object_detection.py index aa922a2..c5b3f1c 100644 --- a/tests/test_object_detection.py +++ b/tests/test_object_detection.py @@ -156,6 +156,25 @@ def test_repository_not_found_error_message(self): 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 import errors as _hf_errors + + exc = self._make_hf_error(_hf_errors.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 import errors as _hf_errors From 2eeb35bbc481219b74f41b468a196258ab1f3f0c Mon Sep 17 00:00:00 2001 From: Rui Andrada Date: Thu, 23 Apr 2026 17:43:48 -0300 Subject: [PATCH 3/9] refactor(backends): extract shared HF load-error handler; add repr(e) everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Leandro's review on PR #21 (review PRR_kwDORDqaqs74SxH4): 1. Shared exception handler (Leandro #1): extracted `hf_load_error_handler` into `filter_huggingface_vision/backends/_hf_load_errors.py` as a `@contextmanager`. Both `object_detection.py` and `image_classification.py` now wrap their two `from_pretrained` calls in `with hf_load_error_handler(...)`, eliminating the duplicated eight-branch try/except. Future huggingface_hub error types only need to be updated in one place. 2. repr(e) consistency (Leandro #2): `GatedRepoError`, `RepositoryNotFoundError`, and `RevisionNotFoundError` now include `Detail: {repr(e)}` in their messages, consistent with the HfHubHTTPError/ValueError/fallback branches. 3. Model-side patch coverage (Leandro #3): addressed in prior commit c3ecda6 per @lucasmundim's inline nit. 4. _make_hf_error deduplication (Leandro #4): moved to `tests/_hf_test_utils.py` as a module-level `make_hf_error` function; both test files import from there. Behavior unchanged — same messages, same exception mapping, de-duplicated. Bumps VERSION v0.4.4 → v0.4.5 (patch bump; behavioral fix, no API change) and adds the corresponding RELEASE.md entry to satisfy CI check-release-log. Signed-off-by: Rui Andrada --- RELEASE.md | 9 ++++ .../backends/_hf_load_errors.py | 53 +++++++++++++++++++ .../backends/image_classification.py | 41 +------------- .../backends/object_detection.py | 41 +------------- tests/_hf_test_utils.py | 10 ++++ tests/test_image_classification.py | 18 +++---- tests/test_object_detection.py | 18 +++---- 7 files changed, 90 insertions(+), 100 deletions(-) create mode 100644 filter_huggingface_vision/backends/_hf_load_errors.py create mode 100644 tests/_hf_test_utils.py diff --git a/RELEASE.md b/RELEASE.md index f5873b2..0b3adcf 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -14,6 +14,15 @@ Huggingface Vision filter release notes ## [Unreleased] +## v0.4.5 - 2026-04-23 + +### 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. +- Deduplicated the `make_hf_error` test helper into `tests/_hf_test_utils.py` (shared util). +- Synced `uv.lock` with `pyproject.toml`'s existing `openfilter[all]>=0.1.27` pin. + + ## v0.4.4 - 2026-04-20 ### Changed 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..62bfa39 --- /dev/null +++ b/filter_huggingface_vision/backends/_hf_load_errors.py @@ -0,0 +1,53 @@ +"""Shared HuggingFace Hub load-error handler for vision backends.""" +from contextlib import contextmanager + +from huggingface_hub import errors as _hf_errors + + +@contextmanager +def hf_load_error_handler(model_id: str, revision: str, task: str): + """Wraps AutoImageProcessor + AutoModelFor*.from_pretrained calls with + targeted, actionable error messages. + + GatedRepoError must be caught before RepositoryNotFoundError because it + is a subclass of it in huggingface_hub >= 0.22 / 1.x. + """ + try: + yield + except ImportError as e: + if "timm" in str(e).lower(): + raise ImportError( + f"This model (e.g. {model_id}) requires the timm library. " + "Install it with: pip install timm" + ) from e + raise + except _hf_errors.GatedRepoError as e: + raise RuntimeError( + f"Access to '{model_id}' requires accepting its license on the Hub. " + f"Accept it with an authenticated token. Detail: {repr(e)}" + ) from e + except _hf_errors.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 _hf_errors.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 + except _hf_errors.HfHubHTTPError as 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}' could not be loaded for {task}. " + f"Check that the model's architecture exposes the required head: {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/image_classification.py b/filter_huggingface_vision/backends/image_classification.py index ea7c068..84f0888 100644 --- a/filter_huggingface_vision/backends/image_classification.py +++ b/filter_huggingface_vision/backends/image_classification.py @@ -2,10 +2,9 @@ import logging -from huggingface_hub import errors as _hf_errors - 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__) @@ -50,49 +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 ImportError as e: - if "timm" in str(e).lower(): - raise ImportError( - f"This model (e.g. {model_id}) requires the timm library. " - "Install it with: pip install timm" - ) from e - raise - except _hf_errors.GatedRepoError as e: - raise RuntimeError( - f"Access to '{model_id}' requires accepting its license on the Hub. " - "Accept it with an authenticated token." - ) from e - except _hf_errors.RepositoryNotFoundError as e: - raise RuntimeError( - f"HuggingFace repository '{model_id}' not found. " - "Check the model ID or your HF_TOKEN if the repo is gated." - ) from e - except _hf_errors.RevisionNotFoundError as e: - raise RuntimeError( - f"Revision '{revision}' not found in '{model_id}'. " - "Check the revision (commit SHA, tag, or branch)." - ) from e - except _hf_errors.HfHubHTTPError as 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}' could not be loaded as an image-classification model " - "(AutoImageProcessor + AutoModelForImageClassification). " - f"Check that the model's architecture exposes an image-classification head: {repr(e)}" - ) from e - except Exception as e: - raise RuntimeError( - f"Unexpected failure loading '{model_id}@{revision}' for image classification: {repr(e)}" - ) 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 5df2408..63ff9ce 100644 --- a/filter_huggingface_vision/backends/object_detection.py +++ b/filter_huggingface_vision/backends/object_detection.py @@ -2,10 +2,9 @@ import logging -from huggingface_hub import errors as _hf_errors - 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__) @@ -81,49 +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 _hf_errors.GatedRepoError as e: - raise RuntimeError( - f"Access to '{model_id}' requires accepting its license on the Hub. " - "Accept it with an authenticated token." - ) from e - except _hf_errors.RepositoryNotFoundError as e: - raise RuntimeError( - f"HuggingFace repository '{model_id}' not found. " - "Check the model ID or your HF_TOKEN if the repo is gated." - ) from e - except _hf_errors.RevisionNotFoundError as e: - raise RuntimeError( - f"Revision '{revision}' not found in '{model_id}'. " - "Check the revision (commit SHA, tag, or branch)." - ) from e - except _hf_errors.HfHubHTTPError as 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}' could not be loaded as an object-detection model " - "(AutoImageProcessor + AutoModelForObjectDetection). " - f"Check that the model's architecture exposes an object-detection head: {repr(e)}" - ) from e - except Exception as e: - raise RuntimeError( - f"Unexpected failure loading '{model_id}@{revision}' for object detection: {repr(e)}" - ) from e self._model = self._model.to(self._device) self._model.eval() diff --git a/tests/_hf_test_utils.py b/tests/_hf_test_utils.py new file mode 100644 index 0000000..b57748c --- /dev/null +++ b/tests/_hf_test_utils.py @@ -0,0 +1,10 @@ +"""Shared test utilities for HuggingFace Hub error-handling tests.""" +from unittest.mock import MagicMock + + +def make_hf_error(cls, message): + """Build a huggingface_hub error instance with a minimal mock response + attached, suitable for use in backend load-error tests.""" + mock_response = MagicMock() + mock_response.status_code = 404 + return cls(message, response=mock_response) diff --git a/tests/test_image_classification.py b/tests/test_image_classification.py index caffa14..e5e19dc 100644 --- a/tests/test_image_classification.py +++ b/tests/test_image_classification.py @@ -11,6 +11,7 @@ FilterHuggingfaceVision, FilterHuggingfaceVisionConfig, ) +from tests._hf_test_utils import make_hf_error class TestLogitsToClassifications(unittest.TestCase): @@ -241,15 +242,10 @@ def test_non_timm_import_error_is_reraised_unchanged(self): # --- HuggingFace Hub error branches --- - def _make_hf_error(self, cls, message): - mock_response = MagicMock() - mock_response.status_code = 404 - return cls(message, response=mock_response) - def test_repository_not_found_error_message(self): from huggingface_hub import errors as _hf_errors - exc = self._make_hf_error(_hf_errors.RepositoryNotFoundError, "org/model") + exc = make_hf_error(_hf_errors.RepositoryNotFoundError, "org/model") with patch("transformers.AutoImageProcessor.from_pretrained", side_effect=exc): with self.assertRaises(RuntimeError) as ctx: self._load_backend() @@ -260,7 +256,7 @@ def test_repository_not_found_error_message(self): def test_repository_not_found_on_model_download_gives_actionable_message(self): from huggingface_hub import errors as _hf_errors - exc = self._make_hf_error(_hf_errors.RepositoryNotFoundError, "org/model") + exc = make_hf_error(_hf_errors.RepositoryNotFoundError, "org/model") with patch( "transformers.AutoImageProcessor.from_pretrained", return_value=MagicMock(), @@ -279,7 +275,7 @@ def test_repository_not_found_on_model_download_gives_actionable_message(self): def test_revision_not_found_error_message(self): from huggingface_hub import errors as _hf_errors - exc = self._make_hf_error(_hf_errors.RevisionNotFoundError, "abc123") + exc = make_hf_error(_hf_errors.RevisionNotFoundError, "abc123") with patch("transformers.AutoImageProcessor.from_pretrained", side_effect=exc): with self.assertRaises(RuntimeError) as ctx: self._load_backend() @@ -290,7 +286,7 @@ def test_revision_not_found_error_message(self): def test_gated_repo_error_message(self): from huggingface_hub import errors as _hf_errors - exc = self._make_hf_error(_hf_errors.GatedRepoError, "org/model") + exc = make_hf_error(_hf_errors.GatedRepoError, "org/model") with patch("transformers.AutoImageProcessor.from_pretrained", side_effect=exc): with self.assertRaises(RuntimeError) as ctx: self._load_backend() @@ -300,7 +296,7 @@ def test_gated_repo_error_message(self): def test_hf_hub_http_error_includes_repr(self): from huggingface_hub import errors as _hf_errors - exc = self._make_hf_error(_hf_errors.HfHubHTTPError, "503 Service Unavailable") + exc = make_hf_error(_hf_errors.HfHubHTTPError, "503 Service Unavailable") with patch("transformers.AutoImageProcessor.from_pretrained", side_effect=exc): with self.assertRaises(RuntimeError) as ctx: self._load_backend() @@ -318,7 +314,7 @@ def test_value_error_gives_incompatibility_message_with_repr(self): with self.assertRaises(RuntimeError) as ctx: self._load_backend() msg = str(ctx.exception) - self.assertIn("image-classification", msg) + self.assertIn("image classification", msg) self.assertIn("unrecognized architecture", msg) # --- Fallback branch --- diff --git a/tests/test_object_detection.py b/tests/test_object_detection.py index c5b3f1c..136cf19 100644 --- a/tests/test_object_detection.py +++ b/tests/test_object_detection.py @@ -9,6 +9,7 @@ FilterHuggingfaceVision, FilterHuggingfaceVisionConfig, ) +from tests._hf_test_utils import make_hf_error class TestNormalizeDetections(unittest.TestCase): @@ -140,15 +141,10 @@ def test_non_timm_import_error_is_reraised_unchanged(self): # --- HuggingFace Hub error branches --- - def _make_hf_error(self, cls, message): - mock_response = MagicMock() - mock_response.status_code = 404 - return cls(message, response=mock_response) - def test_repository_not_found_error_message(self): from huggingface_hub import errors as _hf_errors - exc = self._make_hf_error(_hf_errors.RepositoryNotFoundError, "org/model") + exc = make_hf_error(_hf_errors.RepositoryNotFoundError, "org/model") with patch("transformers.AutoImageProcessor.from_pretrained", side_effect=exc): with self.assertRaises(RuntimeError) as ctx: self._load_backend() @@ -159,7 +155,7 @@ def test_repository_not_found_error_message(self): def test_repository_not_found_on_model_download_gives_actionable_message(self): from huggingface_hub import errors as _hf_errors - exc = self._make_hf_error(_hf_errors.RepositoryNotFoundError, "org/model") + exc = make_hf_error(_hf_errors.RepositoryNotFoundError, "org/model") with patch( "transformers.AutoImageProcessor.from_pretrained", return_value=MagicMock(), @@ -178,7 +174,7 @@ def test_repository_not_found_on_model_download_gives_actionable_message(self): def test_revision_not_found_error_message(self): from huggingface_hub import errors as _hf_errors - exc = self._make_hf_error(_hf_errors.RevisionNotFoundError, "abc123") + exc = make_hf_error(_hf_errors.RevisionNotFoundError, "abc123") with patch("transformers.AutoImageProcessor.from_pretrained", side_effect=exc): with self.assertRaises(RuntimeError) as ctx: self._load_backend() @@ -189,7 +185,7 @@ def test_revision_not_found_error_message(self): def test_gated_repo_error_message(self): from huggingface_hub import errors as _hf_errors - exc = self._make_hf_error(_hf_errors.GatedRepoError, "org/model") + exc = make_hf_error(_hf_errors.GatedRepoError, "org/model") with patch("transformers.AutoImageProcessor.from_pretrained", side_effect=exc): with self.assertRaises(RuntimeError) as ctx: self._load_backend() @@ -199,7 +195,7 @@ def test_gated_repo_error_message(self): def test_hf_hub_http_error_includes_repr(self): from huggingface_hub import errors as _hf_errors - exc = self._make_hf_error(_hf_errors.HfHubHTTPError, "503 Service Unavailable") + exc = make_hf_error(_hf_errors.HfHubHTTPError, "503 Service Unavailable") with patch("transformers.AutoImageProcessor.from_pretrained", side_effect=exc): with self.assertRaises(RuntimeError) as ctx: self._load_backend() @@ -217,7 +213,7 @@ def test_value_error_gives_incompatibility_message_with_repr(self): with self.assertRaises(RuntimeError) as ctx: self._load_backend() msg = str(ctx.exception) - self.assertIn("object-detection", msg) + self.assertIn("object detection", msg) self.assertIn("unrecognized architecture", msg) # --- Fallback branch --- From f8eb23e49d0fc4f1cd29b0c8442e5c1648b6fbfe Mon Sep 17 00:00:00 2001 From: Rui Andrada Date: Thu, 23 Apr 2026 17:55:06 -0300 Subject: [PATCH 4/9] fix(tests): use flat import for shared HF test utils so pytest collection works The `from tests._hf_test_utils import make_hf_error` form required the `tests/` directory to be a Python package, which it isn't (no __init__.py). Pytest adds each test file's directory to sys.path, so a flat `from _hf_test_utils import make_hf_error` resolves cleanly and matches how the rest of this repo structures its test-internal imports. Fixes ModuleNotFoundError on CI run 24857999573. Signed-off-by: Rui Andrada --- tests/test_image_classification.py | 2 +- tests/test_object_detection.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_image_classification.py b/tests/test_image_classification.py index e5e19dc..cba6c60 100644 --- a/tests/test_image_classification.py +++ b/tests/test_image_classification.py @@ -11,7 +11,7 @@ FilterHuggingfaceVision, FilterHuggingfaceVisionConfig, ) -from tests._hf_test_utils import make_hf_error +from _hf_test_utils import make_hf_error class TestLogitsToClassifications(unittest.TestCase): diff --git a/tests/test_object_detection.py b/tests/test_object_detection.py index 136cf19..46e4d49 100644 --- a/tests/test_object_detection.py +++ b/tests/test_object_detection.py @@ -9,7 +9,7 @@ FilterHuggingfaceVision, FilterHuggingfaceVisionConfig, ) -from tests._hf_test_utils import make_hf_error +from _hf_test_utils import make_hf_error class TestNormalizeDetections(unittest.TestCase): From 7359ca6ef3f49f82cc1b4851f46bdfb5d9dd72d4 Mon Sep 17 00:00:00 2001 From: Rui Andrada Date: Thu, 23 Apr 2026 18:09:47 -0300 Subject: [PATCH 5/9] chore(deps,tests): pin huggingface-hub floor and parameterize test helper - pyproject.toml / uv.lock: add `huggingface-hub>=0.23` lower bound. The new `_hf_load_errors` module imports `huggingface_hub.errors.GatedRepoError` and `RevisionNotFoundError`, which are only present from ~0.22+. Without a floor, an older resolved hub version would break at module import time. uv.lock is regenerated via `uv lock` (no manual edit). - tests/_hf_test_utils.py: `make_hf_error` now accepts an optional `status_code` parameter (defaults to 404). Future tests that simulate 401/403/503 conditions can set the code without monkey-patching the helper, and assertions touching the status code stay accurate. Addresses Copilot review comments on PR #21 (discussion_r3133786636 and discussion_r3133786674). Signed-off-by: Rui Andrada --- pyproject.toml | 2 +- tests/_hf_test_utils.py | 10 +++++++--- uv.lock | 2 +- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2a0c197..7455d2a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ dependencies = [ "timm", "sentencepiece", "pillow", - "huggingface-hub", + "huggingface-hub>=0.23", "python-dotenv", ] diff --git a/tests/_hf_test_utils.py b/tests/_hf_test_utils.py index b57748c..ca6f2c5 100644 --- a/tests/_hf_test_utils.py +++ b/tests/_hf_test_utils.py @@ -2,9 +2,13 @@ from unittest.mock import MagicMock -def make_hf_error(cls, message): +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.""" + 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 = 404 + mock_response.status_code = status_code return cls(message, response=mock_response) diff --git a/uv.lock b/uv.lock index fa9bc51..74db112 100644 --- a/uv.lock +++ b/uv.lock @@ -582,7 +582,7 @@ 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.27" }, { name = "pillow" }, From 3ce22d786fc988fdab9579d2f9e9e9e07510449d Mon Sep 17 00:00:00 2001 From: Rui Andrada Date: Thu, 23 Apr 2026 19:25:22 -0300 Subject: [PATCH 6/9] fix(backends): broaden HF error coverage (Entry/LocalEntry, 401 detection, dep hints) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B1 — switch HF exception import from huggingface_hub.errors to huggingface_hub.utils, which has been stable since hub ~0.16 and is available at the declared floor (>=0.23). Eliminates the AttributeError risk on older hub installs flagged by Copilot in discussion_r3133786636. Floor in pyproject.toml unchanged. B2 — add (LocalEntryNotFoundError, EntryNotFoundError) branch before HfHubHTTPError. Neither class inherits from HfHubHTTPError so they were silently falling through to the generic "Unexpected failure" catch-all. Typical triggers: HF_HUB_OFFLINE=1 with a cold cache, or Vertex workers racing a shared model cache. S1 — soften ValueError message: drop "exposes the required head" assertion since ValueError also fires for corrupt preprocessor_config.json, unrecognized model type, bad image_size, etc. Message is now: "Model '{model_id}@{revision}' could not be loaded for {task}. Detail: ..." S2 — (a) GatedRepoError message now explicitly mentions HF_TOKEN env var. (b) HfHubHTTPError branch inspects e.response.status_code — 401 emits a dedicated hint pointing to HF_TOKEN and the gated-repo flow, since older hub versions return a generic 401 without raising GatedRepoError. S4 — expand ImportError handling from single-string timm check to a _MISSING_DEP_HINTS allowlist covering timm, sentencepiece, detectron2, and torchvision. Unknown deps are still re-raised unchanged. Tests: new cases for LocalEntryNotFoundError, EntryNotFoundError, 401 branch, and sentencepiece allowlist entry (both backends). All 85 tests pass. Signed-off-by: Rui Andrada --- .../backends/_hf_load_errors.py | 69 +++++++++++++++---- tests/test_image_classification.py | 68 ++++++++++++++---- tests/test_object_detection.py | 68 ++++++++++++++---- 3 files changed, 166 insertions(+), 39 deletions(-) diff --git a/filter_huggingface_vision/backends/_hf_load_errors.py b/filter_huggingface_vision/backends/_hf_load_errors.py index 62bfa39..76f44ec 100644 --- a/filter_huggingface_vision/backends/_hf_load_errors.py +++ b/filter_huggingface_vision/backends/_hf_load_errors.py @@ -1,7 +1,26 @@ """Shared HuggingFace Hub load-error handler for vision backends.""" from contextlib import contextmanager -from huggingface_hub import errors as _hf_errors +# 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 @@ -9,43 +28,63 @@ def hf_load_error_handler(model_id: str, revision: str, task: str): """Wraps AutoImageProcessor + AutoModelFor*.from_pretrained calls with targeted, actionable error messages. - GatedRepoError must be caught before RepositoryNotFoundError because it - is a subclass of it in huggingface_hub >= 0.22 / 1.x. + Catch order matters: + - GatedRepoError before RepositoryNotFoundError (it is a subclass). + - RepositoryNotFoundError and RevisionNotFoundError before HfHubHTTPError + (both are subclasses). + - (LocalEntryNotFoundError, EntryNotFoundError) before HfHubHTTPError + because they do NOT inherit from it and would otherwise fall through to + the generic fallback. """ try: yield except ImportError as e: - if "timm" in str(e).lower(): - raise ImportError( - f"This model (e.g. {model_id}) requires the timm library. " - "Install it with: pip install timm" - ) from 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 _hf_errors.GatedRepoError as e: + 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. Detail: {repr(e)}" + f"Accept it with an authenticated token " + f"(set HF_TOKEN environment variable). Detail: {repr(e)}" ) from e - except _hf_errors.RepositoryNotFoundError as 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 _hf_errors.RevisionNotFoundError as 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 - except _hf_errors.HfHubHTTPError as e: + except (LocalEntryNotFoundError, EntryNotFoundError) as e: + raise RuntimeError( + f"Model entry for '{model_id}@{revision}' not found in cache. " + f"If running offline, pre-download the model; if concurrent workers " + f"share a cache, retry 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}' could not be loaded for {task}. " - f"Check that the model's architecture exposes the required head: {repr(e)}" + f"Model '{model_id}@{revision}' could not be loaded for {task}. " + f"Detail: {repr(e)}" ) from e except Exception as e: raise RuntimeError( diff --git a/tests/test_image_classification.py b/tests/test_image_classification.py index cba6c60..39e201a 100644 --- a/tests/test_image_classification.py +++ b/tests/test_image_classification.py @@ -218,7 +218,7 @@ def _load_backend(self): ImageClassificationBackend().load(self._CONFIG) - # --- ImportError branches --- + # --- ImportError branches (S4 allowlist) --- def test_timm_import_error_gives_actionable_message(self): with patch( @@ -230,7 +230,17 @@ def test_timm_import_error_gives_actionable_message(self): self.assertIn("timm", str(ctx.exception)) self.assertIn("pip install timm", str(ctx.exception)) - def test_non_timm_import_error_is_reraised_unchanged(self): + 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", @@ -243,9 +253,9 @@ def test_non_timm_import_error_is_reraised_unchanged(self): # --- HuggingFace Hub error branches --- def test_repository_not_found_error_message(self): - from huggingface_hub import errors as _hf_errors + from huggingface_hub.utils import RepositoryNotFoundError - exc = make_hf_error(_hf_errors.RepositoryNotFoundError, "org/model") + 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() @@ -254,9 +264,9 @@ def test_repository_not_found_error_message(self): self.assertIn("not found", msg) def test_repository_not_found_on_model_download_gives_actionable_message(self): - from huggingface_hub import errors as _hf_errors + from huggingface_hub.utils import RepositoryNotFoundError - exc = make_hf_error(_hf_errors.RepositoryNotFoundError, "org/model") + exc = make_hf_error(RepositoryNotFoundError, "org/model") with patch( "transformers.AutoImageProcessor.from_pretrained", return_value=MagicMock(), @@ -273,9 +283,9 @@ def test_repository_not_found_on_model_download_gives_actionable_message(self): self.assertIs(ctx.exception.__cause__, exc) def test_revision_not_found_error_message(self): - from huggingface_hub import errors as _hf_errors + from huggingface_hub.utils import RevisionNotFoundError - exc = make_hf_error(_hf_errors.RevisionNotFoundError, "abc123") + exc = make_hf_error(RevisionNotFoundError, "abc123") with patch("transformers.AutoImageProcessor.from_pretrained", side_effect=exc): with self.assertRaises(RuntimeError) as ctx: self._load_backend() @@ -284,19 +294,20 @@ def test_revision_not_found_error_message(self): self.assertIn("Revision", msg) def test_gated_repo_error_message(self): - from huggingface_hub import errors as _hf_errors + from huggingface_hub.utils import GatedRepoError - exc = make_hf_error(_hf_errors.GatedRepoError, "org/model") + 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 import errors as _hf_errors + from huggingface_hub.utils import HfHubHTTPError - exc = make_hf_error(_hf_errors.HfHubHTTPError, "503 Service Unavailable") + 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() @@ -304,6 +315,39 @@ def test_hf_hub_http_error_includes_repr(self): self.assertIn("org/model", msg) self.assertIn("HuggingFace Hub", 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): diff --git a/tests/test_object_detection.py b/tests/test_object_detection.py index 46e4d49..1aabc28 100644 --- a/tests/test_object_detection.py +++ b/tests/test_object_detection.py @@ -117,7 +117,7 @@ def _load_backend(self): ObjectDetectionBackend().load(self._CONFIG) - # --- ImportError branches --- + # --- ImportError branches (S4 allowlist) --- def test_timm_import_error_gives_actionable_message(self): with patch( @@ -129,7 +129,17 @@ def test_timm_import_error_gives_actionable_message(self): self.assertIn("timm", str(ctx.exception)) self.assertIn("pip install timm", str(ctx.exception)) - def test_non_timm_import_error_is_reraised_unchanged(self): + 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", @@ -142,9 +152,9 @@ def test_non_timm_import_error_is_reraised_unchanged(self): # --- HuggingFace Hub error branches --- def test_repository_not_found_error_message(self): - from huggingface_hub import errors as _hf_errors + from huggingface_hub.utils import RepositoryNotFoundError - exc = make_hf_error(_hf_errors.RepositoryNotFoundError, "org/model") + 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() @@ -153,9 +163,9 @@ def test_repository_not_found_error_message(self): self.assertIn("not found", msg) def test_repository_not_found_on_model_download_gives_actionable_message(self): - from huggingface_hub import errors as _hf_errors + from huggingface_hub.utils import RepositoryNotFoundError - exc = make_hf_error(_hf_errors.RepositoryNotFoundError, "org/model") + exc = make_hf_error(RepositoryNotFoundError, "org/model") with patch( "transformers.AutoImageProcessor.from_pretrained", return_value=MagicMock(), @@ -172,9 +182,9 @@ def test_repository_not_found_on_model_download_gives_actionable_message(self): self.assertIs(ctx.exception.__cause__, exc) def test_revision_not_found_error_message(self): - from huggingface_hub import errors as _hf_errors + from huggingface_hub.utils import RevisionNotFoundError - exc = make_hf_error(_hf_errors.RevisionNotFoundError, "abc123") + exc = make_hf_error(RevisionNotFoundError, "abc123") with patch("transformers.AutoImageProcessor.from_pretrained", side_effect=exc): with self.assertRaises(RuntimeError) as ctx: self._load_backend() @@ -183,19 +193,20 @@ def test_revision_not_found_error_message(self): self.assertIn("Revision", msg) def test_gated_repo_error_message(self): - from huggingface_hub import errors as _hf_errors + from huggingface_hub.utils import GatedRepoError - exc = make_hf_error(_hf_errors.GatedRepoError, "org/model") + 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 import errors as _hf_errors + from huggingface_hub.utils import HfHubHTTPError - exc = make_hf_error(_hf_errors.HfHubHTTPError, "503 Service Unavailable") + 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() @@ -203,6 +214,39 @@ def test_hf_hub_http_error_includes_repr(self): self.assertIn("org/model", msg) self.assertIn("HuggingFace Hub", 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): From 392e82409f1fcec8cf3c04cc5e489acb0149d03b Mon Sep 17 00:00:00 2001 From: Rui Andrada Date: Thu, 23 Apr 2026 19:49:16 -0300 Subject: [PATCH 7/9] fix(backends): extend hf_load_error_handler to grounding-dino and owlvit + pytest pythonpath nit Closes the coverage gap flagged by lucasmundim in discussion_r3134262360: `grounding_dino.py` and `owlvit.py` also call `AutoProcessor.from_pretrained` / `OwlViTProcessor.from_pretrained` and were still falling into an unguarded load path, so the same opaque failures that object_detection and image_classification used to emit (gated repo without HF_TOKEN, bad revision, offline/cold-cache, missing deps) kept surfacing as "model not compatible" for these two backends. - Wrap both backends' load() with `hf_load_error_handler(model_id, revision, task)`. Task tags are "open-vocabulary detection (grounding-dino)" and "zero-shot detection (owl-vit)" so the thrown messages identify which backend failed. - Add load-error test classes (RepositoryNotFoundError, RevisionNotFoundError, ValueError) to `tests/test_grounding_dino.py` and `tests/test_owlvit.py`, mirroring the shape of TestObjectDetectionBackendLoadErrors. - Declare `[tool.pytest.ini_options] pythonpath = ["tests"]` in pyproject.toml so the `from _hf_test_utils import make_hf_error` shorthand is documented rather than relying on pytest's `prepend` import mode implicitly (discussion_r3134262364). - Drop the double blank line before v0.4.4 in RELEASE.md (discussion_r3134262367) and expand the v0.4.5 notes to reflect the coverage landing across all four backends. All backend `from_pretrained` calls in this filter now produce the same actionable errors. 47/47 non-torchvision tests pass locally (the torchvision subset runs clean in CI). Signed-off-by: Rui Andrada --- RELEASE.md | 6 ++- .../backends/grounding_dino.py | 14 +++--- filter_huggingface_vision/backends/owlvit.py | 14 +++--- pyproject.toml | 3 ++ tests/test_grounding_dino.py | 47 +++++++++++++++++++ tests/test_owlvit.py | 45 ++++++++++++++++++ 6 files changed, 115 insertions(+), 14 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 0b3adcf..3b4b489 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -19,10 +19,12 @@ Huggingface Vision filter release notes ### 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. -- Deduplicated the `make_hf_error` test helper into `tests/_hf_test_utils.py` (shared util). +- 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.4 - 2026-04-20 ### Changed 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/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 7455d2a..62fc6e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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/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_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() From b5483c78b23a63a0fa50977bb6fe7ce6cfb9e4f9 Mon Sep 17 00:00:00 2001 From: Rui Andrada Date: Fri, 24 Apr 2026 00:25:47 -0300 Subject: [PATCH 8/9] docs(backends): correct catch-order justification at declared hub floor The previous docstring in _hf_load_errors said the LocalEntryNotFoundError and EntryNotFoundError catches sit before HfHubHTTPError because they do NOT inherit from it. That is true on huggingface-hub >=1.0 (which flattened the exception hierarchy) but false at the declared floor (>=0.23 with the requests backbone), where both ARE subclasses of HfHubHTTPError and LocalEntryNotFoundError is additionally a ValueError. The current catch order happens to be correct across the supported range, but the documented reason was wrong for the floor era, so a future maintainer could easily reorder based on the stale justification. Rewrite the note to cite both eras explicitly, and point out that LocalEntryNotFoundError's additional ValueError ancestry means the `except ValueError` fallback below must also stay after the cache branch. Verified against `huggingface-hub==0.23.0` in a throwaway venv. Addresses #21 review thread 1 (stwilt). Signed-off-by: Rui Andrada --- filter_huggingface_vision/backends/_hf_load_errors.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/filter_huggingface_vision/backends/_hf_load_errors.py b/filter_huggingface_vision/backends/_hf_load_errors.py index 76f44ec..66c6ea1 100644 --- a/filter_huggingface_vision/backends/_hf_load_errors.py +++ b/filter_huggingface_vision/backends/_hf_load_errors.py @@ -33,8 +33,12 @@ def hf_load_error_handler(model_id: str, revision: str, task: str): - RepositoryNotFoundError and RevisionNotFoundError before HfHubHTTPError (both are subclasses). - (LocalEntryNotFoundError, EntryNotFoundError) before HfHubHTTPError - because they do NOT inherit from it and would otherwise fall through to - the generic fallback. + 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 From a33fb6d9151b3d3529e6266d44695fadb075debe Mon Sep 17 00:00:00 2001 From: Rui Andrada Date: Mon, 27 Apr 2026 13:37:38 -0300 Subject: [PATCH 9/9] fix: bump v0.4.6, broaden EntryNotFoundError, guard exception order, tighten 503 test Signed-off-by: Rui Andrada --- RELEASE.md | 21 +++++++++---------- VERSION | 2 +- .../backends/_hf_load_errors.py | 12 ++++++++--- tests/test_image_classification.py | 5 ++++- tests/test_object_detection.py | 5 ++++- 5 files changed, 28 insertions(+), 17 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 3b4b489..16ce235 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,23 +1,15 @@ -# v0.4.5 +# v0.4.6 # Changelog Huggingface Vision filter release notes -## 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.5 - 2026-04-23 +## 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`. +- 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. @@ -25,6 +17,13 @@ Huggingface Vision filter release notes - 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`. + ## 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 index 66c6ea1..ea60a1c 100644 --- a/filter_huggingface_vision/backends/_hf_load_errors.py +++ b/filter_huggingface_vision/backends/_hf_load_errors.py @@ -69,11 +69,17 @@ def hf_load_error_handler(model_id: str, revision: str, task: str): 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}' not found in cache. " - f"If running offline, pre-download the model; if concurrent workers " - f"share a cache, retry after the first worker completes. Detail: {repr(e)}" + 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) diff --git a/tests/test_image_classification.py b/tests/test_image_classification.py index 39e201a..f3617e0 100644 --- a/tests/test_image_classification.py +++ b/tests/test_image_classification.py @@ -313,7 +313,10 @@ def test_hf_hub_http_error_includes_repr(self): self._load_backend() msg = str(ctx.exception) self.assertIn("org/model", msg) - self.assertIn("HuggingFace Hub", 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 diff --git a/tests/test_object_detection.py b/tests/test_object_detection.py index 1aabc28..f58b915 100644 --- a/tests/test_object_detection.py +++ b/tests/test_object_detection.py @@ -212,7 +212,10 @@ def test_hf_hub_http_error_includes_repr(self): self._load_backend() msg = str(ctx.exception) self.assertIn("org/model", msg) - self.assertIn("HuggingFace Hub", 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