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:
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user