From 49cca4b0c1ba1530d42baf38e2ad864fa00e4431 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Tue, 11 Aug 2026 23:34:27 +0000 Subject: [PATCH] Make CLI work without worker extras; add oosetup subcommand - __main__ is now a self-contained docopt CLI going straight to backends.dispatch (no blake3/tracerite needed); 'oosetup' builds and starts the bundled OnlyOffice container and prints ONLYOFFICE_JWT_SECRET= as its only stdout line - dispatch: log a plain one-line error for missing-extra ImportErrors instead of a traceback - worker/pool: friendly install hints when the 'worker' extra is missing - office: fix import on Python 3.11-3.13 without the 'office' extra (lazy annotations); setup_docker returns the JWT secret used and redacts it from the logged docker command - add 'mediapreview' console script and docopt-ng dependency --- README.md | 27 ++++++-- mediapreview/__main__.py | 101 ++++++++++++++++++++++++++++-- mediapreview/backends/__init__.py | 4 ++ mediapreview/office.py | 16 +++-- mediapreview/pool.py | 8 ++- mediapreview/worker.py | 12 +++- pyproject.toml | 4 ++ 7 files changed, 157 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index d47789a..4c3a75e 100644 --- a/README.md +++ b/README.md @@ -46,13 +46,32 @@ Worker pool (isolates heavy imports and native crashes from the async loop): from mediapreview.pool import start_preview_workers, shutdown_preview_workers ``` +One-shot CLI (works with the base install, no worker extra needed): + +```bash +mediapreview photo.jpg -o preview.avif # or: python -m mediapreview ... +mediapreview doc.pdf -q 70 --maxsize 1024 # needs the matching backend extra +``` + ## OnlyOffice Docker bootstrap A patched OnlyOffice image (configurable converter worker count) ships as package data and can be built/started with: -```python -from mediapreview.office import setup_docker - -setup_docker() # builds + runs "onlyoffice-mediapreview" on port 8988 +```bash +mediapreview oosetup # builds + runs "onlyoffice-mediapreview" on port 8988 ``` + +Container name and port are optional positional args: +`mediapreview oosetup [name] [port]`. + +`oosetup` logs progress to stderr and prints exactly one line on stdout: + +``` +ONLYOFFICE_JWT_SECRET= +``` + +If `ONLYOFFICE_JWT_SECRET` is already set in the environment it is used as-is +(and echoed back); otherwise a random secret is generated. Persist the token +wherever your deployment keeps its configuration and export it for later runs +— the caller owns the secret, mediapreview does not store it. diff --git a/mediapreview/__main__.py b/mediapreview/__main__.py index faa12f9..17fb2f7 100644 --- a/mediapreview/__main__.py +++ b/mediapreview/__main__.py @@ -1,10 +1,103 @@ -"""CLI entry point: delegates to the preview worker subprocess. +"""Media preview CLI. -A richer CLI for one-shot preview generation and pool management can be -added later. +Usage: + mediapreview [-o OUTPUT] [-q QUALITY] [--maxsize N] [--maxzoom Z] + mediapreview oosetup [] [] + mediapreview (-h | --help) + +Generate an AVIF preview for a media file (one-shot, in-process), or set up +the bundled OnlyOffice container. + +Arguments: + media file to preview + container name [default: onlyoffice-mediapreview] + container host port [default: 8988] + +Options: + -o OUTPUT output .avif file (default: write AVIF bytes to stdout) + -q QUALITY preview quality [default: 60] + --maxsize N max preview dimension [default: 512] + --maxzoom Z max zoom factor [default: 2.0] + -h --help show this help + +oosetup prints ONLYOFFICE_JWT_SECRET= on stdout (logs go to stderr); +persist the token wherever your deployment keeps its configuration. """ -from mediapreview.worker import main +import logging +import sys +from pathlib import Path + +from docopt import docopt + +from mediapreview.backends import dispatch + + +def _oosetup(name: str, port: int) -> None: + logging.basicConfig(level=logging.INFO, format="%(message)s") + try: + # Lazy import: keeps the base CLI free of office-extra concerns. + from mediapreview.office import setup_docker # noqa: PLC0415 + except ImportError as e: + sys.stderr.write(f"error: {e}\n(hint: pip install mediapreview[office])\n") + sys.exit(1) + try: + # Logs go to stderr; stdout carries only the secret line below. + secret = setup_docker(name=name, port=port) + except Exception as e: + sys.stderr.write(f"error: OnlyOffice setup failed: {e}\n") + sys.exit(1) + # The caller owns the secret from here on — persist it wherever the + # deployment keeps its configuration. + sys.stdout.write(f"ONLYOFFICE_JWT_SECRET={secret}\n") + + +def _preview(args: dict) -> None: + path = Path(args[""]) + if not path.is_file(): + sys.stderr.write(f"error: no such file: {path}\n") + sys.exit(2) + + result, resp = dispatch( + path, + quality=int(args["-q"]), + maxsize=int(args["--maxsize"]), + maxzoom=float(args["--maxzoom"]), + ) + if not resp.ok or result is None: + sys.stderr.write(f"error: {resp.error or 'preview failed'}\n") + if resp.stderr: + sys.stderr.write(f"{resp.stderr}\n") + sys.exit(1) + + if args["-o"] is not None: + Path(args["-o"]).write_bytes(result) + where = args["-o"] + else: + sys.stdout.buffer.write(result) + sys.stdout.buffer.flush() + where = "stdout" + sys.stderr.write( + f"{path.name} -> {where} ({len(result)} bytes," + f" backend={resp.backend or 'unknown'})\n" + ) + + +def main() -> None: + # docopt matches usage patterns in order, so `oosetup` would be swallowed + # by the pattern if it came second. Dispatch it before parsing; + # the main help above still documents both modes. + if sys.argv[1:2] == ["oosetup"]: + args = docopt( + "Usage:\n mediapreview oosetup [] []", argv=sys.argv[1:] + ) + _oosetup( + args[""] or "onlyoffice-mediapreview", + int(args[""] or 8988), + ) + return + _preview(docopt(__doc__)) + if __name__ == "__main__": main() diff --git a/mediapreview/backends/__init__.py b/mediapreview/backends/__init__.py index e2b86a6..076b1b2 100644 --- a/mediapreview/backends/__init__.py +++ b/mediapreview/backends/__init__.py @@ -51,6 +51,10 @@ def dispatch(path, quality, maxsize, maxzoom, data=None): return process_image(path, quality=quality, maxsize=maxsize) except ValueError as e: return None, PreviewResponse(ok=False, backend=backend, error=str(e)) + except ImportError as e: + # Missing optional extra — expected, so a plain message, no traceback. + logger.error("Preview dispatch failed for %s: %s", path, e) # noqa: TRY400 + return None, PreviewResponse(ok=False, backend=backend, error=str(e)) except Exception as e: logger.exception("Preview dispatch failed for %s", path) return None, PreviewResponse(ok=False, backend=backend, error=str(e)) diff --git a/mediapreview/office.py b/mediapreview/office.py index 7d73c69..66d8c28 100644 --- a/mediapreview/office.py +++ b/mediapreview/office.py @@ -10,6 +10,8 @@ Environment requirements: reachable from the container (usually the docker bridge IP). """ +from __future__ import annotations + import asyncio import json import logging @@ -153,13 +155,19 @@ def log_reachable_info() -> None: logger.warning("OnlyOffice probe failed%s", suffix) -def setup_docker(name: str = "onlyoffice-mediapreview", port: int = 8988) -> int: +def setup_docker(name: str = "onlyoffice-mediapreview", port: int = 8988) -> str: """Build and run the patched OnlyOffice Docker image. Uses ONLYOFFICE_JWT_SECRET if set, otherwise generates a random secret. + Returns the secret used, so the caller is responsible for persisting it + (the CLI prints it as `ONLYOFFICE_JWT_SECRET=`). The Docker build context ships inside the package at `mediapreview/docker`. """ - secret = _get_jwt_secret() or secrets.token_hex(16) + if secret := _get_jwt_secret(): + logger.info("Using OnlyOffice JWT secret from ONLYOFFICE_JWT_SECRET") + else: + secret = secrets.token_hex(16) + logger.info("Generated a random OnlyOffice JWT secret") docker_dir = Path(__file__).parent / "docker" if not docker_dir.is_dir(): raise FileNotFoundError( @@ -190,12 +198,12 @@ def setup_docker(name: str = "onlyoffice-mediapreview", port: int = 8988) -> int "unless-stopped", name, ] - logger.info("%s", " ".join(run_cmd)) + logger.info("%s", " ".join(run_cmd).replace(secret, "")) result = subprocess.run(run_cmd, check=False, shell=False) # noqa: S603 if result.returncode != 0: raise RuntimeError("Failed to start OnlyOffice container") logger.info("OnlyOffice is running on http://localhost:%d", port) - return 0 + return secret async def is_available_async(request_timeout: float = 2.0) -> bool: diff --git a/mediapreview/pool.py b/mediapreview/pool.py index cdf2621..bbe96f1 100644 --- a/mediapreview/pool.py +++ b/mediapreview/pool.py @@ -12,7 +12,13 @@ from pathlib import Path from time import perf_counter import msgspec -from blake3 import blake3 + +try: + from blake3 import blake3 +except ImportError as e: # pragma: no cover - optional worker extra + raise ImportError( + "The worker pool requires the 'worker' extra: pip install mediapreview[worker]" + ) from e from mediapreview.formats import ( expected_backend as _expected_preview_backend, diff --git a/mediapreview/worker.py b/mediapreview/worker.py index 1facfe6..27945e1 100644 --- a/mediapreview/worker.py +++ b/mediapreview/worker.py @@ -25,8 +25,16 @@ import sys from pathlib import Path import msgspec -import tracerite -from blake3 import blake3 + +try: + import tracerite + from blake3 import blake3 +except ImportError: # pragma: no cover - optional worker extra + sys.stderr.write( + "mediapreview worker requires the 'worker' extra:" + " pip install mediapreview[worker]\n" + ) + sys.exit(1) from mediapreview.backends import dispatch from mediapreview.protocol import PreviewRequest, PreviewResponse diff --git a/pyproject.toml b/pyproject.toml index 1ad57e5..0737b80 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,10 +9,14 @@ description = "Low-level media preview converters and a worker pool framework" readme = "README.md" requires-python = ">=3.11" dependencies = [ + "docopt-ng>=0.9.0", "msgspec>=0.19.0", "pyvips[binary]>=3.1.1", ] +[project.scripts] +mediapreview = "mediapreview.__main__:main" + [project.optional-dependencies] worker = [ "blake3>=1.0.5",