From 5e87ca0738c9e816b8f9503693074a428ecd8069 Mon Sep 17 00:00:00 2001 From: Ignazio De Santis Date: Wed, 10 Jun 2026 13:50:47 +0800 Subject: [PATCH] fix(multimodal): raise explicit errors in Audio instead of assert `Audio.from_url` and `Audio.from_path` validated inputs with bare `assert` statements. Asserts are silently stripped under `python -O` (PEP 230), so the checks vanish in optimized deployments, and when they do fire they raise `AssertionError` rather than the `ValueError` used everywhere else. This left `Audio` inconsistent with `Image` and `PDF`, which already raise `FileNotFoundError` / `ValueError` and reject empty files. Replace the asserts with explicit `FileNotFoundError` (missing file) and `ValueError` (empty file, unsupported format), mirroring the `Image`/`PDF` pattern. Add regression tests covering all four cases. --- CHANGELOG.md | 1 + instructor/v2/core/multimodal.py | 22 +++++++---- tests/v2/test_core_multimodal_runtime.py | 47 ++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fbeddff2..e771f66e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ### Fixed - **v2 cleanup**: Consolidate small provider/runtime fixes for Gemini JSON prompts, Cohere templating, JSON array extraction, iterable streaming, missing `jsonref` dependency guidance, retry semantics and hook metadata, and multimodal autodetection. +- **Multimodal (Audio)**: `Audio.from_url` and `Audio.from_path` now raise `ValueError` (unsupported/empty) and `FileNotFoundError` (missing) instead of bare `assert` statements, which are silently stripped under `python -O`. Brings `Audio` validation in line with `Image`/`PDF`. ([#2361](https://github.com/567-labs/instructor/pull/2361)) ### Tests / CI - **Type checking**: Upgrade to `ty` 0.0.44, enforce warning-free checks with GitHub annotations, cover V2 tests, validate supported Python versions and platforms, and strengthen installed-package public API typing tests. diff --git a/instructor/v2/core/multimodal.py b/instructor/v2/core/multimodal.py index 6a76e5104..89c53755d 100644 --- a/instructor/v2/core/multimodal.py +++ b/instructor/v2/core/multimodal.py @@ -325,9 +325,11 @@ def from_url(cls, url: str) -> Audio: return cls.from_gs_url(url) response = requests.get(url) content_type = response.headers.get("content-type") - assert content_type in VALID_AUDIO_MIME_TYPES, ( - f"Invalid audio format. Must be one of: {', '.join(VALID_AUDIO_MIME_TYPES)}" - ) + if content_type not in VALID_AUDIO_MIME_TYPES: + raise ValueError( + f"Unsupported audio format: {content_type}. " + f"Must be one of: {', '.join(VALID_AUDIO_MIME_TYPES)}" + ) data = base64.b64encode(response.content).decode("utf-8") return cls(source=url, data=data, media_type=content_type) @@ -336,7 +338,11 @@ def from_url(cls, url: str) -> Audio: def from_path(cls, path: Union[str, Path]) -> Audio: # noqa: UP007 """Create an Audio instance from a file path.""" path = Path(path) - assert path.is_file(), f"Audio file not found: {path}" + if not path.is_file(): + raise FileNotFoundError(f"Audio file not found: {path}") + + if path.stat().st_size == 0: + raise ValueError("Audio file is empty") mime_type = mimetypes.guess_type(str(path))[0] @@ -348,9 +354,11 @@ def from_path(cls, path: Union[str, Path]) -> Audio: # noqa: UP007 ): # <--- this is the case for aac audio files in Windows mime_type = "audio/aac" - assert mime_type in VALID_AUDIO_MIME_TYPES, ( - f"Invalid audio format. Must be one of: {', '.join(VALID_AUDIO_MIME_TYPES)}" - ) + if mime_type not in VALID_AUDIO_MIME_TYPES: + raise ValueError( + f"Unsupported audio format: {mime_type}. " + f"Must be one of: {', '.join(VALID_AUDIO_MIME_TYPES)}" + ) data = base64.b64encode(path.read_bytes()).decode("utf-8") return cls(source=str(path), data=data, media_type=mime_type) diff --git a/tests/v2/test_core_multimodal_runtime.py b/tests/v2/test_core_multimodal_runtime.py index a7661fa73..2fd9c24b3 100644 --- a/tests/v2/test_core_multimodal_runtime.py +++ b/tests/v2/test_core_multimodal_runtime.py @@ -191,3 +191,50 @@ def test_audio_from_path_normalizes_windows_wav_and_aac_mime_types( assert wav_audio.media_type == "audio/wav" assert aac_audio.media_type == "audio/aac" + + +def test_audio_from_url_raises_value_error_for_unsupported_content_type( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``Audio.from_url`` rejects an unsupported content-type with ``ValueError`` + (not ``AssertionError``), mirroring ``Image.from_url``. ``assert`` is stripped + under ``python -O``, so validation must not rely on it.""" + + class _Response: + headers = {"content-type": "video/mp4"} + content = b"\x00\x01\x02" + + monkeypatch.setattr("requests.get", lambda *_args, **_kwargs: _Response()) + + with pytest.raises(ValueError, match="Unsupported audio format"): + Audio.from_url("https://example.com/clip.mp4") + + +def test_audio_from_path_raises_value_error_for_unsupported_format( + tmp_path: Path, +) -> None: + """``Audio.from_path`` rejects an unsupported format with ``ValueError`` + (not ``AssertionError``), mirroring ``Image.from_path``.""" + txt_path = tmp_path / "clip.txt" + txt_path.write_bytes(b"not audio") # ``.txt`` -> ``text/plain`` (not an audio type) + + with pytest.raises(ValueError, match="Unsupported audio format"): + Audio.from_path(txt_path) + + +def test_audio_from_path_raises_value_error_for_empty_file(tmp_path: Path) -> None: + """``Audio.from_path`` rejects an empty file, mirroring ``Image.from_path``.""" + empty_path = tmp_path / "empty.wav" + empty_path.write_bytes(b"") + + with pytest.raises(ValueError, match="Audio file is empty"): + Audio.from_path(empty_path) + + +def test_audio_from_path_raises_file_not_found_for_missing_file(tmp_path: Path) -> None: + """``Audio.from_path`` raises ``FileNotFoundError`` (not ``AssertionError``) + for a missing file, mirroring ``Image.from_path``.""" + missing_path = tmp_path / "missing.wav" + + with pytest.raises(FileNotFoundError): + Audio.from_path(missing_path)