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.
Files changed (77) hide show
  1. thumbforge/__init__.py +10 -0
  2. thumbforge/__main__.py +3 -0
  3. thumbforge/cli/__init__.py +1 -0
  4. thumbforge/cli/_errors.py +89 -0
  5. thumbforge/cli/_render.py +196 -0
  6. thumbforge/cli/_runs.py +429 -0
  7. thumbforge/cli/_youtube.py +165 -0
  8. thumbforge/cli/app.py +155 -0
  9. thumbforge/cli/batch.py +398 -0
  10. thumbforge/cli/config.py +147 -0
  11. thumbforge/cli/db.py +151 -0
  12. thumbforge/cli/fetch.py +124 -0
  13. thumbforge/cli/playlist.py +168 -0
  14. thumbforge/cli/provider.py +218 -0
  15. thumbforge/cli/runs.py +199 -0
  16. thumbforge/cli/template.py +271 -0
  17. thumbforge/cli/thumb.py +278 -0
  18. thumbforge/cli/video.py +107 -0
  19. thumbforge/core/__init__.py +5 -0
  20. thumbforge/core/enums.py +48 -0
  21. thumbforge/core/errors.py +206 -0
  22. thumbforge/core/ids.py +38 -0
  23. thumbforge/core/json.py +37 -0
  24. thumbforge/core/layout.py +188 -0
  25. thumbforge/core/models.py +112 -0
  26. thumbforge/core/providers.py +205 -0
  27. thumbforge/core/redaction.py +99 -0
  28. thumbforge/core/services/__init__.py +6 -0
  29. thumbforge/core/services/batch.py +733 -0
  30. thumbforge/core/services/fetch.py +188 -0
  31. thumbforge/core/services/hero.py +698 -0
  32. thumbforge/core/services/iterate.py +96 -0
  33. thumbforge/core/sources.py +44 -0
  34. thumbforge/core/urls.py +152 -0
  35. thumbforge/credentials.py +113 -0
  36. thumbforge/imaging/__init__.py +1 -0
  37. thumbforge/imaging/compliance.py +66 -0
  38. thumbforge/imaging/finalize.py +112 -0
  39. thumbforge/imaging/fit.py +25 -0
  40. thumbforge/imaging/fonts/Inter-Bold.ttf +0 -0
  41. thumbforge/imaging/fonts/OFL.txt +92 -0
  42. thumbforge/imaging/fonts.py +47 -0
  43. thumbforge/imaging/overlay.py +234 -0
  44. thumbforge/logging.py +170 -0
  45. thumbforge/providers/__init__.py +9 -0
  46. thumbforge/providers/antigravity.py +661 -0
  47. thumbforge/providers/fake.py +187 -0
  48. thumbforge/providers/registry.py +127 -0
  49. thumbforge/settings.py +454 -0
  50. thumbforge/sources/__init__.py +5 -0
  51. thumbforge/sources/youtube_api.py +497 -0
  52. thumbforge/sources/ytdlp.py +362 -0
  53. thumbforge/storage/__init__.py +52 -0
  54. thumbforge/storage/assets.py +171 -0
  55. thumbforge/storage/db.py +290 -0
  56. thumbforge/storage/migrations/env.py +74 -0
  57. thumbforge/storage/migrations/script.py.mako +33 -0
  58. thumbforge/storage/migrations/versions/0001_initial.py +281 -0
  59. thumbforge/storage/models.py +373 -0
  60. thumbforge/storage/repositories.py +462 -0
  61. thumbforge/storage/runs.py +600 -0
  62. thumbforge/templates/__init__.py +5 -0
  63. thumbforge/templates/builtin/bold-title.j2 +5 -0
  64. thumbforge/templates/builtin/bold-title.toml +29 -0
  65. thumbforge/templates/builtin/minimal.j2 +5 -0
  66. thumbforge/templates/builtin/minimal.toml +29 -0
  67. thumbforge/templates/builtin/series-parts.j2 +10 -0
  68. thumbforge/templates/builtin/series-parts.toml +32 -0
  69. thumbforge/templates/builtins.py +29 -0
  70. thumbforge/templates/loader.py +284 -0
  71. thumbforge/templates/render.py +117 -0
  72. thumbforge/templates/schema.py +49 -0
  73. thumbforge-0.1.0.dist-info/METADATA +196 -0
  74. thumbforge-0.1.0.dist-info/RECORD +77 -0
  75. thumbforge-0.1.0.dist-info/WHEEL +4 -0
  76. thumbforge-0.1.0.dist-info/entry_points.txt +5 -0
  77. 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,3 @@
1
+ from thumbforge.cli.app import main
2
+
3
+ main()
@@ -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)