Skip to content

Commit 6c33ca0

Browse files
committed
✨ Add watch command to CLI for automatic re-rendering
- Implement `quickthumb watch <spec.json>` command to monitor changes in the spec file and re-render automatically. - Require `watchfiles` for file watching functionality, updating installation instructions accordingly. - Validate output format and quality parameters, ensuring proper error handling for invalid inputs. - Update documentation to reflect changes in dependencies and command usage.
1 parent fecddb3 commit 6c33ca0

4 files changed

Lines changed: 104 additions & 4 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ rembg = [
4747
]
4848
cli = [
4949
"typer>=0.24.1,<0.25.0",
50+
"watchfiles>=1.0.0,<2.0.0",
5051
]
5152

5253
[project.scripts]

quickthumb/cli.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,5 +109,92 @@ def replace(match: re.Match) -> str:
109109
return result
110110

111111

112+
@app.command()
113+
def watch(
114+
spec: Annotated[Path, typer.Argument(help="Path to a JSON spec file")],
115+
output: Annotated[
116+
Path,
117+
typer.Option("-o", "--output", help="Output file path"),
118+
] = Path("output.png"),
119+
fmt: Annotated[
120+
str | None,
121+
typer.Option("--format", help="Output format: PNG, JPEG, or WEBP"),
122+
] = None,
123+
quality: Annotated[
124+
int | None,
125+
typer.Option("--quality", help="Quality for JPEG/WEBP (1-95)"),
126+
] = None,
127+
var: Annotated[
128+
list[str] | None,
129+
typer.Option("--var", help="Variable substitution as KEY=VALUE"),
130+
] = None,
131+
) -> None:
132+
"""Watch a JSON spec file and re-render on changes."""
133+
try:
134+
from watchfiles import watch as _watch # type: ignore[import-untyped]
135+
except ImportError:
136+
typer.echo(
137+
"watchfiles is not installed. Install it with: pip install 'quickthumb[cli]'",
138+
err=True,
139+
)
140+
raise typer.Exit(1) from None
141+
142+
if fmt is not None and fmt.upper() not in _VALID_FORMATS:
143+
typer.echo(f"Invalid format '{fmt}'. Must be one of: PNG, JPEG, WEBP", err=True)
144+
raise typer.Exit(1)
145+
146+
if quality is not None and not (1 <= quality <= 95):
147+
typer.echo(f"Invalid quality {quality}. Must be between 1 and 95.", err=True)
148+
raise typer.Exit(1)
149+
150+
variables: dict[str, str] = {}
151+
if var:
152+
for item in var:
153+
key, sep, value = item.partition("=")
154+
if not sep:
155+
typer.echo(f"Invalid --var '{item}': expected KEY=VALUE format.", err=True)
156+
raise typer.Exit(1)
157+
variables[key] = value
158+
159+
def _render_once() -> None:
160+
try:
161+
text = spec.read_text()
162+
except (FileNotFoundError, PermissionError) as e:
163+
typer.echo(str(e), err=True)
164+
return
165+
166+
if variables:
167+
try:
168+
text = _substitute_vars(text, variables)
169+
except ValidationError as e:
170+
typer.echo(str(e), err=True)
171+
return
172+
173+
try:
174+
canvas = Canvas.from_json(text)
175+
except (json.JSONDecodeError, ValidationError) as e:
176+
typer.echo(str(e), err=True)
177+
return
178+
179+
try:
180+
canvas.render(
181+
str(output),
182+
format=fmt.upper() if fmt else None, # type: ignore[arg-type]
183+
quality=quality,
184+
)
185+
typer.echo(str(output))
186+
except (RenderingError, OSError) as e:
187+
typer.echo(str(e), err=True)
188+
189+
typer.echo(f"Watching {spec} … (Ctrl+C to stop)")
190+
_render_once()
191+
192+
try:
193+
for _ in _watch(spec):
194+
_render_once()
195+
except KeyboardInterrupt:
196+
pass
197+
198+
112199
if __name__ == "__main__":
113200
main()

specs/SPEC.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ Parameters:
7373

7474
#### `watch` (stretch goal)
7575

76-
Re-render automatically when the spec file changes. Requires `watchdog`.
76+
Re-render automatically when the spec file changes. Requires `watchfiles`.
7777

