4 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
LeoVasanko 7633ee0d84 onlyoffice: bind temp file server to bridge IP, silence handler tracebacks, self-limit lifetime
The temporary HTTP server that OnlyOffice downloads the source document
from was bound to 0.0.0.0, so internet scanners could (and did) connect,
and socketserver dumped a full traceback to stderr for every dropped
connection. It also stayed up for the whole conversion attempt, leaving
the port exposed when conversions hang.

- Bind only to the callback host (oonet gateway by default) so the port
  is unreachable from the internet.
- Override handle_error to log at debug level instead of printing
  tracebacks.
- Watchdog shuts the server down ~2s after the file is fetched, or at
  max_lifetime (request_timeout + 30s), and the socket is closed with
  server_close() in the normal path.
2026-09-10 20:22:41 +00:00
11 changed files with 177 additions and 23 deletions
-2
View File
@@ -39,8 +39,6 @@ def _configure_logging() -> None:
handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(EmojiFormatter())
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:
+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.
"""
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}")
+3
View File
@@ -9,6 +9,9 @@ from time import perf_counter
import pyvips
from mediapreview.protocol import PreviewResponse
from mediapreview.util.logformat import quiet_vips_logging
quiet_vips_logging()
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()
try:
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:
raise backend_error(BACKEND, str(e)) from e
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:
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:
@@ -191,4 +193,6 @@ def preview_timeout_error(
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
)
+47 -9
View File
@@ -283,32 +283,65 @@ async def is_available_cached() -> bool:
# ---------------------------------------------------------------------------
class _TempServer(socketserver.TCPServer):
"""TCPServer that logs handler errors instead of dumping tracebacks to stderr."""
daemon_threads = True
oo_fetched: bool
def handle_error(self, request, client_address) -> None: # noqa: ARG002
# Dropped connections (client disconnects mid-request, port scanners)
# are routine noise; socketserver's default prints a full traceback.
logger.debug("Temp file server: error from %s", client_address)
class _QuietHandler(SimpleHTTPRequestHandler):
server: _TempServer
def log_message(self, fmt, *args) -> None:
# Any request logged here means a client (OnlyOffice) connected to
# fetch the file; record it for timeout diagnostics.
self.server.oo_fetched = True
def _get_free_port() -> int:
def _get_free_port(host: str) -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("0.0.0.0", 0)) # noqa: S104
s.bind((host, 0))
return s.getsockname()[1]
def _serve_file_temporarily(file_path: Path):
"""Start a temporary HTTP server for *file_path* and return (url, server)."""
def _serve_file_temporarily(file_path: Path, max_lifetime: float = 60.0):
"""Start a temporary HTTP server for *file_path* and return (url, server).
The server binds only to the callback host address (the docker bridge
gateway by default), not 0.0.0.0, so it is unreachable from the internet.
It shuts itself down shortly after the file has been fetched, or when
*max_lifetime* elapses, so a hung OnlyOffice request cannot leave the
port open indefinitely.
"""
directory = str(file_path.parent)
filename = file_path.name
port = _get_free_port()
host = _get_callback_host()
port = _get_free_port(host)
handler = partial(_QuietHandler, directory=directory)
httpd = socketserver.TCPServer(("0.0.0.0", port), handler) # noqa: S104
httpd = _TempServer((host, port), handler)
httpd.oo_fetched = False
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
host = _get_callback_host()
def _watchdog() -> None:
deadline = perf_counter() + max_lifetime
while perf_counter() < deadline and not httpd.oo_fetched:
threading.Event().wait(0.1)
if httpd.oo_fetched:
# Brief grace so the in-flight response finishes transferring.
threading.Event().wait(2.0)
httpd.shutdown()
httpd.server_close()
threading.Thread(target=_watchdog, daemon=True).start()
url = f"http://{host}:{port}/{quote(filename)}"
return url, httpd
@@ -343,8 +376,12 @@ async def convert_to_png_async(
convert_url = f"{oo_url}/ConvertService.ashx"
client = get_httpx_client()
# Start temporary HTTP server so OnlyOffice can fetch the file
doc_url, httpd = await asyncio.to_thread(_serve_file_temporarily, file_path)
# Start temporary HTTP server so OnlyOffice can fetch the file. The
# watchdog lifetime covers the full conversion plus slack so a hung
# conversion cannot leave the port open forever.
doc_url, httpd = await asyncio.to_thread(
_serve_file_temporarily, file_path, request_timeout + 30.0
)
try:
suffix = file_path.suffix.lstrip(".").lower()
payload = {
@@ -415,6 +452,7 @@ async def convert_to_png_async(
return png_response.content
finally:
await asyncio.to_thread(httpd.shutdown)
await asyncio.to_thread(httpd.server_close)
# ---------------------------------------------------------------------------
+15
View File
@@ -31,3 +31,18 @@ class EmojiFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
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.setFormatter(_WorkerLogFormatter())
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.
# Consumers can load their own configuration before starting workers.
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)
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)
i = start
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:
size = int.from_bytes(data[i + 8 : i + 16], "big")
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
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 = _matrix_90_cw() if degrees == 90 else _matrix_270_cw()
for i, val in enumerate(matrix):
data[matrix_offset + i * 4 : matrix_offset + (i + 1) * 4] = val.to_bytes(
4, "big", signed=True
data[matrix_offset + i * 4 : matrix_offset + (i + 1) * 4] = (
val.to_bytes(4, "big", signed=True)
)
out_path.write_bytes(data)
return
+5
View File
@@ -95,10 +95,15 @@ def test_error_pickle_round_trip():
@pytest.mark.asyncio
async def test_generate_office_preview_raises_structured_error(monkeypatch):
"""On OnlyOffice failure, generate_office_preview raises OnlyOfficeError."""
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(
+49 -1
View File
@@ -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
@@ -106,7 +107,9 @@ VIDEO_FIXTURES = [
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."""
data, resp = process_video(FILES / filename, maxsize=512, quality=60)
_assert_ok(data, resp, backend="video")
@@ -159,6 +162,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
# ---------------------------------------------------------------------------