Files
wehub-resource-sync 461bf6fd40
CI / lint (3.11) (push) Has been cancelled
CI / lint (3.12) (push) Has been cancelled
CI / lint (3.13) (push) Has been cancelled
CI / shellcheck (push) Has been cancelled
CI / shfmt (push) Has been cancelled
CI / setup (3.11) (push) Has been cancelled
CI / setup (3.12) (push) Has been cancelled
CI / setup (3.13) (push) Has been cancelled
CI / check-licenses (3.12) (push) Has been cancelled
CI / test_unit (3.11) (push) Has been cancelled
CI / test_unit (3.12) (push) Has been cancelled
CI / test_unit (3.13) (push) Has been cancelled
CI / test_unit_no_extras (3.11) (push) Has been cancelled
CI / test_unit_no_extras (3.12) (push) Has been cancelled
CI / test_json_to_html (3.12) (push) Has been cancelled
CI / test_unit_no_extras (3.13) (push) Has been cancelled
CI / test_unit_dependency_extras (csv, 3.12, --extra csv) (push) Has been cancelled
CI / test_unit_dependency_extras (xlsx, 3.11, --extra xlsx) (push) Has been cancelled
CI / test_unit_dependency_extras (xlsx, 3.12, --extra xlsx) (push) Has been cancelled
CI / test_unit_dependency_extras (csv, 3.11, --extra csv) (push) Has been cancelled
CI / test_unit_dependency_extras (csv, 3.13, --extra csv) (push) Has been cancelled
CI / test_unit_dependency_extras (docx, 3.11, --extra docx) (push) Has been cancelled
CI / test_unit_dependency_extras (docx, 3.12, --extra docx) (push) Has been cancelled
CI / test_unit_dependency_extras (docx, 3.13, --extra docx) (push) Has been cancelled
CI / test_unit_dependency_extras (markdown, 3.11, --extra md) (push) Has been cancelled
CI / test_unit_dependency_extras (markdown, 3.12, --extra md) (push) Has been cancelled
CI / test_unit_dependency_extras (markdown, 3.13, --extra md) (push) Has been cancelled
CI / test_unit_dependency_extras (odt, 3.11, --extra odt) (push) Has been cancelled
CI / test_unit_dependency_extras (odt, 3.12, --extra odt) (push) Has been cancelled
CI / test_unit_dependency_extras (odt, 3.13, --extra odt) (push) Has been cancelled
CI / test_unit_dependency_extras (pdf-image, 3.11, --extra pdf --extra image --extra paddleocr) (push) Has been cancelled
CI / test_unit_dependency_extras (pdf-image, 3.12, --extra pdf --extra image --extra paddleocr) (push) Has been cancelled
CI / test_unit_dependency_extras (pdf-image, 3.13, --extra pdf --extra image --extra paddleocr) (push) Has been cancelled
CI / test_unit_dependency_extras (pptx, 3.11, --extra pptx) (push) Has been cancelled
CI / test_unit_dependency_extras (pptx, 3.12, --extra pptx) (push) Has been cancelled
CI / test_unit_dependency_extras (pptx, 3.13, --extra pptx) (push) Has been cancelled
CI / test_unit_dependency_extras (pypandoc, 3.11, --extra epub --extra org --extra rtf --extra rst) (push) Has been cancelled
CI / test_unit_dependency_extras (pypandoc, 3.12, --extra epub --extra org --extra rtf --extra rst) (push) Has been cancelled
CI / test_unit_dependency_extras (pypandoc, 3.13, --extra epub --extra org --extra rtf --extra rst) (push) Has been cancelled
Build And Push Docker Image / set-short-sha (push) Has been cancelled
Partition Benchmark / setup (push) Has been cancelled
Partition Benchmark / Measure and compare partition() runtime (push) Has been cancelled
CI / test_unit_dependency_extras (xlsx, 3.13, --extra xlsx) (push) Has been cancelled
CI / test_ingest_src (3.12) (push) Has been cancelled
CI / test_json_to_markdown (3.12) (push) Has been cancelled
CI / changelog (push) Has been cancelled
CI / test_dockerfile (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Build And Push Docker Image / build-images (linux/amd64, opensource-linux-8core) (push) Has been cancelled
Build And Push Docker Image / build-images (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build And Push Docker Image / publish-images (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:33:56 +08:00

417 lines
16 KiB
Python

# pyright: reportPrivateUsage=false
"""Tests for partition_audio (speech-to-text in multimodal pipeline)."""
from __future__ import annotations
import tempfile
from contextlib import contextmanager
from pathlib import Path
from typing import IO, Generator
from unittest.mock import MagicMock, patch
import pytest
from unstructured.documents.elements import NarrativeText
from unstructured.file_utils.model import FileType
from unstructured.partition.audio import partition_audio
# ================================================================================================
# Shared fixtures
# ================================================================================================
_ONE_SEGMENT = [{"text": "Hello, world.", "start": 0.0, "end": 1.0}]
@contextmanager
def _tmp_audio(suffix: str = ".wav", content: bytes = b"\x00" * 44) -> Generator[str, None, None]:
"""Yield the path to a temporary audio file, deleting it on exit."""
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
tmp.write(content)
tmp.flush()
path = tmp.name
try:
yield path
finally:
Path(path).unlink(missing_ok=True)
@pytest.fixture
def mock_stt_agent() -> Generator[MagicMock, None, None]:
"""Patch SpeechToTextAgent.get_agent and yield the mock agent instance.
Tests that only need a working agent (and don't care about call details) can use
this fixture directly. Tests that need to inspect mock calls should use
``@patch("unstructured.partition.audio.SpeechToTextAgent.get_agent")`` directly so
the mock is visible in the function signature.
"""
with patch("unstructured.partition.audio.SpeechToTextAgent.get_agent") as mock_get_agent:
agent = mock_get_agent.return_value
agent.transcribe_segments.return_value = _ONE_SEGMENT
yield agent
@pytest.fixture
def _clear_stt_cache() -> Generator[None, None, None]:
"""Clear SpeechToTextAgent.get_instance lru_cache before and after the test.
Use in any test that touches get_instance or get_agent directly so cached state
does not leak between tests (mirrors OCRAgent _clear_cache in test_ocr_interface).
Tests that patch get_instance (e.g. get_agent tests using mock_get_instance) never
call the real get_instance, so the cache is unchanged by them and this fixture is
a no-op for those; it matters for tests that call get_instance directly.
"""
from unstructured.partition.utils.speech_to_text.speech_to_text_interface import (
SpeechToTextAgent,
)
SpeechToTextAgent.get_instance.cache_clear()
yield
SpeechToTextAgent.get_instance.cache_clear()
# ================================================================================================
# Input validation
# ================================================================================================
def test_partition_audio_raises_with_neither_filename_nor_file():
with pytest.raises(ValueError, match="Exactly one of .* must be specified"):
partition_audio()
def test_partition_audio_raises_with_both_filename_and_file():
with _tmp_audio() as path:
with pytest.raises(ValueError, match="Exactly one of .* must be specified"):
with open(path, "rb") as f:
partition_audio(filename=path, file=f)
# ================================================================================================
# Core element output
# ================================================================================================
@patch("unstructured.partition.audio.SpeechToTextAgent.get_agent")
def test_partition_audio_from_filename_returns_transcript_elements(mock_get_agent):
mock_agent = mock_get_agent.return_value
mock_agent.transcribe_segments.return_value = [
{"text": "Hello, this is a test transcript.", "start": 0.0, "end": 2.5},
]
with _tmp_audio() as path:
elements = partition_audio(filename=path)
assert len(elements) == 1
assert isinstance(elements[0], NarrativeText)
assert elements[0].text == "Hello, this is a test transcript."
assert elements[0].metadata.detection_origin == "speech_to_text"
assert elements[0].metadata.segment_start_seconds == 0.0
assert elements[0].metadata.segment_end_seconds == 2.5
mock_get_agent.assert_called_once_with(None)
mock_agent.transcribe_segments.assert_called_once_with(path, language=None)
def test_partition_audio_empty_transcript_returns_empty_list(mock_stt_agent):
mock_stt_agent.transcribe_segments.return_value = []
with _tmp_audio() as path:
assert partition_audio(filename=path) == []
def test_partition_audio_returns_one_element_per_segment(mock_stt_agent):
mock_stt_agent.transcribe_segments.return_value = [
{"text": "First segment.", "start": 0.0, "end": 1.0},
{"text": "Second segment.", "start": 1.0, "end": 2.5},
{"text": "Third segment.", "start": 2.5, "end": 4.0},
]
with _tmp_audio() as path:
elements = partition_audio(filename=path)
assert len(elements) == 3
assert elements[0].text == "First segment."
assert elements[0].metadata.segment_start_seconds == 0.0
assert elements[0].metadata.segment_end_seconds == 1.0
assert elements[1].text == "Second segment."
assert elements[1].metadata.segment_start_seconds == 1.0
assert elements[1].metadata.segment_end_seconds == 2.5
assert elements[2].text == "Third segment."
assert elements[2].metadata.segment_start_seconds == 2.5
assert elements[2].metadata.segment_end_seconds == 4.0
def test_partition_audio_filters_whitespace_only_segments(mock_stt_agent):
mock_stt_agent.transcribe_segments.return_value = [
{"text": " ", "start": 0.0, "end": 0.5},
{"text": "Real content.", "start": 0.5, "end": 2.0},
{"text": "\t\n", "start": 2.0, "end": 2.5},
]
with _tmp_audio() as path:
elements = partition_audio(filename=path)
assert len(elements) == 1
assert elements[0].text == "Real content."
# ================================================================================================
# Temp-file lifecycle (file-object input)
# ================================================================================================
@patch("unstructured.partition.audio.SpeechToTextAgent.get_agent")
def test_partition_audio_from_file_uses_temp_path_and_cleans_up(mock_get_agent):
mock_agent = mock_get_agent.return_value
mock_agent.transcribe_segments.return_value = [
{"text": "From file object.", "start": 0.0, "end": 1.0},
]
captured_temp_path: list[str] = []
real_named_temp = tempfile.NamedTemporaryFile
def spy_named_temp(*args, **kwargs):
ctx = real_named_temp(*args, **kwargs)
captured_temp_path.append(ctx.name)
return ctx
with _tmp_audio() as path:
with open(path, "rb") as f:
with patch("unstructured.partition.audio.tempfile.NamedTemporaryFile", spy_named_temp):
elements = partition_audio(file=f, metadata_filename="recording.wav")
assert len(elements) == 1
assert elements[0].text == "From file object."
assert elements[0].metadata.filename == "recording.wav"
assert len(captured_temp_path) == 1, "expected exactly one temp file to be created"
assert not Path(captured_temp_path[0]).exists(), "temp file was not deleted after partitioning"
@patch("unstructured.partition.audio.SpeechToTextAgent.get_agent")
def test_partition_audio_cleans_up_temp_file_when_transcription_raises(mock_get_agent):
mock_agent = mock_get_agent.return_value
mock_agent.transcribe_segments.side_effect = RuntimeError("transcription failed")
captured_temp_path: list[str] = []
real_named_temp = tempfile.NamedTemporaryFile
def spy_named_temp(*args, **kwargs):
ctx = real_named_temp(*args, **kwargs)
captured_temp_path.append(ctx.name)
return ctx
with _tmp_audio() as path:
with open(path, "rb") as f:
with patch("unstructured.partition.audio.tempfile.NamedTemporaryFile", spy_named_temp):
with pytest.raises(RuntimeError, match="transcription failed"):
partition_audio(file=f)
assert len(captured_temp_path) == 1, "expected exactly one temp file to be created"
assert not Path(captured_temp_path[0]).exists(), "temp file was not deleted after exception"
# ================================================================================================
# MIME type / filetype metadata
# ================================================================================================
@patch("unstructured.partition.audio.SpeechToTextAgent.get_agent")
@patch("unstructured.partition.audio.detect_filetype")
def test_partition_audio_stamps_correct_mime_type_for_detected_format(mock_detect, mock_get_agent):
"""metadata.filetype reflects the actual detected audio format, not a hardcoded WAV."""
mock_detect.return_value = FileType.MP3
mock_get_agent.return_value.transcribe_segments.return_value = [
{"text": "MP3 content.", "start": 0.0, "end": 1.0},
]
with _tmp_audio(suffix=".mp3", content=b"\xff\xfb" + b"\x00" * 42) as path:
elements = partition_audio(filename=path)
assert len(elements) == 1
assert elements[0].metadata.filetype == FileType.MP3.mime_type # "audio/mpeg"
@patch("unstructured.partition.audio.SpeechToTextAgent.get_agent")
@patch("unstructured.partition.audio.detect_filetype")
def test_partition_audio_falls_back_to_wav_mime_type_when_format_undetectable(
mock_detect, mock_get_agent
):
"""When filetype detection returns UNK, metadata.filetype falls back to audio/wav."""
mock_detect.return_value = FileType.UNK
mock_get_agent.return_value.transcribe_segments.return_value = [
{"text": "Unknown format.", "start": 0.0, "end": 1.0},
]
with _tmp_audio() as path:
elements = partition_audio(filename=path)
assert len(elements) == 1
assert elements[0].metadata.filetype == FileType.WAV.mime_type # "audio/wav"
# ================================================================================================
# FileType model
# ================================================================================================
@pytest.mark.parametrize(
"file_type",
[
FileType.FLAC,
FileType.M4A,
FileType.MP3,
FileType.OGG,
FileType.OPUS,
FileType.WAV,
FileType.WEBM,
],
)
def test_audio_file_types_are_partitionable(file_type: FileType):
assert file_type.is_partitionable
assert file_type.partitioner_shortname == "audio"
assert file_type.partitioner_function_name == "partition_audio"
assert file_type.extra_name == "audio"
# STT agent deps are validated at runtime by the chosen agent, not at FileType level
assert file_type.importable_package_dependencies == ()
# ================================================================================================
# _audio_suffix helper
# ================================================================================================
@pytest.mark.parametrize(
("metadata_filename", "file_name", "expected"),
[
("recording.mp3", None, ".mp3"),
("recording.mp3", "other.wav", ".mp3"), # metadata_filename wins
(None, "audio.flac", ".flac"),
(None, None, ".wav"), # fallback
("noext", None, ".wav"), # no extension → fallback
],
)
def test_audio_suffix(metadata_filename, file_name, expected):
from io import BytesIO
from unstructured.partition.audio import _audio_suffix
f: IO[bytes] = BytesIO(b"")
if file_name is not None:
f.name = file_name # type: ignore[attr-defined]
assert _audio_suffix(f, metadata_filename) == expected
# ================================================================================================
# partition_audio parameter forwarding
# ================================================================================================
@patch("unstructured.partition.audio.SpeechToTextAgent.get_agent")
def test_partition_audio_forwards_custom_stt_agent_to_get_agent(mock_get_agent):
mock_get_agent.return_value.transcribe_segments.return_value = _ONE_SEGMENT
custom_module = (
"unstructured.partition.utils.speech_to_text.whisper_stt.SpeechToTextAgentWhisper"
)
with _tmp_audio() as path:
partition_audio(filename=path, stt_agent=custom_module)
mock_get_agent.assert_called_once_with(custom_module)
@patch("unstructured.partition.audio.SpeechToTextAgent.get_agent")
def test_partition_audio_forwards_language_to_transcribe_segments(mock_get_agent):
mock_agent = mock_get_agent.return_value
mock_agent.transcribe_segments.return_value = [
{"text": "Hola mundo.", "start": 0.0, "end": 1.5},
]
with _tmp_audio() as path:
elements = partition_audio(filename=path, language="es")
mock_agent.transcribe_segments.assert_called_once_with(path, language="es")
assert elements[0].text == "Hola mundo."
# ================================================================================================
# SpeechToTextAgent unit tests
# ================================================================================================
@pytest.mark.usefixtures("_clear_stt_cache")
class TestSpeechToTextAgentInterface:
"""Unit tests for the SpeechToTextAgent base class."""
def test_get_agent_uses_env_config_when_no_module_given(self):
import os
from unittest.mock import patch as _patch
from unstructured.partition.utils.speech_to_text.speech_to_text_interface import (
SpeechToTextAgent,
)
sentinel = "unstructured.partition.utils.speech_to_text.whisper_stt.SentinelAgent"
with (
_patch.dict(os.environ, {"STT_AGENT": sentinel}),
_patch.object(SpeechToTextAgent, "get_instance") as mock_get_instance,
):
SpeechToTextAgent.get_agent(None)
mock_get_instance.assert_called_once_with(sentinel)
def test_get_agent_passes_explicit_module_to_get_instance(self):
from unittest.mock import patch as _patch
from unstructured.partition.utils.speech_to_text.speech_to_text_interface import (
SpeechToTextAgent,
)
custom = "unstructured.partition.utils.speech_to_text.whisper_stt.SpeechToTextAgentWhisper"
with _patch.object(SpeechToTextAgent, "get_instance") as mock_get_instance:
SpeechToTextAgent.get_agent(custom)
mock_get_instance.assert_called_once_with(custom)
def test_get_instance_rejects_non_whitelisted_module(self):
from unstructured.partition.utils.speech_to_text.speech_to_text_interface import (
SpeechToTextAgent,
)
with pytest.raises(ValueError, match="must be in the whitelist"):
SpeechToTextAgent.get_instance("evil.module.EvilAgent")
def test_get_instance_rejects_whitelisted_module_attribute_that_is_not_a_class(self):
"""Whitelisted module exposes a non-class (e.g. function or constant) -> TypeError."""
from unittest.mock import MagicMock, patch
from unstructured.partition.utils.speech_to_text.speech_to_text_interface import (
SpeechToTextAgent,
)
agent_module = "unstructured.partition.utils.speech_to_text.whisper_stt.some_function"
mock_mod = MagicMock()
mock_mod.some_function = lambda x: x # not a class
with patch(
"unstructured.partition.utils.speech_to_text.speech_to_text_interface.importlib.import_module",
return_value=mock_mod,
):
with pytest.raises(TypeError, match="does not refer to a class"):
SpeechToTextAgent.get_instance(agent_module)
def test_get_instance_rejects_class_that_does_not_subclass_speech_to_text_agent(self):
"""Whitelisted module exposes a class not subclassing SpeechToTextAgent -> TypeError."""
from unittest.mock import MagicMock, patch
from unstructured.partition.utils.speech_to_text.speech_to_text_interface import (
SpeechToTextAgent,
)
agent_module = "unstructured.partition.utils.speech_to_text.whisper_stt.WrongClass"
mock_mod = MagicMock()
mock_mod.WrongClass = object # a class, but not a subclass of SpeechToTextAgent
with patch(
"unstructured.partition.utils.speech_to_text.speech_to_text_interface.importlib.import_module",
return_value=mock_mod,
):
with pytest.raises(TypeError, match="must be a subclass of SpeechToTextAgent"):
SpeechToTextAgent.get_instance(agent_module)