webpthumb 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
webpthumb/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """WebP thumbnails for images and PDFs."""
2
+
3
+ from .core import ThumbError, ThumbResult, make_thumbnail, write_thumbnail
4
+ from .options import ThumbOptions
5
+
6
+ __version__ = "0.1.0"
7
+
8
+ __all__ = [
9
+ "ThumbError",
10
+ "ThumbOptions",
11
+ "ThumbResult",
12
+ "make_thumbnail",
13
+ "write_thumbnail",
14
+ "__version__",
15
+ ]
webpthumb/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
webpthumb/api.py ADDED
@@ -0,0 +1,125 @@
1
+ """HTTP endpoint that returns a WebP thumbnail for an uploaded image or PDF."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from pathlib import Path
8
+
9
+ from fastapi import FastAPI, HTTPException, UploadFile
10
+ from fastapi.responses import Response
11
+
12
+ from . import __version__
13
+ from .core import ThumbError, make_thumbnail
14
+ from .options import ThumbOptions
15
+
16
+ _SAFE = re.compile(r"[^A-Za-z0-9._-]+")
17
+
18
+
19
+ def _env_int(name: str, default: int) -> int:
20
+ raw = os.environ.get(name)
21
+ if not raw:
22
+ return default
23
+ try:
24
+ return int(raw)
25
+ except ValueError:
26
+ return default
27
+
28
+
29
+ def _env_optional_int(name: str) -> int | None:
30
+ raw = os.environ.get(name)
31
+ if not raw:
32
+ return None
33
+ try:
34
+ return int(raw)
35
+ except ValueError:
36
+ return None
37
+
38
+
39
+ def _env_optional_float(name: str) -> float | None:
40
+ raw = os.environ.get(name)
41
+ if not raw:
42
+ return None
43
+ try:
44
+ return float(raw)
45
+ except ValueError:
46
+ return None
47
+
48
+
49
+ def upload_limit_bytes() -> int:
50
+ megabytes = _env_int("WEBTHUMB_MAX_UPLOAD_MB", 32)
51
+ if megabytes < 1:
52
+ megabytes = 32
53
+ return megabytes * 1024 * 1024
54
+
55
+
56
+ def safe_stem(filename: str | None) -> str:
57
+ stem = Path(filename or "thumbnail").stem
58
+ cleaned = _SAFE.sub("_", stem).strip("._")
59
+ return (cleaned or "thumbnail")[:120]
60
+
61
+
62
+ def create_app(max_upload_bytes: int | None = None) -> FastAPI:
63
+ """Build the API. Query defaults fall back to ``WEBTHUMB_*`` environment variables."""
64
+ limit = upload_limit_bytes() if max_upload_bytes is None else max_upload_bytes
65
+ app = FastAPI(title="webpthumb", version=__version__)
66
+ app.state.max_upload_bytes = limit
67
+
68
+ @app.get("/health")
69
+ def health() -> dict[str, str]:
70
+ return {"status": "ok"}
71
+
72
+ @app.post("/v1/thumbnail")
73
+ async def thumbnail(
74
+ file: UploadFile,
75
+ width: int | None = None,
76
+ height: int | None = None,
77
+ quality: int | None = None,
78
+ page: int = 1,
79
+ crop_top: float | None = None,
80
+ ) -> Response:
81
+ data = await _read_limited(file, app.state.max_upload_bytes)
82
+ if not data:
83
+ raise HTTPException(status_code=400, detail="empty upload")
84
+ resolved_width = width if width is not None else _env_int("WEBTHUMB_WIDTH", 256)
85
+ resolved_height = height if height is not None else _env_optional_int("WEBTHUMB_HEIGHT")
86
+ resolved_quality = quality if quality is not None else _env_int("WEBTHUMB_QUALITY", 80)
87
+ resolved_crop = crop_top if crop_top is not None else _env_optional_float("WEBTHUMB_CROP_TOP")
88
+ filename = file.filename or "thumbnail"
89
+ try:
90
+ options = ThumbOptions(
91
+ width=resolved_width,
92
+ height=resolved_height,
93
+ quality=resolved_quality,
94
+ crop_top=resolved_crop,
95
+ )
96
+ webp = make_thumbnail(data, filename, options, page=page)
97
+ except ThumbError as exc:
98
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
99
+ except ValueError as exc:
100
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
101
+ stem = safe_stem(filename)
102
+ return Response(
103
+ content=webp,
104
+ media_type="image/webp",
105
+ headers={"Content-Disposition": f'inline; filename="{stem}.webp"'},
106
+ )
107
+
108
+ return app
109
+
110
+
111
+ async def _read_limited(file: UploadFile, limit: int) -> bytes:
112
+ chunks: list[bytes] = []
113
+ total = 0
114
+ while True:
115
+ block = await file.read(1024 * 1024)
116
+ if not block:
117
+ break
118
+ total += len(block)
119
+ if total > limit:
120
+ raise HTTPException(status_code=413, detail="upload exceeds limit")
121
+ chunks.append(block)
122
+ return b"".join(chunks)
123
+
124
+
125
+ app = create_app()
webpthumb/cli.py ADDED
@@ -0,0 +1,321 @@
1
+ """Command line: convert files, serve HTTP, or watch a directory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import logging
7
+ import os
8
+ import sys
9
+ import threading
10
+ from concurrent.futures import ThreadPoolExecutor
11
+ from pathlib import Path
12
+
13
+ from pdfthumb import generate_thumbnails
14
+
15
+ from .api import create_app
16
+ from .core import ThumbResult, is_image_name, render_file
17
+ from .daemon import start_watcher, stop_watcher, watcher_status
18
+ from .options import ThumbOptions
19
+ from .watch import run_watch
20
+
21
+ log = logging.getLogger("webpthumb")
22
+
23
+
24
+ def _env_flag(name: str) -> bool:
25
+ return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"}
26
+
27
+
28
+ def _build_options(args: argparse.Namespace) -> ThumbOptions:
29
+ if args.width is not None:
30
+ width = args.width
31
+ else:
32
+ width = int(os.environ.get("WEBTHUMB_WIDTH", "256"))
33
+ if args.height is not None:
34
+ height = args.height
35
+ else:
36
+ raw_height = os.environ.get("WEBTHUMB_HEIGHT")
37
+ height = int(raw_height) if raw_height else None
38
+ if args.quality is not None:
39
+ quality = args.quality
40
+ else:
41
+ quality = int(os.environ.get("WEBTHUMB_QUALITY", "80"))
42
+ pages = args.pages if args.pages is not None else os.environ.get("WEBTHUMB_PAGES", "1")
43
+ if args.crop_top is not None:
44
+ crop_top = args.crop_top
45
+ else:
46
+ raw_crop = os.environ.get("WEBTHUMB_CROP_TOP")
47
+ crop_top = float(raw_crop) if raw_crop else None
48
+ return ThumbOptions(
49
+ width=width,
50
+ height=height,
51
+ quality=quality,
52
+ pages=pages,
53
+ crop_top=crop_top,
54
+ overwrite=bool(args.overwrite) or _env_flag("WEBTHUMB_OVERWRITE"),
55
+ )
56
+
57
+
58
+ def _add_common(parser: argparse.ArgumentParser) -> None:
59
+ parser.add_argument("-w", "--width", type=int, default=None, help="thumbnail width (default 256)")
60
+ parser.add_argument("--height", type=int, default=None, help="maximum height; fit inside width x height")
61
+ parser.add_argument("-q", "--quality", type=int, default=None, help="WebP quality 0-100 (default 80)")
62
+ parser.add_argument("-p", "--pages", default=None, help="PDF pages, e.g. 1, 1,3-5, all (default 1)")
63
+ parser.add_argument(
64
+ "-c",
65
+ "--crop-top",
66
+ nargs="?",
67
+ const=1.0,
68
+ type=float,
69
+ default=None,
70
+ help="PDF only: keep the top band, height = width x RATIO (default 1 when the flag has no value)",
71
+ )
72
+ parser.add_argument("--overwrite", action="store_true", help="replace an existing thumbnail")
73
+ parser.add_argument("-r", "--recursive", action="store_true", help="walk directories recursively")
74
+
75
+
76
+ def build_parser() -> argparse.ArgumentParser:
77
+ parser = argparse.ArgumentParser(
78
+ prog="webpthumb",
79
+ description="Write a WebP thumbnail for an image or a PDF.",
80
+ )
81
+ commands = parser.add_subparsers(dest="cmd", required=True)
82
+
83
+ convert = commands.add_parser("convert", help="thumbnail files or directories")
84
+ convert.add_argument("inputs", nargs="+", help="image files, PDF files, or directories")
85
+ convert.add_argument("-o", "--output", default=None, help="output directory (default ./thumbnails)")
86
+ convert.add_argument("-j", "--jobs", type=int, default=None, help="parallel workers (default: CPU count)")
87
+ _add_common(convert)
88
+
89
+ serve = commands.add_parser("serve", help="serve POST /v1/thumbnail")
90
+ serve.add_argument("--host", default=None, help="bind address (default 127.0.0.1, or WEBTHUMB_HOST)")
91
+ serve.add_argument("--port", type=int, default=None, help="bind port (default 8080, or WEBTHUMB_PORT)")
92
+
93
+ watch = commands.add_parser("watch", help="keep thumbnailing files added to a directory")
94
+ _add_watch_args(watch, background_flag=True)
95
+
96
+ start = commands.add_parser("start", help="start the directory watcher in the background")
97
+ _add_watch_args(start, background_flag=False)
98
+
99
+ commands.add_parser("stop", help="stop the background watcher")
100
+ commands.add_parser("status", help="show whether the background watcher is running")
101
+ return parser
102
+
103
+
104
+ def _add_watch_args(parser: argparse.ArgumentParser, *, background_flag: bool) -> None:
105
+ parser.add_argument("input", help="directory to watch")
106
+ parser.add_argument("-o", "--output", default=None, help="output directory (or WEBTHUMB_OUTPUT)")
107
+ _add_common(parser)
108
+ if background_flag:
109
+ parser.add_argument(
110
+ "-b",
111
+ "--background",
112
+ action="store_true",
113
+ help="run in the background and return",
114
+ )
115
+
116
+
117
+ def _iter_files(root: Path, recursive: bool) -> list[Path]:
118
+ if recursive:
119
+ files = [path for path in root.rglob("*") if path.is_file() and not path.name.startswith(".")]
120
+ else:
121
+ files = [path for path in root.iterdir() if path.is_file() and not path.name.startswith(".")]
122
+ return sorted(files)
123
+
124
+
125
+ def _collect(inputs: list[str], recursive: bool) -> tuple[list[Path], list[tuple[Path, Path]], list[str]]:
126
+ """Split inputs into PDF paths (for pdfthumb), image jobs, and error strings."""
127
+ pdf_inputs: list[Path] = []
128
+ images: list[tuple[Path, Path]] = []
129
+ errors: list[str] = []
130
+ seen: set[Path] = set()
131
+ for raw in inputs:
132
+ path = Path(raw).expanduser()
133
+ if path.is_dir():
134
+ pdf_inputs.append(path)
135
+ for found in _iter_files(path, recursive):
136
+ if found.suffix.lower() == ".pdf" or not is_image_name(found.name):
137
+ continue
138
+ resolved = found.resolve()
139
+ if resolved in seen:
140
+ continue
141
+ seen.add(resolved)
142
+ images.append((found, found.parent.relative_to(path)))
143
+ elif path.is_file():
144
+ if path.suffix.lower() == ".pdf":
145
+ pdf_inputs.append(path)
146
+ elif is_image_name(path.name):
147
+ resolved = path.resolve()
148
+ if resolved not in seen:
149
+ seen.add(resolved)
150
+ images.append((path, Path(".")))
151
+ else:
152
+ errors.append(f"unsupported format: {path}")
153
+ else:
154
+ errors.append(f"not found: {path}")
155
+ return pdf_inputs, images, errors
156
+
157
+
158
+ def _print_result(result: ThumbResult) -> None:
159
+ if result.status == "error":
160
+ print(f"failed {result.source}: {result.error}", file=sys.stderr)
161
+ elif result.status == "skipped":
162
+ print(f"skipped {result.source} -> {result.output}")
163
+ else:
164
+ print(f"ok {result.source} -> {result.output}")
165
+
166
+
167
+ def cmd_convert(args: argparse.Namespace) -> int:
168
+ try:
169
+ options = _build_options(args)
170
+ options.validate()
171
+ except ValueError as exc:
172
+ print(f"error: {exc}", file=sys.stderr)
173
+ return 2
174
+ if args.jobs is not None:
175
+ jobs = args.jobs
176
+ else:
177
+ raw_jobs = os.environ.get("WEBTHUMB_JOBS")
178
+ jobs = int(raw_jobs) if raw_jobs else (os.cpu_count() or 1)
179
+ if jobs < 1:
180
+ print("error: jobs must be positive", file=sys.stderr)
181
+ return 2
182
+ output = Path(args.output or os.environ.get("WEBTHUMB_OUTPUT", "thumbnails"))
183
+ recursive = bool(args.recursive) or _env_flag("WEBTHUMB_RECURSIVE")
184
+ pdf_inputs, images, errors = _collect(args.inputs, recursive)
185
+ failed = len(errors)
186
+ for message in errors:
187
+ print(f"failed {message}", file=sys.stderr)
188
+
189
+ if pdf_inputs:
190
+ try:
191
+ pdf_results = generate_thumbnails(
192
+ pdf_inputs,
193
+ output,
194
+ width=options.width,
195
+ height=options.height,
196
+ quality=options.quality,
197
+ pages=options.pages,
198
+ crop_top=options.crop_top,
199
+ recursive=recursive,
200
+ overwrite=options.overwrite,
201
+ jobs=jobs,
202
+ )
203
+ except ValueError as exc:
204
+ print(f"error: {exc}", file=sys.stderr)
205
+ return 2
206
+ for result in pdf_results:
207
+ if result.status == "error":
208
+ print(f"failed {result.input}: {result.error}", file=sys.stderr)
209
+ failed += 1
210
+ elif result.status == "skipped":
211
+ print(f"skipped {result.input} -> {result.output}")
212
+ else:
213
+ print(f"ok {result.input} -> {result.output}")
214
+
215
+ def _render(item: tuple[Path, Path]) -> list[ThumbResult]:
216
+ src, relative = item
217
+ return render_file(src, output, options, relative_dir=relative)
218
+
219
+ if jobs == 1 or len(images) < 2:
220
+ rendered = [_render(item) for item in images]
221
+ else:
222
+ with ThreadPoolExecutor(max_workers=min(jobs, len(images))) as pool:
223
+ rendered = list(pool.map(_render, images))
224
+ for results in rendered:
225
+ for result in results:
226
+ _print_result(result)
227
+ if result.status == "error":
228
+ failed += 1
229
+ return 1 if failed else 0
230
+
231
+
232
+ def _start_embedded_watch() -> None:
233
+ watch = os.environ.get("WEBTHUMB_WATCH")
234
+ output = os.environ.get("WEBTHUMB_OUTPUT")
235
+ if not watch and not output:
236
+ return
237
+ if not watch or not output:
238
+ log.error("set both WEBTHUMB_WATCH and WEBTHUMB_OUTPUT to watch while serving")
239
+ return
240
+ try:
241
+ options = ThumbOptions(
242
+ width=int(os.environ.get("WEBTHUMB_WIDTH", "256")),
243
+ height=int(os.environ["WEBTHUMB_HEIGHT"]) if os.environ.get("WEBTHUMB_HEIGHT") else None,
244
+ quality=int(os.environ.get("WEBTHUMB_QUALITY", "80")),
245
+ pages=os.environ.get("WEBTHUMB_PAGES", "1"),
246
+ crop_top=float(os.environ["WEBTHUMB_CROP_TOP"]) if os.environ.get("WEBTHUMB_CROP_TOP") else None,
247
+ overwrite=_env_flag("WEBTHUMB_OVERWRITE"),
248
+ )
249
+ options.validate()
250
+ except ValueError as exc:
251
+ log.error("watcher options: %s", exc)
252
+ return
253
+ thread = threading.Thread(
254
+ target=run_watch,
255
+ args=(watch, output, options),
256
+ kwargs={
257
+ "recursive": _env_flag("WEBTHUMB_RECURSIVE"),
258
+ "install_signals": False,
259
+ },
260
+ name="webpthumb-watch",
261
+ daemon=True,
262
+ )
263
+ thread.start()
264
+
265
+
266
+ def cmd_serve(args: argparse.Namespace) -> int:
267
+ import uvicorn
268
+
269
+ host = args.host or os.environ.get("WEBTHUMB_HOST", "127.0.0.1")
270
+ port = args.port if args.port is not None else int(os.environ.get("WEBTHUMB_PORT", "8080"))
271
+ if not 1 <= port <= 65535:
272
+ print("error: port must be from 1 to 65535", file=sys.stderr)
273
+ return 2
274
+ _start_embedded_watch()
275
+ uvicorn.run(create_app(), host=host, port=port)
276
+ return 0
277
+
278
+
279
+ def _run_watch_command(args: argparse.Namespace, *, background: bool) -> int:
280
+ try:
281
+ options = _build_options(args)
282
+ options.validate()
283
+ except ValueError as exc:
284
+ print(f"error: {exc}", file=sys.stderr)
285
+ return 2
286
+ output = args.output or os.environ.get("WEBTHUMB_OUTPUT")
287
+ if not output:
288
+ print("error: watch needs -o or WEBTHUMB_OUTPUT", file=sys.stderr)
289
+ return 2
290
+ recursive = bool(args.recursive) or _env_flag("WEBTHUMB_RECURSIVE")
291
+ if background:
292
+ return start_watcher(args.input, output, options, recursive=recursive)
293
+ return run_watch(args.input, output, options, recursive=recursive)
294
+
295
+
296
+ def cmd_watch(args: argparse.Namespace) -> int:
297
+ return _run_watch_command(args, background=bool(args.background))
298
+
299
+
300
+ def cmd_start(args: argparse.Namespace) -> int:
301
+ return _run_watch_command(args, background=True)
302
+
303
+
304
+ def main(argv: list[str] | None = None) -> int:
305
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
306
+ parser = build_parser()
307
+ args = parser.parse_args(argv)
308
+ if args.cmd == "convert":
309
+ return cmd_convert(args)
310
+ if args.cmd == "serve":
311
+ return cmd_serve(args)
312
+ if args.cmd == "watch":
313
+ return cmd_watch(args)
314
+ if args.cmd == "start":
315
+ return cmd_start(args)
316
+ if args.cmd == "stop":
317
+ return stop_watcher()
318
+ if args.cmd == "status":
319
+ return watcher_status()
320
+ parser.error(f"unknown command {args.cmd}")
321
+ return 2