Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v0.4.5
v0.4.6

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ultra-nit: missing trailing newline (\ No newline at end of file in the diff). Some Unix tools (e.g. wc -l, POSIX read) behave unexpectedly without it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: missing trailing newline ( in the diff). POSIX convention — most tools handle it fine, but 0 will report 0 lines and some CI linters flag it.

102 changes: 102 additions & 0 deletions filter_huggingface_vision/backends/_hf_load_errors.py
Original file line number Diff line number Diff line change
@@ -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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two items on this handler:

  1. LocalEntryNotFoundError / ValueError ordering is load-bearing at hub 0.23 — stwilt's open comment is valid: at the declared floor, LocalEntryNotFoundError inherits from ValueError (verified via MRO). A one-line comment above this branch would protect against a future reorder, e.g.:
# Must stay above except ValueError — LocalEntryNotFoundError inherits ValueError at hub 0.23

I verified against hub 1.7.1 that the hierarchy was flattened (neither class inherits from HfHubHTTPError or ValueError anymore), so the docstring's "both eras" claim is accurate.

  1. EntryNotFoundError message may mislead (echoing lucasmundim's open comment) — EntryNotFoundError (without "Local") can mean a remote file genuinely doesn't exist in the repo (e.g., missing config.json), not just a cache miss. The current "not found in cache" / "pre-download the model" guidance is wrong for that case. Consider broadening the message, e.g.:
"A required file for '{model_id}@{revision}' was not found — the repo may be missing expected files, or the local cache is incomplete. If running offline, pre-download the model. Detail: {repr(e)}"

Neither is blocking, but both are easy fixes while you're here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the one-line guard comment above the (LocalEntryNotFoundError, EntryNotFoundError) branch in a33fb6d. Message broadening lands here too, replied on the line 74 thread.

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
Comment on lines +75 to +83

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up to the docstring nit above — LocalEntryNotFoundError is also a ValueError (via its MRO: … -> FileNotFoundError -> OSError -> ValueError). This makes the ordering relative to L84 load-bearing: if the except ValueError branch is ever hoisted above this one (e.g. during a future refactor), LocalEntryNotFoundError will be silently re-routed and the cache-specific guidance (offline pre-download / shared-cache retry) will disappear without any test catching it.

The existing test_local_entry_not_found_error_message passes because it uses the specific class — it wouldn't catch a reorder. Consider either:

  1. A one-line comment above the (LocalEntryNotFoundError, EntryNotFoundError) branch noting LocalEntryNotFoundError inherits from ValueError and must stay above that branch, or
  2. A regression test that asserts a LocalEntryNotFoundError produces the cache-specific message verbatim (substring match on "not found in cache" rather than just "cache"), so a reorder falling through to the ValueError branch would flip the test red.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already in flight: you implemented this on refactor/dry-hf-load-error-tests in #23, which tightens assertIn("cache") to assertIn("not found in cache") in both test files. That is exactly what I was about to push. Since your commit also extracts the shared HFLoadErrorTestsMixin and deletes the two target test classes here, landing my duplicate would just force you to re-rebase, so leaving the assertion tightening to land with your refactor. (Did confirm the substring change has teeth by running a reorder mutation at hub 0.23 in a throwaway venv: the loose assertion passes in both orderings, the tightened one flips red when except ValueError is hoisted above the cache branch, so the new assertion does what we need.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update on my earlier reply: this ended up landing here in a33fb6d after all (the one-line guard comment is right above the (LocalEntryNotFoundError, EntryNotFoundError) branch now), since Lucas's review forced another push and it was cheaper to just include it than wait on PR #23.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up suggestion: grounding_dino.py and owlvit.py also call from_pretrained with no error handling at all — they’d be natural candidates to wrap with this context manager in a subsequent PR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 1f3de3d. Both grounding_dino.py and owlvit.py now wrap their from_pretrained calls in hf_load_error_handler, and tests/test_grounding_dino.py + tests/test_owlvit.py grow matching load-error test classes covering RepositoryNotFoundError, RevisionNotFoundError, and ValueError.

14 changes: 8 additions & 6 deletions filter_huggingface_vision/backends/grounding_dino.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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
Expand Down
9 changes: 2 additions & 7 deletions filter_huggingface_vision/backends/image_classification.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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()
Expand Down
15 changes: 2 additions & 13 deletions filter_huggingface_vision/backends/object_detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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()
Expand Down
14 changes: 8 additions & 6 deletions filter_huggingface_vision/backends/owlvit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ dependencies = [
"timm",
"sentencepiece",
"pillow",
"huggingface-hub",
"huggingface-hub>=0.23",
"python-dotenv",
]

Expand All @@ -48,6 +48,9 @@ dev = [
"pytest-cov==7.0.0",
]

[tool.pytest.ini_options]
pythonpath = ["tests"]

[tool.black]
skip-string-normalization = true

Expand Down
14 changes: 14 additions & 0 deletions tests/_hf_test_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"""Shared test utilities for HuggingFace Hub error-handling tests."""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this bare import (from _hf_test_utils import make_hf_error) works via pytest’s default prepend import mode, but there’s no conftest.py or __init__.py in tests/ to make it explicit. Consider adding [tool.pytest.ini_options] pythonpath = ["tests"] in pyproject.toml so the mechanism is documented rather than relying on pytest internals.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 1f3de3d. Added [tool.pytest.ini_options] pythonpath = ["tests"] to pyproject.toml so the from _hf_test_utils import make_hf_error shorthand is documented via pytest's own config rather than relying on prepend import mode implicitly.

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)
47 changes: 47 additions & 0 deletions tests/test_grounding_dino.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -10,6 +11,7 @@
FilterHuggingfaceVision,
FilterHuggingfaceVisionConfig,
)
from _hf_test_utils import make_hf_error


class TestNormalizeResultsGrounding(unittest.TestCase):
Expand Down Expand Up @@ -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()
Loading
Loading