7878
```bash
7979
quickthumb watch spec.json -o thumbnail.png
@@ -82,7 +82,7 @@ quickthumb watch spec.json -o thumbnail.png
8282
Install with:
8383

8484
```bash
85-
uv pip install "quickthumb[cli,watch]"
85+
uv pip install "quickthumb[cli]"
8686
```
8787

8888
### Pipeline
@@ -103,7 +103,7 @@ uv pip install "quickthumb[cli,watch]"
103103
### Notes
104104

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

109109
---

specs/TASKS.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,36 @@
11
## Tasks
22

33
### P0 — Critical / Quick Wins
4+
45
- [DONE] Export `RenderingError` from `__init__.py` so users can `from quickthumb import RenderingError`
56
- [DONE] Raise `ValidationError` when `canvas.background()` is called with no `color`, `gradient`, or `image`
67
- [DONE] Remove dead `_get_style_string` method in `canvas.py` (defined but never called)
78

89
### P1 — High Impact Features
10+
911
- [DONE] Rich text word-wrapping: auto-wrap `list[TextPart]` content when `max_width` is set (currently only plain string content wraps)
1012
- [DONE] Long-word overflow: truncate or warn when a single word exceeds `max_width` in `_wrap_text`
1113
- [DONE] Configurable font cache directory via `QUICKTHUMB_FONT_CACHE_DIR` env var (currently hardcoded to `/tmp`)
1214

1315
### P1 — Planned Features (see SPEC.md)
1416

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

2225
#### Template System
26+
2327
- [TODO] Implement `Canvas.from_template(spec_or_path, variables={})` with `$var` / `${var}` string substitution
2428
- [TODO] Raise `ValidationError` on unresolved placeholders before JSON parsing
2529
- [TODO] Add `Canvas.register_template(name, path)` and `Canvas.unregister_template(name)` registry
2630
- [TODO] Create `quickthumb/templates/` directory with starter templates: `youtube-16x9`, `instagram-square`, `twitter-card`, `og-image`
2731

2832
#### Gradient / Image-Filled Text (Knockout Text)
33+
2934
- [TODO] Add `TextFillImage` model with `path` and `fit` fields and `type: Literal["image"]` discriminator
3035
- [TODO] Add `fill` parameter to `TextLayer` accepting `LinearGradient`, `RadialGradient`, or `TextFillImage`
3136
- [TODO] Add `fill` parameter to `TextPart` for per-segment fill overrides
@@ -34,6 +39,7 @@
3439
- [TODO] Add JSON round-trip support using `type` discriminators (`linear_gradient`, `radial_gradient`, `image`)
3540

3641
#### Noise / Grain Effect
42+
3743
- [TODO] Add `Grain` effect model: `intensity`, `monochrome`, `blend_mode`, `opacity` fields with `type: "grain"` discriminator
3844
- [TODO] Add `Grain` to `BackgroundEffect` and `ImageEffect` unions
3945
- [TODO] Implement grain rendering using Pillow only (no NumPy)
@@ -42,12 +48,14 @@
4248
- [TODO] Add JSON round-trip for both per-layer `Grain` effect and top-level `GrainLayer`
4349

4450
### P2 — Docs & Discoverability
51+
4552
- [TODO] Homepage headline: make the AI/JSON workflow angle front and center (currently buried)
4653
- [TODO] Add "Common LLM Mistakes" section to the JSON schema page (invalid hex, wrong position format, unsupported effects, etc.)
4754
- [TODO] Add "Why not X" section: brief comparison vs raw Pillow and html2image to help developers justify the dependency
4855
- [TODO] Add community entry point: link to GitHub issues/discussions for bug reports and questions
4956

5057
### P1 — CLI Hardening (from code review)
58+
5159
- [DONE] CLI `render`: catch `FileNotFoundError` / `PermissionError` from `spec.read_text()` and exit with code 1
5260
- [DONE] CLI `render`: catch `json.JSONDecodeError` from `Canvas.from_json()` and exit with code 1
5361
- [DONE] `_substitute_vars`: raise `ValidationError` on unresolved `$VAR` / `${VAR}` placeholders (currently passes them through silently)
@@ -58,19 +66,23 @@
5866
- [DONE] CLI `render`: validate `--format` is one of `PNG`, `JPEG`, `WEBP`; reject unknown values early
5967

6068
### P2 — Font Cache Hardening (from code review)
69+
6170
- [TODO] Use `tempfile.gettempdir()` instead of hardcoded `"/tmp"` as the default font cache dir (fixes Windows compatibility)
6271
- [TODO] Validate downloaded font content before writing to cache (currently writes arbitrary data from any URL)
6372
- [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`
6473

6574
### P2 — CLI Polish (from code review)
75+
6676
- [TODO] Rename `format` parameter to `fmt` or `output_format` internally — currently shadows Python's built-in `format()`
6777
- [TODO] Widen typer version pin from `>=0.24.1,<0.25.0` to `>=0.24.1,<1.0` to avoid unnecessary resolver conflicts
6878

6979
### P3 — Lower Priority
80+
7081
- [TODO] Fix color tuple JSON round-trip: `BackgroundLayer.color` accepts RGB tuples but they break `to_json()` / `from_json()`
7182
- [TODO] Font metadata reading: use `fonttools` to read font weight/style from file metadata instead of relying on filename parsing
7283
- [TODO] Split `canvas.py` (currently 2466 lines) into smaller modules before it becomes a maintenance burden
7384

7485
## Handoff Notes
86+
7587
-
7688
-

0 commit comments

Comments
 (0)