Skip to content

Commit fecddb3

Browse files
sjquantclaude
andcommitted
✨ Add CLI, harden font cache, and fix review issues
- Add `quickthumb/cli.py` with `typer`: `render` subcommand with `-o`, `--format`, `--quality`, `--var` flags and proper exit codes (0/1/2) - Validate `--format` (PNG/JPEG/WEBP), `--quality` range (1-95), and `--var` KEY=VALUE syntax; raise on unresolved `$VAR` placeholders - Catch `FileNotFoundError`, `PermissionError`, `json.JSONDecodeError` in `render` and map to exit code 1; narrow render errors to `RenderingError`/`OSError` to avoid masking real bugs - Add `quickthumb[cli]` optional dependency and entry point in `pyproject.toml` - Support `QUICKTHUMB_FONT_CACHE_DIR` env var for font cache location; auto-create the directory if it does not exist (`makedirs exist_ok`) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent aef11b0 commit fecddb3

8 files changed

Lines changed: 580 additions & 33 deletions

File tree

pyproject.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@ rembg = [
4545
"onnxruntime>=1.24.1,<2.0.0",
4646
"rembg>=2.0.50,<3.0.0",
4747
]
48+
cli = [
49+
"typer>=0.24.1,<0.25.0",
50+
]
51+
52+
[project.scripts]
53+
quickthumb = "quickthumb.cli:main"
4854

