Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
720c4f06e5 | ||
|
|
c15e964e6d | ||
|
|
7633ee0d84 | ||
|
|
d4dfc57994 | ||
|
|
65e2fddf1a |
@@ -32,15 +32,14 @@ from docopt import docopt
|
|||||||
|
|
||||||
from mediapreview.backends import dispatch
|
from mediapreview.backends import dispatch
|
||||||
from mediapreview.exceptions import PreviewError
|
from mediapreview.exceptions import PreviewError
|
||||||
from mediapreview.util.logformat import EmojiFormatter
|
from mediapreview.util.logformat import EmojiFormatter, quiet_vips_logging
|
||||||
|
|
||||||
|
|
||||||
def _configure_logging() -> None:
|
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).
|
quiet_vips_logging()
|
||||||
logging.getLogger("pyvips").setLevel(logging.WARNING)
|
|
||||||
|
|
||||||
|
|
||||||
def _oosetup(name: str) -> None:
|
def _oosetup(name: str) -> None:
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
+63
-70
@@ -50,9 +50,11 @@ logger = logging.getLogger(__name__)
|
|||||||
# Isolated docker network for the OnlyOffice container: internal-only (no
|
# Isolated docker network for the OnlyOffice container: internal-only (no
|
||||||
# outbound internet), the container can only reach the host on this bridge.
|
# outbound internet), the container can only reach the host on this bridge.
|
||||||
# Docker discards published ports on internal networks, so the container is
|
# Docker discards published ports on internal networks, so the container is
|
||||||
# reached at its fixed IP instead of a published localhost port.
|
# reached at its fixed IP instead of a published localhost port. The host is
|
||||||
|
# always the first address of the pinned subnet (the bridge gateway).
|
||||||
OO_NETWORK = "oonet"
|
OO_NETWORK = "oonet"
|
||||||
OO_SUBNET = "172.30.0.0/24"
|
OO_SUBNET = "172.30.0.0/24"
|
||||||
|
OO_GATEWAY = "172.30.0.1"
|
||||||
OO_CONTAINER_IP = "172.30.0.2"
|
OO_CONTAINER_IP = "172.30.0.2"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -65,73 +67,28 @@ _httpx_client_loop: asyncio.AbstractEventLoop | None = None
|
|||||||
|
|
||||||
|
|
||||||
def _get_onlyoffice_url() -> str:
|
def _get_onlyoffice_url() -> str:
|
||||||
if url := os.environ.get(
|
# The container runs on the isolated oonet network at a fixed IP; the
|
||||||
"ONLYOFFICE_URL", os.environ.get("ONLYOFFICE_CISTA_URL")
|
# host is the bridge gateway and reaches it directly, no published port.
|
||||||
):
|
return os.environ.get(
|
||||||
return url
|
"ONLYOFFICE_URL",
|
||||||
# When the isolated network exists, the container is at its fixed IP and
|
os.environ.get("ONLYOFFICE_CISTA_URL", f"http://{OO_CONTAINER_IP}"),
|
||||||
# no localhost port is published (Docker discards ports on internal
|
)
|
||||||
# networks). Otherwise assume a legacy setup with a published port.
|
|
||||||
if _docker_network_gateway(OO_NETWORK):
|
|
||||||
return f"http://{OO_CONTAINER_IP}"
|
|
||||||
return "http://localhost:8988"
|
|
||||||
|
|
||||||
|
|
||||||
def _get_jwt_secret() -> str:
|
def _get_jwt_secret() -> str:
|
||||||
return os.environ.get("ONLYOFFICE_JWT_SECRET", "")
|
return os.environ.get("ONLYOFFICE_JWT_SECRET", "")
|
||||||
|
|
||||||
|
|
||||||
def _docker_network_gateway(network: str) -> str | None:
|
|
||||||
"""Return the host-side gateway IP of a docker network, or None."""
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
[
|
|
||||||
"docker",
|
|
||||||
"network",
|
|
||||||
"inspect",
|
|
||||||
network,
|
|
||||||
"--format",
|
|
||||||
"{{range .IPAM.Config}}{{.Gateway}}{{end}}",
|
|
||||||
],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=2,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
gateway = result.stdout.strip()
|
|
||||||
if result.returncode == 0 and gateway:
|
|
||||||
return gateway
|
|
||||||
except Exception:
|
|
||||||
logger.debug("Failed to inspect docker network %s", network)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
def _get_callback_host() -> str:
|
def _get_callback_host() -> str:
|
||||||
"""Return the host IP that OnlyOffice (in Docker) can use to reach us."""
|
"""Return the host IP that OnlyOffice (in Docker) can use to reach us.
|
||||||
|
|
||||||
|
The host is always the gateway of the pinned oonet subnet; no detection
|
||||||
|
is needed (the cista service account may not have docker CLI access).
|
||||||
|
"""
|
||||||
if host := os.environ.get("ONLYOFFICE_CALLBACK_HOST"):
|
if host := os.environ.get("ONLYOFFICE_CALLBACK_HOST"):
|
||||||
return host
|
return host
|
||||||
# Prefer the gateway of the isolated network setup_docker() creates —
|
return OO_GATEWAY
|
||||||
# this is the network the container is actually attached to.
|
|
||||||
if gateway := _docker_network_gateway(OO_NETWORK):
|
|
||||||
return gateway
|
|
||||||
# Fall back to the default docker bridge IP
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["/sbin/ip", "-4", "addr", "show", "docker0"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=2,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
for line in result.stdout.splitlines():
|
|
||||||
if "inet " in line:
|
|
||||||
parts = line.strip().split()
|
|
||||||
addr_part = parts[1] # e.g. 172.17.0.1/16
|
|
||||||
return addr_part.split("/")[0]
|
|
||||||
except Exception:
|
|
||||||
logger.debug("Failed to auto-detect docker bridge IP")
|
|
||||||
return "127.0.0.1"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -271,9 +228,7 @@ def setup_docker(name: str = "onlyoffice-mediapreview") -> str:
|
|||||||
# Docker discards published ports on internal networks, so the container
|
# Docker discards published ports on internal networks, so the container
|
||||||
# is reached at its fixed IP; no localhost port is exposed.
|
# is reached at its fixed IP; no localhost port is exposed.
|
||||||
logger.info("OnlyOffice is running on http://%s", OO_CONTAINER_IP)
|
logger.info("OnlyOffice is running on http://%s", OO_CONTAINER_IP)
|
||||||
logger.info(
|
logger.info("Callback host for file downloads: %s", OO_GATEWAY)
|
||||||
"Callback host for file downloads: %s", _docker_network_gateway(OO_NETWORK)
|
|
||||||
)
|
|
||||||
return secret
|
return secret
|
||||||
|
|
||||||
|
|
||||||
@@ -328,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):
|
class _QuietHandler(SimpleHTTPRequestHandler):
|
||||||
|
server: _TempServer
|
||||||
|
|
||||||
def log_message(self, fmt, *args) -> None:
|
def log_message(self, fmt, *args) -> None:
|
||||||
# Any request logged here means a client (OnlyOffice) connected to
|
# Any request logged here means a client (OnlyOffice) connected to
|
||||||
# fetch the file; record it for timeout diagnostics.
|
# fetch the file; record it for timeout diagnostics.
|
||||||
self.server.oo_fetched = True
|
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:
|
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]
|
return s.getsockname()[1]
|
||||||
|
|
||||||
|
|
||||||
def _serve_file_temporarily(file_path: Path):
|
def _serve_file_temporarily(file_path: Path, max_lifetime: float = 60.0):
|
||||||
"""Start a temporary HTTP server for *file_path* and return (url, server)."""
|
"""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)
|
directory = str(file_path.parent)
|
||||||
filename = file_path.name
|
filename = file_path.name
|
||||||
port = _get_free_port()
|
host = _get_callback_host()
|
||||||
|
port = _get_free_port(host)
|
||||||
|
|
||||||
handler = partial(_QuietHandler, directory=directory)
|
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
|
httpd.oo_fetched = False
|
||||||
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||||
thread.start()
|
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)}"
|
url = f"http://{host}:{port}/{quote(filename)}"
|
||||||
return url, httpd
|
return url, httpd
|
||||||
|
|
||||||
@@ -388,8 +376,12 @@ async def convert_to_png_async(
|
|||||||
convert_url = f"{oo_url}/ConvertService.ashx"
|
convert_url = f"{oo_url}/ConvertService.ashx"
|
||||||
client = get_httpx_client()
|
client = get_httpx_client()
|
||||||
|
|
||||||
# Start temporary HTTP server so OnlyOffice can fetch the file
|
# Start temporary HTTP server so OnlyOffice can fetch the file. The
|
||||||
doc_url, httpd = await asyncio.to_thread(_serve_file_temporarily, file_path)
|
# 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:
|
try:
|
||||||
suffix = file_path.suffix.lstrip(".").lower()
|
suffix = file_path.suffix.lstrip(".").lower()
|
||||||
payload = {
|
payload = {
|
||||||
@@ -460,6 +452,7 @@ async def convert_to_png_async(
|
|||||||
return png_response.content
|
return png_response.content
|
||||||
finally:
|
finally:
|
||||||
await asyncio.to_thread(httpd.shutdown)
|
await asyncio.to_thread(httpd.shutdown)
|
||||||
|
await asyncio.to_thread(httpd.server_close)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ except ImportError: # pragma: no cover - optional worker extra
|
|||||||
from mediapreview.backends import dispatch
|
from mediapreview.backends import dispatch
|
||||||
from mediapreview.exceptions import PreviewError
|
from mediapreview.exceptions import PreviewError
|
||||||
from mediapreview.protocol import PreviewRequest, PreviewResponse
|
from mediapreview.protocol import PreviewRequest, PreviewResponse
|
||||||
from mediapreview.util.logformat import format_level_prefix
|
from mediapreview.util.logformat import format_level_prefix, quiet_vips_logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -171,8 +171,7 @@ 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).
|
quiet_vips_logging()
|
||||||
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:
|
||||||
|
|||||||
@@ -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:
|
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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -159,6 +160,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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user