From c15e964e6dce8d12254b9bd08188e5bc9bca6047 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Tue, 15 Sep 2026 02:11:35 +0000 Subject: [PATCH] backends: route office formats through dispatch(); diagnostic unknown-extension errors Synchronous dispatch() now converts office documents via OnlyOffice (asyncio.run around OOConversionManager.convert), refusing to run inside a live event loop and pointing async callers at pool.generate_office_preview(). The office import stays lazy so the base install works without the 'office' extra. Unknown file types now name the offending extension (or its absence) instead of a generic 'preview unsupported'. --- mediapreview/backends/__init__.py | 43 +++++++++++++++++++++++++++-- tests/test_office.py | 4 +++ tests/test_previews.py | 46 +++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 2 deletions(-) diff --git a/mediapreview/backends/__init__.py b/mediapreview/backends/__init__.py index 756cd98..0b1cb5c 100644 --- a/mediapreview/backends/__init__.py +++ b/mediapreview/backends/__init__.py @@ -5,6 +5,7 @@ plus quality/size parameters and returning `(avif_bytes, PreviewResponse)`. `dispatch` picks the right backend for a path. """ +import asyncio import logging import mimetypes @@ -16,7 +17,7 @@ from mediapreview.backends.image import ( from mediapreview.backends.pdf import process_pdf from mediapreview.backends.video import process_video from mediapreview.exceptions import PreviewError, backend_error -from mediapreview.formats import DOC_PREVIEW_SUFFIXES +from mediapreview.formats import DOC_PREVIEW_SUFFIXES, OFFICE_PREVIEW_SUFFIXES __all__ = [ "dispatch", @@ -42,6 +43,42 @@ def dispatch(path, quality, maxsize, maxzoom, data=None): if suffix in DOC_PREVIEW_SUFFIXES: backend = "pdf" return process_pdf(path, quality=quality, maxsize=maxsize, maxzoom=maxzoom) + if suffix in OFFICE_PREVIEW_SUFFIXES: + backend = "onlyoffice" + try: + from mediapreview.office import ( # noqa: PLC0415 + close_oo_client, + get_oo_manager, + ) + except ImportError as e: + raise ImportError( + "Office document previews require the 'office' extra:" + " pip install mediapreview[office]" + ) from e + try: + asyncio.get_running_loop() + except RuntimeError: + pass # no event loop, asyncio.run() is safe + else: + raise RuntimeError( + "Office preview via dispatch() cannot be called inside a running" + " event loop; use mediapreview.pool.generate_office_preview() instead" + ) + + async def _convert_office() -> bytes: + manager = get_oo_manager() + try: + return await manager.convert(path) + finally: + await close_oo_client() + + png_bytes = asyncio.run(_convert_office()) + result, resp = process_image_buffer( + png_bytes, quality=quality, maxsize=maxsize, maxzoom=maxzoom + ) + if resp is not None: + resp.backend = "onlyoffice+" + (resp.backend or "vips") + return result, resp mime_type, _ = mimetypes.guess_type(path.name) if mime_type and mime_type.startswith("video/"): backend = "video" @@ -62,4 +99,6 @@ def dispatch(path, quality, maxsize, maxzoom, data=None): except Exception as e: logger.exception("Preview dispatch failed for %s", path) raise backend_error(backend, str(e)) from e - raise backend_error(backend, "preview unsupported") + if not suffix: + raise backend_error(backend, "unknown file type: no file extension") + raise backend_error(backend, f"unknown file extension: {suffix!r}") diff --git a/tests/test_office.py b/tests/test_office.py index 99e055f..39d382a 100644 --- a/tests/test_office.py +++ b/tests/test_office.py @@ -98,7 +98,11 @@ async def test_generate_office_preview_raises_structured_error(monkeypatch): async def fake_convert(_filepath: Path, request_timeout: float = 5.0) -> bytes: raise onlyoffice_error_from_code("-8") + async def fake_available() -> bool: + return True + monkeypatch.setattr(office, "convert_to_png_async", fake_convert) + monkeypatch.setattr(office, "is_available_cached", fake_available) with pytest.raises(OnlyOfficeError) as exc_info: await generate_office_preview( diff --git a/tests/test_previews.py b/tests/test_previews.py index c420958..5ea1ba8 100644 --- a/tests/test_previews.py +++ b/tests/test_previews.py @@ -28,6 +28,7 @@ from mediapreview.backends.image import ( ) from mediapreview.backends.pdf import process_pdf from mediapreview.backends.video import process_video +from mediapreview.exceptions import PreviewBackendError from mediapreview.office import is_available_async from mediapreview.pool import generate_office_preview @@ -159,6 +160,51 @@ def test_dispatch( assert resp.height == expected_height +def test_dispatch_office(monkeypatch) -> None: + """dispatch() converts office documents via OnlyOffice when called directly.""" + fake_png = (FILES / "Landscape_1.jpg").read_bytes() + + class _FakeManager: + async def convert(self, filepath: Path) -> bytes: + assert filepath == FILES / "file-sample_100kB.docx" + return fake_png + + async def _noop() -> None: + return None + + monkeypatch.setattr("mediapreview.office.get_oo_manager", _FakeManager) + monkeypatch.setattr("mediapreview.office.close_oo_client", _noop) + + data, resp = dispatch( + FILES / "file-sample_100kB.docx", + quality=60, + maxsize=512, + maxzoom=2.0, + ) + _assert_ok(data, resp) + assert resp.backend == "onlyoffice+vips" + + +def test_dispatch_unknown_extension(tmp_path: Path) -> None: + """Unsupported extensions produce a diagnostic naming the extension.""" + path = tmp_path / "unknown-file.xyz" + path.write_text("not a previewable file") + with pytest.raises(PreviewBackendError) as exc_info: + dispatch(path, quality=60, maxsize=512, maxzoom=2.0) + assert "unknown file extension: '.xyz'" in str(exc_info.value) + assert exc_info.value.backend == "unknown" + + +def test_dispatch_no_extension(tmp_path: Path) -> None: + """Files without an extension produce a diagnostic saying so.""" + path = tmp_path / "unknown-file-no-ext" + path.write_text("not a previewable file") + with pytest.raises(PreviewBackendError) as exc_info: + dispatch(path, quality=60, maxsize=512, maxzoom=2.0) + assert "unknown file type: no file extension" in str(exc_info.value) + assert exc_info.value.backend == "unknown" + + # --------------------------------------------------------------------------- # Office previews via OnlyOffice # ---------------------------------------------------------------------------