4955
[dependency-groups]
5056
docs = [

quickthumb/canvas.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2329,6 +2329,7 @@ def _download_and_cache_font(self, url: str) -> str:
23292329
extension = os.path.splitext(url)[1] or ".ttf"
23302330
cache_filename = f"quickthumb_font_{url_hash}{extension}"
23312331
cache_dir = os.environ.get("QUICKTHUMB_FONT_CACHE_DIR", "/tmp")
2332+
os.makedirs(cache_dir, exist_ok=True)
23322333
cache_path = os.path.join(cache_dir, cache_filename)
23332334

23342335
if os.path.exists(cache_path):

quickthumb/cli.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import re
5+
from pathlib import Path
6+
from typing import Annotated
7+
8+
import typer
9+
10+
from quickthumb.canvas import Canvas
11+
from quickthumb.errors import RenderingError, ValidationError
12+
13+
_VALID_FORMATS = {"PNG", "JPEG", "WEBP"}
14+
15+
app = typer.Typer(help="QuickThumb — programmatic thumbnail generation")
16+
17+
18+
@app.callback()
19+
def _callback() -> None:
20+
"""QuickThumb CLI."""
21+
22+
23+
def main() -> None:
24+
app()
25+
26+
27+
@app.command()
28+
def render(
29+
spec: Annotated[Path, typer.Argument(help="Path to a JSON spec file")],
30+
output: Annotated[
31+
Path,
32+
typer.Option("-o", "--output", help="Output file path"),
33+
] = Path("output.png"),
34+
fmt: Annotated[
35+
str | None,
36+
typer.Option("--format", help="Output format: PNG, JPEG, or WEBP"),
37+
] = None,
38+
quality: Annotated[
39+
int | None,
40+
typer.Option("--quality", help="Quality for JPEG/WEBP (1-95)"),
41+
] = None,
42+
var: Annotated[
43+
list[str] | None,
44+
typer.Option("--var", help="Variable substitution as KEY=VALUE"),
45+
] = None,
46+
) -> None:
47+
"""Render a JSON spec file to an image."""
48+
# Validate --format early
49+
if fmt is not None and fmt.upper() not in _VALID_FORMATS:
50+
typer.echo(f"Invalid format '{fmt}'. Must be one of: PNG, JPEG, WEBP", err=True)
51+
raise typer.Exit(1)
52+
53+
# Validate --quality range
54+
if quality is not None and not (1 <= quality <= 95):
55+
typer.echo(f"Invalid quality {quality}. Must be between 1 and 95.", err=True)
56+
raise typer.Exit(1)
57+
58+
try:
59+
try:
60+
text = spec.read_text()
61+
except (FileNotFoundError, PermissionError) as e:
62+
typer.echo(str(e), err=True)
63+
raise typer.Exit(1) from e
64+
65+
if var:
66+
variables: dict[str, str] = {}
67+
for item in var:
68+
key, sep, value = item.partition("=")
69+
if not sep:
70+
typer.echo(f"Invalid --var '{item}': expected KEY=VALUE format.", err=True)
71+
raise typer.Exit(1)
72+
variables[key] = value
73+
text = _substitute_vars(text, variables)
74+
75+
try:
76+
canvas = Canvas.from_json(text)
77+
except (json.JSONDecodeError, ValidationError) as e:
78+
typer.echo(str(e), err=True)
79+
raise typer.Exit(1) from e
80+
81+
except typer.Exit:
82+
raise
83+
84+
try:
85+
canvas.render(
86+
str(output),
87+
format=fmt.upper() if fmt else None, # type: ignore[arg-type]
88+
quality=quality,
89+
)
90+
except (RenderingError, OSError) as e:
91+
typer.echo(str(e), err=True)
92+
raise typer.Exit(2) from e
93+
94+
typer.echo(str(output))
95+
96+
97+
def _substitute_vars(text: str, variables: dict[str, str]) -> str:
98+
def replace(match: re.Match) -> str:
99+
key = match.group(1) or match.group(2)
100+
return variables.get(key, match.group(0))
101+
102+
result = re.sub(r"\$\{(\w+)\}|\$(\w+)", replace, text)
103+
104+
unresolved = re.findall(r"\$\{(\w+)\}|\$(\w+)", result)
105+
if unresolved:
106+
names = [k1 or k2 for k1, k2 in unresolved]
107+
raise ValidationError(f"Unresolved placeholder(s): {', '.join(names)}")
108+
109+
return result
110+
111+
112+
if __name__ == "__main__":
113+
main()

specs/SPEC.md

Lines changed: 30 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,22 @@ This document specifies planned and exploratory features for QuickThumb. It is a
44

55
### Status Legend
66

7-
| Status | Meaning |
8-
|--------|---------|
9-
| `planned` | Committed for implementation; not yet shipped |
10-
| `in progress` | Actively being developed |
11-
| `done` | Shipped; refer to README.md for the final API |
12-
| `exploratory` | Under consideration; design not committed |
7+
| Status | Meaning |
8+
| ------------- | --------------------------------------------- |
9+
| `planned` | Committed for implementation; not yet shipped |
10+
| `in progress` | Actively being developed |
11+
| `done` | Shipped; refer to README.md for the final API |
12+
| `exploratory` | Under consideration; design not committed |
1313

1414
### Feature Status
1515

16-
| # | Feature | Status |
17-
|---|---------|--------|
18-
| 1 | CLI (`quickthumb` command) | `planned` |
19-
| 2 | Template System | `planned` |
20-
| 3 | Gradient / Image-Filled Text | `planned` |
21-
| 4 | Noise / Grain Effect | `planned` |
22-
| 5 | Presentation & Video | `exploratory` |
16+
| # | Feature | Status |
17+
| --- | ---------------------------- | ------------- |
18+
| 1 | CLI (`quickthumb` command) | `planned` |
19+
| 2 | Template System | `planned` |
20+
| 3 | Gradient / Image-Filled Text | `planned` |
21+
| 4 | Noise / Grain Effect | `planned` |
22+
| 5 | Presentation & Video | `exploratory` |
2323

2424
---
2525

@@ -29,7 +29,7 @@ A `quickthumb` command-line tool for rendering JSON specs without writing Python
2929

3030
### Installation
3131

32-
The CLI is an optional extra to avoid pulling `click` into the core dependency set.
32+
The CLI is an optional extra to avoid pulling `typer` into the core dependency set.
3333

3434
```bash
3535
uv pip install "quickthumb[cli]"
@@ -94,15 +94,15 @@ uv pip install "quickthumb[cli,watch]"
9494

9595
### Exit Codes
9696

97-
| Code | Meaning |
98-
|------|---------|
99-
| 0 | Success |
100-
| 1 | Validation error (bad spec, missing required field, unknown layer type) |
101-
| 2 | Rendering error (missing file, download failure, unsupported format) |
97+
| Code | Meaning |
98+
| ---- | ----------------------------------------------------------------------- |
99+
| 0 | Success |
100+
| 1 | Validation error (bad spec, missing required field, unknown layer type) |
101+
| 2 | Rendering error (missing file, download failure, unsupported format) |
102102

103103
### Notes
104104

105-
- `click` is only imported inside `quickthumb/cli.py`; the rest of the library does not depend on it.
105+
- `typer` is only imported inside `quickthumb/cli.py`; the rest of the library does not depend on it.
106106
- `quickthumb watch` exits with code 1 if `watchdog` is not installed.
107107
- Errors print to stderr; the rendered image path prints to stdout on success.
108108

@@ -171,12 +171,12 @@ Parameters:
171171

172172
QuickThumb ships a small set of starter templates in `quickthumb/templates/`:
173173

174-
| Name | Aspect Ratio | Description |
175-
|------|-------------|-------------|
176-
| `youtube-16x9` | 16:9 (1280×720) | Title left, subject image right |
177-
| `instagram-square` | 1:1 (1080×1080) | Centered headline with background image |
178-
| `twitter-card` | 2:1 (1200×600) | Logo + title + subtitle |
179-
| `og-image` | 1.91:1 (1200×630) | Open Graph social card |
174+
| Name | Aspect Ratio | Description |
175+
| ------------------ | ----------------- | --------------------------------------- |
176+
| `youtube-16x9` | 16:9 (1280×720) | Title left, subject image right |
177+
| `instagram-square` | 1:1 (1080×1080) | Centered headline with background image |
178+
| `twitter-card` | 2:1 (1200×600) | Logo + title + subtitle |
179+
| `og-image` | 1.91:1 (1200×630) | Open Graph social card |
180180

181181
Access by name:
182182

@@ -310,7 +310,10 @@ Fallback rule: if `fill` is `None`, `color` is used as before.
310310
"fill": {
311311
"type": "linear_gradient",
312312
"angle": 90,
313-
"stops": [["#FF6B6B", 0.0], ["#4ECDC4", 1.0]]
313+
"stops": [
314+
["#FF6B6B", 0.0],
315+
["#4ECDC4", 1.0]
316+
]
314317
},
315318
"position": ["50%", "50%"],
316319
"align": "center",

specs/TASKS.md

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,16 @@
88
### P1 — High Impact Features
99
- [DONE] Rich text word-wrapping: auto-wrap `list[TextPart]` content when `max_width` is set (currently only plain string content wraps)
1010
- [DONE] Long-word overflow: truncate or warn when a single word exceeds `max_width` in `_wrap_text`
11-
- [TODO] Configurable font cache directory via `QUICKTHUMB_FONT_CACHE_DIR` env var (currently hardcoded to `/tmp`)
11+
- [DONE] Configurable font cache directory via `QUICKTHUMB_FONT_CACHE_DIR` env var (currently hardcoded to `/tmp`)
1212

1313
### P1 — Planned Features (see SPEC.md)
1414

1515
#### CLI (`quickthumb[cli]`)
16-
- [TODO] Create `quickthumb/cli.py` with `click`; add `quickthumb[cli]` optional extra in `pyproject.toml`
17-
- [TODO] Implement `quickthumb render <spec.json>` with `-o`, `--format`, `--quality` flags
18-
- [TODO] Implement `--var KEY=VALUE` template variable substitution in the `render` subcommand
16+
- [DONE] Create `quickthumb/cli.py` with `click`; add `quickthumb[cli]` optional extra in `pyproject.toml`
17+
- [DONE] Implement `quickthumb render <spec.json>` with `-o`, `--format`, `--quality` flags
18+
- [DONE] Implement `--var KEY=VALUE` template variable substitution in the `render` subcommand
1919
- [TODO] Add `quickthumb watch <spec.json>` subcommand using `watchdog` (`quickthumb[cli,watch]`)
20-
- [TODO] Wire up exit codes: 0 success, 1 `ValidationError`, 2 `RenderingError`
20+
- [DONE] Wire up exit codes: 0 success, 1 `ValidationError`, 2 `RenderingError`
2121

2222
#### Template System
2323
- [TODO] Implement `Canvas.from_template(spec_or_path, variables={})` with `$var` / `${var}` string substitution
@@ -47,6 +47,25 @@
4747
- [TODO] Add "Why not X" section: brief comparison vs raw Pillow and html2image to help developers justify the dependency
4848
- [TODO] Add community entry point: link to GitHub issues/discussions for bug reports and questions
4949

50+
### P1 — CLI Hardening (from code review)
51+
- [DONE] CLI `render`: catch `FileNotFoundError` / `PermissionError` from `spec.read_text()` and exit with code 1
52+
- [DONE] CLI `render`: catch `json.JSONDecodeError` from `Canvas.from_json()` and exit with code 1
53+
- [DONE] `_substitute_vars`: raise `ValidationError` on unresolved `$VAR` / `${VAR}` placeholders (currently passes them through silently)
54+
- [TODO] Guard CLI entrypoint import: print a helpful message and exit if `typer` is not installed instead of crashing with `ImportError`
55+
- [DONE] CLI `render`: validate `--var` entries contain `=`; raise a clear error when `--var keyonly` is passed (currently silently maps to empty string)
56+
- [DONE] CLI `render`: replace bare `except Exception` with `except (RenderingError, OSError)` to avoid masking real bugs
57+
- [DONE] CLI `render`: validate `--quality` is in `1–95` range (`typer.Option(..., min=1, max=95)`)
58+
- [DONE] CLI `render`: validate `--format` is one of `PNG`, `JPEG`, `WEBP`; reject unknown values early
59+
60+
### P2 — Font Cache Hardening (from code review)
61+
- [TODO] Use `tempfile.gettempdir()` instead of hardcoded `"/tmp"` as the default font cache dir (fixes Windows compatibility)
62+
- [TODO] Validate downloaded font content before writing to cache (currently writes arbitrary data from any URL)
63+
- [DONE] Call `os.makedirs(cache_dir, exist_ok=True)` before writing cached font; `QUICKTHUMB_FONT_CACHE_DIR` pointing to a non-existent dir currently crashes with `FileNotFoundError`
64+
65+
### P2 — CLI Polish (from code review)
66+
- [TODO] Rename `format` parameter to `fmt` or `output_format` internally — currently shadows Python's built-in `format()`
67+
- [TODO] Widen typer version pin from `>=0.24.1,<0.25.0` to `>=0.24.1,<1.0` to avoid unnecessary resolver conflicts
68+
5069
### P3 — Lower Priority
5170
- [TODO] Fix color tuple JSON round-trip: `BackgroundLayer.color` accepts RGB tuples but they break `to_json()` / `from_json()`
5271
- [TODO] Font metadata reading: use `fonttools` to read font weight/style from file metadata instead of relying on filename parsing

tests/test_canvas.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,37 @@ def test_should_default_to_tmp_when_font_cache_dir_not_set(self, monkeypatch):
424424

425425
assert result.startswith("/tmp")
426426

427+
def test_should_create_font_cache_dir_if_not_exists(self, monkeypatch):
428+
"""_download_and_cache_font creates QUICKTHUMB_FONT_CACHE_DIR if it does not exist"""
429+
import os
430+
import tempfile
431+
from unittest.mock import MagicMock, patch
432+
433+
from quickthumb import Canvas
434+
435+
canvas = Canvas(100, 100)
436+
437+
with tempfile.TemporaryDirectory() as base:
438+
new_dir = os.path.join(base, "nested", "cache")
439+
monkeypatch.setenv("QUICKTHUMB_FONT_CACHE_DIR", new_dir)
440+
441+
fake_font_data = b"fake font data"
442+
mock_response = MagicMock()
443+
mock_response.__enter__ = lambda s: s
444+
mock_response.__exit__ = MagicMock(return_value=False)
445+
mock_response.read.return_value = fake_font_data
446+
447+
# Given: cache dir does not exist yet
448+
assert not os.path.exists(new_dir)
449+
450+
# When: downloading a font
451+
with patch("quickthumb.canvas.urlopen", return_value=mock_response):
452+
result = canvas._download_and_cache_font("https://example.com/font3.ttf")
453+
454+
# Then: the directory is created and the font is written there
455+
assert os.path.exists(new_dir)
456+
assert result.startswith(new_dir)
457+
427458
def test_should_raise_error_when_custom_callback_returns_different_size(self):
428459
"""custom callback should preserve canvas dimensions when returning an image"""
429460
import os

0 commit comments

Comments
 (0)