3 Commits
Author SHA1 Message Date
LeoVasanko 73c6a55bce Quiet VIPS logging at import time. Ruff format. 2026-09-20 00:50:18 +00:00
LeoVasanko 720c4f06e5 logging: add opt-in quiet_vips_logging() helper
pyvips redirects all GLib messages ("VIPS: threadpool completed ..."
per operation) onto the 'pyvips' logger at INFO. The helper caps that
logger at WARNING while keeping 'pyvips.voperation' (deprecated-argument
notices) at INFO. Library import remains free of logging side effects;
the CLI and worker entry points call the helper instead of their old
bare setLevel, which also hid the deprecation notices.
2026-09-18 03:42:36 +00:00
Leo Vasanko c15e964e6d 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'.
2026-09-15 02:11:35 +00:00
10 changed files with 130 additions and 14 deletions
-2
View File
@@ -39,8 +39,6 @@ def _configure_logging() -> None:
handler = logging.StreamHandler(sys.stderr) handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(EmojiFormatter()) handler.setFormatter(EmojiFormatter())
logging.basicConfig(level=logging.INFO, handlers=[handler]) logging.basicConfig(level=logging.INFO, handlers=[handler])
# pyvips is chatty at INFO ("threadpool completed ..." per operation).
logging.getLogger("pyvips").setLevel(logging.WARNING)
def _oosetup(name: str) -> None: def _oosetup(name: str) -> None:
+41 -2
View File
@@ -5,6 +5,7 @@ plus quality/size parameters and returning `(avif_bytes, PreviewResponse)`.
`dispatch` picks the right backend for a path. `dispatch` picks the right backend for a path.
""" """
import asyncio
import logging import logging
import mimetypes import mimetypes
@@ -16,7 +17,7 @@ from mediapreview.backends.image import (
from mediapreview.backends.pdf import process_pdf from mediapreview.backends.pdf import process_pdf
from mediapreview.backends.video import process_video from mediapreview.backends.video import process_video
from mediapreview.exceptions import PreviewError, backend_error 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__ = [ __all__ = [
"dispatch", "dispatch",
@@ -42,6 +43,42 @@ def dispatch(path, quality, maxsize, maxzoom, data=None):
if suffix in DOC_PREVIEW_SUFFIXES: if suffix in DOC_PREVIEW_SUFFIXES:
backend = "pdf" backend = "pdf"
return process_pdf(path, quality=quality, maxsize=maxsize, maxzoom=maxzoom) 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) mime_type, _ = mimetypes.guess_type(path.name)
if mime_type and mime_type.startswith("video/"): if mime_type and mime_type.startswith("video/"):
backend = "video" backend = "video"
@@ -62,4 +99,6 @@ def dispatch(path, quality, maxsize, maxzoom, data=None):
except Exception as e: except Exception as e:
logger.exception("Preview dispatch failed for %s", path) logger.exception("Preview dispatch failed for %s", path)
raise backend_error(backend, str(e)) from e 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}")
+3
View File
@@ -9,6 +9,9 @@ from time import perf_counter
import pyvips import pyvips
from mediapreview.protocol import PreviewResponse from mediapreview.protocol import PreviewResponse
from mediapreview.util.logformat import quiet_vips_logging
quiet_vips_logging()
AVIF_FAST_EFFORT = 0 AVIF_FAST_EFFORT = 0
+3 -1
View File
@@ -38,7 +38,9 @@ def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
t_save_start = perf_counter() t_save_start = perf_counter()
try: try:
img = pyvips.Image.new_from_memory(samples, width, height, n, "uchar") img = pyvips.Image.new_from_memory(samples, width, height, n, "uchar")
ret = img.write_to_buffer(".avif", Q=quality, effort=AVIF_FAST_EFFORT, keep="none") ret = img.write_to_buffer(
".avif", Q=quality, effort=AVIF_FAST_EFFORT, keep="none"
)
except Exception as e: except Exception as e:
raise backend_error(BACKEND, str(e)) from e raise backend_error(BACKEND, str(e)) from e
t_save_end = perf_counter() t_save_end = perf_counter()
+6 -2
View File
@@ -145,7 +145,9 @@ def onlyoffice_unavailable_error(url: str | None = None) -> OnlyOfficeError:
def onlyoffice_http_error(status: int) -> OnlyOfficeError: def onlyoffice_http_error(status: int) -> OnlyOfficeError:
return OnlyOfficeError(f"OnlyOffice HTTP error: {status}", "http error", status=status) return OnlyOfficeError(
f"OnlyOffice HTTP error: {status}", "http error", status=status
)
def onlyoffice_no_fileurl_error(snippet: str | None = None) -> OnlyOfficeError: def onlyoffice_no_fileurl_error(snippet: str | None = None) -> OnlyOfficeError:
@@ -191,4 +193,6 @@ def preview_timeout_error(
def preview_cancelled_error(reason: str = "pool closed") -> PreviewCancelledError: def preview_cancelled_error(reason: str = "pool closed") -> PreviewCancelledError:
return PreviewCancelledError(f"Preview cancelled ({reason})", "cancelled", reason=reason) return PreviewCancelledError(
f"Preview cancelled ({reason})", "cancelled", reason=reason
)
+15
View File
@@ -31,3 +31,18 @@ class EmojiFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str: def format(self, record: logging.LogRecord) -> str:
return format_level_prefix(record.levelno) + record.getMessage() return format_level_prefix(record.levelno) + record.getMessage()
def quiet_vips_logging() -> None:
"""Silence libvips per-operation chatter without hiding deprecations.
pyvips redirects every GLib message ("VIPS: threadpool completed ...")
onto the ``pyvips`` logger at INFO; cap that logger at WARNING. pyvips's
own diagnostics (e.g. deprecated-argument notices) are logged on the
``pyvips.voperation`` child logger and stay at the default INFO.
Opt-in: applications that want quiet vips output call this once during
their own logging setup. mediapreview never calls it on import.
"""
logging.getLogger("pyvips").setLevel(logging.WARNING)
logging.getLogger("pyvips.voperation").setLevel(logging.INFO)
-2
View File
@@ -171,8 +171,6 @@ def main() -> None:
handler = logging.StreamHandler(sys.stderr) handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(_WorkerLogFormatter()) handler.setFormatter(_WorkerLogFormatter())
logging.basicConfig(level=logging.INFO, handlers=[handler]) logging.basicConfig(level=logging.INFO, handlers=[handler])
# pyvips is chatty at INFO ("threadpool completed ..." per operation).
logging.getLogger("pyvips").setLevel(logging.WARNING)
# NOTE: standalone package no longer depends on cista config loading. # NOTE: standalone package no longer depends on cista config loading.
# Consumers can load their own configuration before starting workers. # Consumers can load their own configuration before starting workers.
if len(sys.argv) > 1: if len(sys.argv) > 1:
+8 -4
View File
@@ -29,7 +29,9 @@ def run(cmd: list[str]) -> None:
subprocess.run(cmd, check=True, capture_output=True, text=True) subprocess.run(cmd, check=True, capture_output=True, text=True)
def _find_box(data: bytearray, box_type: bytes, start: int = 0, end: int | None = None) -> int: def _find_box(
data: bytearray, box_type: bytes, start: int = 0, end: int | None = None
) -> int:
end = end or len(data) end = end or len(data)
i = start i = start
while i + 8 <= end: while i + 8 <= end:
@@ -42,7 +44,9 @@ def _find_box(data: bytearray, box_type: bytes, start: int = 0, end: int | None
if size == 1: if size == 1:
size = int.from_bytes(data[i + 8 : i + 16], "big") size = int.from_bytes(data[i + 8 : i + 16], "big")
if size < 8: if size < 8:
raise ValueError(f"Invalid box size {size} for {btype.decode('ascii', errors='replace')}") raise ValueError(
f"Invalid box size {size} for {btype.decode('ascii', errors='replace')}"
)
i += size i += size
return -1 return -1
@@ -88,8 +92,8 @@ def _patch_tkhd_rotation(in_path: Path, out_path: Path, degrees: int) -> None:
matrix_offset = tkhd_idx + 48 matrix_offset = tkhd_idx + 48
matrix = _matrix_90_cw() if degrees == 90 else _matrix_270_cw() matrix = _matrix_90_cw() if degrees == 90 else _matrix_270_cw()
for i, val in enumerate(matrix): for i, val in enumerate(matrix):
data[matrix_offset + i * 4 : matrix_offset + (i + 1) * 4] = val.to_bytes( data[matrix_offset + i * 4 : matrix_offset + (i + 1) * 4] = (
4, "big", signed=True val.to_bytes(4, "big", signed=True)
) )
out_path.write_bytes(data) out_path.write_bytes(data)
return return
+5
View File
@@ -95,10 +95,15 @@ def test_error_pickle_round_trip():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_generate_office_preview_raises_structured_error(monkeypatch): async def test_generate_office_preview_raises_structured_error(monkeypatch):
"""On OnlyOffice failure, generate_office_preview raises OnlyOfficeError.""" """On OnlyOffice failure, generate_office_preview raises OnlyOfficeError."""
async def fake_convert(_filepath: Path, request_timeout: float = 5.0) -> bytes: async def fake_convert(_filepath: Path, request_timeout: float = 5.0) -> bytes:
raise onlyoffice_error_from_code("-8") 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, "convert_to_png_async", fake_convert)
monkeypatch.setattr(office, "is_available_cached", fake_available)
with pytest.raises(OnlyOfficeError) as exc_info: with pytest.raises(OnlyOfficeError) as exc_info:
await generate_office_preview( await generate_office_preview(
+49 -1
View File
@@ -28,6 +28,7 @@ from mediapreview.backends.image import (
) )
from mediapreview.backends.pdf import process_pdf from mediapreview.backends.pdf import process_pdf
from mediapreview.backends.video import process_video from mediapreview.backends.video import process_video
from mediapreview.exceptions import PreviewBackendError
from mediapreview.office import is_available_async from mediapreview.office import is_available_async
from mediapreview.pool import generate_office_preview from mediapreview.pool import generate_office_preview
@@ -106,7 +107,9 @@ VIDEO_FIXTURES = [
VIDEO_FIXTURES, VIDEO_FIXTURES,
ids=[f[0] for f in VIDEO_FIXTURES], ids=[f[0] for f in VIDEO_FIXTURES],
) )
def test_process_video(filename: str, expected_width: int, expected_height: int) -> None: def test_process_video(
filename: str, expected_width: int, expected_height: int
) -> None:
"""SDR and HDR video clips, with and without rotation, convert successfully.""" """SDR and HDR video clips, with and without rotation, convert successfully."""
data, resp = process_video(FILES / filename, maxsize=512, quality=60) data, resp = process_video(FILES / filename, maxsize=512, quality=60)
_assert_ok(data, resp, backend="video") _assert_ok(data, resp, backend="video")
@@ -159,6 +162,51 @@ def test_dispatch(
assert resp.height == expected_height 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 # Office previews via OnlyOffice
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------