thumbforge 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.
- thumbforge/__init__.py +10 -0
- thumbforge/__main__.py +3 -0
- thumbforge/cli/__init__.py +1 -0
- thumbforge/cli/_errors.py +89 -0
- thumbforge/cli/_render.py +196 -0
- thumbforge/cli/_runs.py +429 -0
- thumbforge/cli/_youtube.py +165 -0
- thumbforge/cli/app.py +155 -0
- thumbforge/cli/batch.py +398 -0
- thumbforge/cli/config.py +147 -0
- thumbforge/cli/db.py +151 -0
- thumbforge/cli/fetch.py +124 -0
- thumbforge/cli/playlist.py +168 -0
- thumbforge/cli/provider.py +218 -0
- thumbforge/cli/runs.py +199 -0
- thumbforge/cli/template.py +271 -0
- thumbforge/cli/thumb.py +278 -0
- thumbforge/cli/video.py +107 -0
- thumbforge/core/__init__.py +5 -0
- thumbforge/core/enums.py +48 -0
- thumbforge/core/errors.py +206 -0
- thumbforge/core/ids.py +38 -0
- thumbforge/core/json.py +37 -0
- thumbforge/core/layout.py +188 -0
- thumbforge/core/models.py +112 -0
- thumbforge/core/providers.py +205 -0
- thumbforge/core/redaction.py +99 -0
- thumbforge/core/services/__init__.py +6 -0
- thumbforge/core/services/batch.py +733 -0
- thumbforge/core/services/fetch.py +188 -0
- thumbforge/core/services/hero.py +698 -0
- thumbforge/core/services/iterate.py +96 -0
- thumbforge/core/sources.py +44 -0
- thumbforge/core/urls.py +152 -0
- thumbforge/credentials.py +113 -0
- thumbforge/imaging/__init__.py +1 -0
- thumbforge/imaging/compliance.py +66 -0
- thumbforge/imaging/finalize.py +112 -0
- thumbforge/imaging/fit.py +25 -0
- thumbforge/imaging/fonts/Inter-Bold.ttf +0 -0
- thumbforge/imaging/fonts/OFL.txt +92 -0
- thumbforge/imaging/fonts.py +47 -0
- thumbforge/imaging/overlay.py +234 -0
- thumbforge/logging.py +170 -0
- thumbforge/providers/__init__.py +9 -0
- thumbforge/providers/antigravity.py +661 -0
- thumbforge/providers/fake.py +187 -0
- thumbforge/providers/registry.py +127 -0
- thumbforge/settings.py +454 -0
- thumbforge/sources/__init__.py +5 -0
- thumbforge/sources/youtube_api.py +497 -0
- thumbforge/sources/ytdlp.py +362 -0
- thumbforge/storage/__init__.py +52 -0
- thumbforge/storage/assets.py +171 -0
- thumbforge/storage/db.py +290 -0
- thumbforge/storage/migrations/env.py +74 -0
- thumbforge/storage/migrations/script.py.mako +33 -0
- thumbforge/storage/migrations/versions/0001_initial.py +281 -0
- thumbforge/storage/models.py +373 -0
- thumbforge/storage/repositories.py +462 -0
- thumbforge/storage/runs.py +600 -0
- thumbforge/templates/__init__.py +5 -0
- thumbforge/templates/builtin/bold-title.j2 +5 -0
- thumbforge/templates/builtin/bold-title.toml +29 -0
- thumbforge/templates/builtin/minimal.j2 +5 -0
- thumbforge/templates/builtin/minimal.toml +29 -0
- thumbforge/templates/builtin/series-parts.j2 +10 -0
- thumbforge/templates/builtin/series-parts.toml +32 -0
- thumbforge/templates/builtins.py +29 -0
- thumbforge/templates/loader.py +284 -0
- thumbforge/templates/render.py +117 -0
- thumbforge/templates/schema.py +49 -0
- thumbforge-0.1.0.dist-info/METADATA +196 -0
- thumbforge-0.1.0.dist-info/RECORD +77 -0
- thumbforge-0.1.0.dist-info/WHEEL +4 -0
- thumbforge-0.1.0.dist-info/entry_points.txt +5 -0
- thumbforge-0.1.0.dist-info/licenses/LICENSE +21 -0
thumbforge/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""thumbforge: consistent, spec-compliant YouTube thumbnails from a hero image and a playlist."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("thumbforge")
|
|
7
|
+
except PackageNotFoundError: # pragma: no cover - only when running from an unbuilt checkout
|
|
8
|
+
__version__ = "0.0.0"
|
|
9
|
+
|
|
10
|
+
__all__ = ["__version__"]
|
thumbforge/__main__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Typer command surface. Commands are thin; behaviour lives in ``thumbforge.core``."""
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Turn exceptions into process exits. The only module permitted to exit the process.
|
|
2
|
+
|
|
3
|
+
Commands raise :class:`~thumbforge.core.errors.ThumbforgeError` subclasses and never call
|
|
4
|
+
``sys.exit``; the decorator below maps them onto the documented exit codes, prints a diagnostic to
|
|
5
|
+
**stderr** (stdout stays reserved for command output), and re-raises as ``typer.Exit``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import functools
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from collections.abc import Callable, Mapping
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
from rich.markup import escape
|
|
18
|
+
|
|
19
|
+
from thumbforge.cli._render import AppContext
|
|
20
|
+
from thumbforge.core.errors import ExitCode, ThumbforgeError
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _app_context(args: tuple[object, ...], kwargs: Mapping[str, object]) -> AppContext | None:
|
|
24
|
+
"""Find the :class:`AppContext` the root callback stored on the Click context.
|
|
25
|
+
|
|
26
|
+
Typer injects the context by keyword and vendors its own Click, so neither
|
|
27
|
+
``isinstance(x, click.Context)`` nor ``isinstance(x, typer.Context)`` matches the object
|
|
28
|
+
actually passed. Duck-typing on the payload is the stable check.
|
|
29
|
+
"""
|
|
30
|
+
for candidate in (*args, *kwargs.values()):
|
|
31
|
+
obj = getattr(candidate, "obj", None)
|
|
32
|
+
if isinstance(obj, AppContext):
|
|
33
|
+
return obj
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _is_json_mode(app_ctx: AppContext | None) -> bool:
|
|
38
|
+
return app_ctx is not None and app_ctx.json_mode
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _report(app_ctx: AppContext | None, error: ThumbforgeError) -> None:
|
|
42
|
+
if _is_json_mode(app_ctx):
|
|
43
|
+
payload: dict[str, str | int] = {
|
|
44
|
+
"error": error.code,
|
|
45
|
+
"message": error.message,
|
|
46
|
+
"exit_code": int(error.exit_code),
|
|
47
|
+
}
|
|
48
|
+
if error.hint:
|
|
49
|
+
payload["hint"] = error.hint
|
|
50
|
+
# Written directly rather than through Rich: `Console.print_json` pretty-prints and
|
|
51
|
+
# soft-wraps at terminal width, which can split a long message across lines and break
|
|
52
|
+
# whatever is parsing it.
|
|
53
|
+
sys.stderr.write(json.dumps(payload) + "\n")
|
|
54
|
+
sys.stderr.flush()
|
|
55
|
+
return
|
|
56
|
+
console = Console(stderr=True, highlight=False)
|
|
57
|
+
# Escaped: error text is plain, and Rich would swallow a bracketed span such as the
|
|
58
|
+
# `[api]` in `uv tool install "thumbforge[api]"` as a markup tag.
|
|
59
|
+
console.print(f"[bold red]{error.code}[/]: {escape(error.message)}")
|
|
60
|
+
if error.hint:
|
|
61
|
+
console.print(f"[dim]hint:[/] {escape(error.hint)}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def handle_errors[**P, R](func: Callable[P, R]) -> Callable[P, R]:
|
|
65
|
+
"""Map ``ThumbforgeError`` and ``KeyboardInterrupt`` onto exit codes.
|
|
66
|
+
|
|
67
|
+
Unexpected exceptions are deliberately **not** swallowed: they propagate so the traceback
|
|
68
|
+
reaches the log and the user, and Typer exits ``1``.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
@functools.wraps(func)
|
|
72
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
73
|
+
try:
|
|
74
|
+
return func(*args, **kwargs)
|
|
75
|
+
except ThumbforgeError as error:
|
|
76
|
+
# Resolved here, not before the call: the root callback populates ctx.obj as part
|
|
77
|
+
# of its body, so a failure inside it would otherwise be reported with no context
|
|
78
|
+
# and ignore --json.
|
|
79
|
+
_report(_app_context(args, kwargs), error)
|
|
80
|
+
raise typer.Exit(int(error.exit_code)) from error
|
|
81
|
+
except KeyboardInterrupt as error:
|
|
82
|
+
if _is_json_mode(_app_context(args, kwargs)):
|
|
83
|
+
sys.stderr.write(json.dumps({"error": "interrupted", "exit_code": 130}) + "\n")
|
|
84
|
+
sys.stderr.flush()
|
|
85
|
+
else:
|
|
86
|
+
Console(stderr=True, highlight=False).print("[yellow]interrupted[/]")
|
|
87
|
+
raise typer.Exit(int(ExitCode.INTERRUPTED)) from error
|
|
88
|
+
|
|
89
|
+
return wrapper
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""The only module allowed to write to stdout.
|
|
2
|
+
|
|
3
|
+
Every command produces one of two shapes: Rich output for humans, or a single JSON document for
|
|
4
|
+
machines (``--json``). ``emit`` picks between them so commands never branch on the mode
|
|
5
|
+
themselves.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
from PIL import Image
|
|
18
|
+
from rich.console import Console, Group, RenderableType
|
|
19
|
+
from rich.panel import Panel
|
|
20
|
+
from rich.table import Table
|
|
21
|
+
from rich.text import Text
|
|
22
|
+
from rich_pixels import Pixels
|
|
23
|
+
|
|
24
|
+
from thumbforge.core.errors import SettingsError
|
|
25
|
+
from thumbforge.core.json import JsonValue
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from thumbforge.settings import Settings
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class AppContext:
|
|
33
|
+
"""Per-invocation state built by the root callback and stored on ``ctx.obj``."""
|
|
34
|
+
|
|
35
|
+
console: Console
|
|
36
|
+
json_mode: bool
|
|
37
|
+
config_path: Path | None = None
|
|
38
|
+
data_dir: Path | None = None
|
|
39
|
+
settings: Settings | None = None
|
|
40
|
+
settings_error: SettingsError | None = None
|
|
41
|
+
|
|
42
|
+
def require_settings(self) -> Settings:
|
|
43
|
+
"""Return the settings, or re-raise the failure that prevented loading them.
|
|
44
|
+
|
|
45
|
+
Commands that read configuration call this; commands that repair the file
|
|
46
|
+
(``config init``, ``config set``) deliberately do not, so they keep working when
|
|
47
|
+
``config.toml`` is broken — otherwise the documented fix would be unreachable.
|
|
48
|
+
"""
|
|
49
|
+
if self.settings_error is not None:
|
|
50
|
+
raise self.settings_error
|
|
51
|
+
if self.settings is None: # pragma: no cover - the root callback always sets one
|
|
52
|
+
msg = "settings were not loaded"
|
|
53
|
+
raise SettingsError(msg)
|
|
54
|
+
return self.settings
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_app_context(ctx: typer.Context) -> AppContext:
|
|
58
|
+
"""Extract and validate the AppContext from a Typer execution context."""
|
|
59
|
+
obj = ctx.obj
|
|
60
|
+
if not isinstance(obj, AppContext): # pragma: no cover - the root callback always sets it
|
|
61
|
+
msg = "CLI context was not initialised"
|
|
62
|
+
raise SettingsError(msg)
|
|
63
|
+
return obj
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def table(
|
|
67
|
+
columns: Sequence[str],
|
|
68
|
+
rows: Sequence[Sequence[str]],
|
|
69
|
+
*,
|
|
70
|
+
title: str | None = None,
|
|
71
|
+
) -> Table:
|
|
72
|
+
"""Build a Rich table. Rendering is the caller's job via :func:`emit`."""
|
|
73
|
+
rendered = Table(title=title, header_style="bold", show_lines=False)
|
|
74
|
+
for column in columns:
|
|
75
|
+
rendered.add_column(column)
|
|
76
|
+
for row in rows:
|
|
77
|
+
rendered.add_row(*row)
|
|
78
|
+
return rendered
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def panel(title: str, body: str) -> Panel:
|
|
82
|
+
"""Build a titled Rich panel."""
|
|
83
|
+
return Panel(body, title=title, expand=False)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def kv(mapping: Mapping[str, object], *, title: str | None = None) -> Table:
|
|
87
|
+
"""Build a two-column key/value table, the default shape for ``show``-style commands."""
|
|
88
|
+
rendered = Table(title=title, box=None, show_header=False, pad_edge=False)
|
|
89
|
+
rendered.add_column(style="bold cyan", no_wrap=True)
|
|
90
|
+
rendered.add_column(overflow="fold")
|
|
91
|
+
for key, value in mapping.items():
|
|
92
|
+
rendered.add_row(key, "" if value is None else str(value))
|
|
93
|
+
return rendered
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def emit(
|
|
97
|
+
ctx: AppContext,
|
|
98
|
+
data: JsonValue,
|
|
99
|
+
*,
|
|
100
|
+
render: Callable[[], RenderableType] | None = None,
|
|
101
|
+
soft_wrap: bool = True,
|
|
102
|
+
) -> None:
|
|
103
|
+
"""Print ``data`` as JSON in ``--json`` mode, otherwise print ``render()``.
|
|
104
|
+
|
|
105
|
+
``data`` is the machine contract and must already be JSON-serialisable: no ``default=``
|
|
106
|
+
coercion, so a stray ``Path`` raises here instead of silently becoming a string in output
|
|
107
|
+
someone parses. ``allow_nan=False`` likewise rejects ``NaN``/``Infinity``, which are
|
|
108
|
+
JavaScript literals rather than valid JSON.
|
|
109
|
+
|
|
110
|
+
``render`` is a thunk so the Rich object is never built in JSON mode.
|
|
111
|
+
"""
|
|
112
|
+
if ctx.json_mode:
|
|
113
|
+
ctx.console.print_json(json.dumps(data, allow_nan=False))
|
|
114
|
+
return
|
|
115
|
+
ctx.console.print(render() if render is not None else data, soft_wrap=soft_wrap)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
_EXPORT_HINT = "Copy images out with: thumbforge thumb export <run|iteration> --to PATH"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _can_draw_pixels(console: Console) -> bool:
|
|
122
|
+
"""Whether ``console`` can show the coloured block characters a tile is made of."""
|
|
123
|
+
return (
|
|
124
|
+
console.is_terminal
|
|
125
|
+
and console.color_system is not None
|
|
126
|
+
and not console.no_color
|
|
127
|
+
and not console.is_dumb_terminal
|
|
128
|
+
and not console.legacy_windows
|
|
129
|
+
and console.encoding.startswith("utf")
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def preview(
|
|
134
|
+
ctx: AppContext,
|
|
135
|
+
paths: Sequence[Path],
|
|
136
|
+
columns: int = 2,
|
|
137
|
+
captions: Sequence[str] | None = None,
|
|
138
|
+
) -> None:
|
|
139
|
+
"""Draw ``paths`` as block-character thumbnails, ``columns`` tiles per row.
|
|
140
|
+
|
|
141
|
+
Each tile is as wide as its grid cell, keeps the image's aspect ratio and carries a
|
|
142
|
+
caption: the file name, or the matching entry of ``captions`` when given. The grid is a
|
|
143
|
+
``Table.grid``: ``rich.columns.Columns`` does not place ``Pixels`` tiles side by side.
|
|
144
|
+
|
|
145
|
+
Falls back to a numbered path table plus the export hint when the console cannot draw
|
|
146
|
+
colour (not a terminal, no colour, a non-UTF-8 encoding, dumb or legacy Windows console)
|
|
147
|
+
or when any path cannot be decoded; a preview never raises for a bad image. The fallback
|
|
148
|
+
table leads each row with the caption instead of the plain number. Prints nothing in
|
|
149
|
+
``--json`` mode.
|
|
150
|
+
"""
|
|
151
|
+
if columns < 1:
|
|
152
|
+
msg = f"columns must be at least 1, got {columns}"
|
|
153
|
+
raise ValueError(msg)
|
|
154
|
+
if captions is not None and len(captions) != len(paths):
|
|
155
|
+
msg = f"captions must match paths: got {len(captions)} captions for {len(paths)} paths"
|
|
156
|
+
raise ValueError(msg)
|
|
157
|
+
if ctx.json_mode or not paths:
|
|
158
|
+
return
|
|
159
|
+
console = ctx.console
|
|
160
|
+
tile_width = max((console.width - (columns - 1)) // columns, 8)
|
|
161
|
+
tiles: list[RenderableType] = []
|
|
162
|
+
try:
|
|
163
|
+
if _can_draw_pixels(console):
|
|
164
|
+
for index, path in enumerate(paths):
|
|
165
|
+
with Image.open(path) as image:
|
|
166
|
+
width, height = image.size
|
|
167
|
+
tile_height = max(round(tile_width * height / width), 1)
|
|
168
|
+
# rich-pixels packs two pixel rows per text line; an even height avoids a
|
|
169
|
+
# half-empty last line.
|
|
170
|
+
tile_height += tile_height % 2
|
|
171
|
+
# Shrink here, not in rich-pixels, whose nearest-neighbour resize breaks up
|
|
172
|
+
# small text; LANCZOS matches imaging.fit.
|
|
173
|
+
small = image.convert("RGB").resize( # pyright: ignore[reportUnknownMemberType]
|
|
174
|
+
(tile_width, tile_height), Image.Resampling.LANCZOS
|
|
175
|
+
)
|
|
176
|
+
pixels = Pixels.from_image(small)
|
|
177
|
+
label = path.name if captions is None else captions[index]
|
|
178
|
+
caption = Text(label, style="dim", no_wrap=True, overflow="ellipsis")
|
|
179
|
+
tiles.append(Group(pixels, caption))
|
|
180
|
+
except OSError, Image.DecompressionBombError:
|
|
181
|
+
tiles = []
|
|
182
|
+
if not tiles:
|
|
183
|
+
rows = [
|
|
184
|
+
(str(index + 1) if captions is None else captions[index], str(path))
|
|
185
|
+
for index, path in enumerate(paths)
|
|
186
|
+
]
|
|
187
|
+
console.print(table(["#", "path"], rows))
|
|
188
|
+
console.print(Text(_EXPORT_HINT, style="dim"))
|
|
189
|
+
return
|
|
190
|
+
grid = Table.grid(padding=(0, 1))
|
|
191
|
+
for _ in range(columns):
|
|
192
|
+
grid.add_column(width=tile_width, no_wrap=True)
|
|
193
|
+
for start in range(0, len(tiles), columns):
|
|
194
|
+
row = tiles[start : start + columns]
|
|
195
|
+
grid.add_row(*row, *[""] * (columns - len(row)))
|
|
196
|
+
console.print(grid)
|