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=<token> 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
This commit is contained in:
2026-08-11 23:34:27 +00:00
parent 1c73320f1d
commit 49cca4b0c1
7 changed files with 157 additions and 15 deletions
+23 -4
View File
@@ -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=<token>
```
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.
+97 -4
View File
@@ -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 <path> [-o OUTPUT] [-q QUALITY] [--maxsize N] [--maxzoom Z]
mediapreview oosetup [<name>] [<port>]
mediapreview (-h | --help)
Generate an AVIF preview for a media file (one-shot, in-process), or set up
the bundled OnlyOffice container.
Arguments:
<path> media file to preview
<name> container name [default: onlyoffice-mediapreview]
<port> 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=<token> 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["<path>"])
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 <path> 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 [<name>] [<port>]", argv=sys.argv[1:]
)
_oosetup(
args["<name>"] or "onlyoffice-mediapreview",
int(args["<port>"] or 8988),
)
return
_preview(docopt(__doc__))
if __name__ == "__main__":
main()
+4
View File
@@ -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))
+12 -4
View File
@@ -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=<token>`).
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, "<redacted>"))
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:
+7 -1
View File
@@ -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,
+10 -2
View File
@@ -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
+4
View File
@@ -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",