thumbrella-client 0.5.0__tar.gz
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.
- thumbrella_client-0.5.0/MANIFEST.in +3 -0
- thumbrella_client-0.5.0/PKG-INFO +143 -0
- thumbrella_client-0.5.0/README.md +116 -0
- thumbrella_client-0.5.0/examples/basic.py +57 -0
- thumbrella_client-0.5.0/examples/collage.py +112 -0
- thumbrella_client-0.5.0/examples/gallery.py +113 -0
- thumbrella_client-0.5.0/examples/stream.py +71 -0
- thumbrella_client-0.5.0/pyproject.toml +45 -0
- thumbrella_client-0.5.0/setup.cfg +4 -0
- thumbrella_client-0.5.0/src/thumbrella/__init__.py +63 -0
- thumbrella_client-0.5.0/src/thumbrella/cache.py +184 -0
- thumbrella_client-0.5.0/src/thumbrella/client.py +442 -0
- thumbrella_client-0.5.0/src/thumbrella/constants.py +73 -0
- thumbrella_client-0.5.0/src/thumbrella/errors.py +25 -0
- thumbrella_client-0.5.0/src/thumbrella/failed.jpeg +0 -0
- thumbrella_client-0.5.0/src/thumbrella/http.py +162 -0
- thumbrella_client-0.5.0/src/thumbrella/py.typed +0 -0
- thumbrella_client-0.5.0/src/thumbrella/result.py +384 -0
- thumbrella_client-0.5.0/src/thumbrella_client.egg-info/PKG-INFO +143 -0
- thumbrella_client-0.5.0/src/thumbrella_client.egg-info/SOURCES.txt +21 -0
- thumbrella_client-0.5.0/src/thumbrella_client.egg-info/dependency_links.txt +1 -0
- thumbrella_client-0.5.0/src/thumbrella_client.egg-info/requires.txt +4 -0
- thumbrella_client-0.5.0/src/thumbrella_client.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: thumbrella-client
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: Python client for the Thumbrella thumbnail API — typed results, async streaming, pluggable caching
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Project-URL: Homepage, https://thumbrella.dev
|
|
7
|
+
Project-URL: Repository, https://github.com/thumbrella-dev/clients
|
|
8
|
+
Project-URL: Issues, https://github.com/thumbrella-dev/clients/issues
|
|
9
|
+
Project-URL: Documentation, https://thumbrella.dev/docs/client
|
|
10
|
+
Keywords: thumbrella,thumbnail,thumbnails,image,media,streaming
|
|
11
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
20
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
Requires-Dist: requests>=2.32.0
|
|
25
|
+
Provides-Extra: async
|
|
26
|
+
Requires-Dist: aiohttp>=3.9.0; extra == "async"
|
|
27
|
+
|
|
28
|
+
# thumbrella-client
|
|
29
|
+
|
|
30
|
+
Python client for [Thumbrella](https://thumbrella.dev) — a fast thumbnail API
|
|
31
|
+
for images, video, documents, and more.
|
|
32
|
+
|
|
33
|
+
[](https://pypi.org/project/thumbrella-client/)
|
|
34
|
+
[](https://pypi.org/project/thumbrella-client/)
|
|
35
|
+
[](https://github.com/thumbrella-dev/clients/blob/main/LICENSE)
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install thumbrella-client
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Or with uv:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
uv add thumbrella-client
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Async streaming needs `aiohttp`:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install "thumbrella-client[async]"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Quickstart
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
import thumbrella
|
|
59
|
+
|
|
60
|
+
tbr = thumbrella.Client().verify()
|
|
61
|
+
result = tbr.thumb("https://example.com/photo.jpg")
|
|
62
|
+
|
|
63
|
+
print(result.status, len(result.media.thumbnail), "bytes")
|
|
64
|
+
|
|
65
|
+
# Use with Pillow
|
|
66
|
+
from PIL import Image
|
|
67
|
+
img = Image.open(result.media.thumbnail.io)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
## How It Works
|
|
73
|
+
|
|
74
|
+
Create a `Client`, call `verify()` to check connectivity, then use `thumb()`,
|
|
75
|
+
`batch()`, or `stream()` to generate thumbnails.
|
|
76
|
+
|
|
77
|
+
The client reads `$TBR_CONNECT` for server config. Override it with a connect
|
|
78
|
+
string:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
tbr = thumbrella.Client("http://localhost:3114") # local dev
|
|
82
|
+
tbr = thumbrella.Client("https://cloud.thumbrella.dev,tbr_s_...") # cloud token
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Every URL gets a `Result` — failures get a placeholder image too. Use
|
|
86
|
+
`result.verify()` to raise on failure, or check `result.is_success()` for inline
|
|
87
|
+
handling. See the [client docs](https://thumbrella.dev/docs/client/) for the
|
|
88
|
+
full `Result` and `Media` field reference.
|
|
89
|
+
|
|
90
|
+
### Errors
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from thumbrella import ThumbError, ConnectionError, TimeoutError, VerifyError
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
All errors extend `ThumbError`. `Client.verify()` raises `VerifyError` on bad
|
|
97
|
+
config. Network issues raise `ConnectionError` or `TimeoutError`. Per-result
|
|
98
|
+
failures return a `Result` with a failure status — call `result.verify()` to
|
|
99
|
+
convert to an exception.
|
|
100
|
+
|
|
101
|
+
### Caching
|
|
102
|
+
|
|
103
|
+
Each `Client` defaults to an in-memory LRU cache (256 entries). Disable with
|
|
104
|
+
`caches=[]` or layer custom backends:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
tbr = thumbrella.Client(caches=[]) # no caching
|
|
108
|
+
tbr = thumbrella.Client(caches=[thumbrella.MemoryCache(max_items=1000)])
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Implement `thumbrella.Cache` to add persistent storage (SQLite, S3, etc.).
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
## Examples
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
# Download one thumbnail to disk (with PIL inspection)
|
|
118
|
+
python examples/basic.py https://demo.thumbrella.dev/media/raw-canon.cr2 cam.jpeg
|
|
119
|
+
|
|
120
|
+
# Stream batch progress
|
|
121
|
+
python examples/stream.py https://example.com/a.jpg https://example.com/b.png
|
|
122
|
+
|
|
123
|
+
# Build a collage grid from streamed thumbnails
|
|
124
|
+
python examples/collage.py urls.txt
|
|
125
|
+
|
|
126
|
+
# Batch download with persistent caching
|
|
127
|
+
python examples/gallery.py https://example.com/a.jpg https://example.com/b.png
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
See [`examples/`](./examples) for full source.
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
## Next Steps
|
|
134
|
+
|
|
135
|
+
- **[Client docs](https://thumbrella.dev/docs/client/)** — full API reference and examples
|
|
136
|
+
- **[Thumbrella](https://thumbrella.dev)** — main site
|
|
137
|
+
- **[GitHub](https://github.com/thumbrella-dev/clients)** — source and issues
|
|
138
|
+
|
|
139
|
+
## License
|
|
140
|
+
|
|
141
|
+
Apache-2.0. See [LICENSE](https://github.com/thumbrella-dev/clients/blob/main/LICENSE).
|
|
142
|
+
|
|
143
|
+
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# thumbrella-client
|
|
2
|
+
|
|
3
|
+
Python client for [Thumbrella](https://thumbrella.dev) — a fast thumbnail API
|
|
4
|
+
for images, video, documents, and more.
|
|
5
|
+
|
|
6
|
+
[](https://pypi.org/project/thumbrella-client/)
|
|
7
|
+
[](https://pypi.org/project/thumbrella-client/)
|
|
8
|
+
[](https://github.com/thumbrella-dev/clients/blob/main/LICENSE)
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install thumbrella-client
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Or with uv:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
uv add thumbrella-client
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Async streaming needs `aiohttp`:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install "thumbrella-client[async]"
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Quickstart
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
import thumbrella
|
|
32
|
+
|
|
33
|
+
tbr = thumbrella.Client().verify()
|
|
34
|
+
result = tbr.thumb("https://example.com/photo.jpg")
|
|
35
|
+
|
|
36
|
+
print(result.status, len(result.media.thumbnail), "bytes")
|
|
37
|
+
|
|
38
|
+
# Use with Pillow
|
|
39
|
+
from PIL import Image
|
|
40
|
+
img = Image.open(result.media.thumbnail.io)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
## How It Works
|
|
46
|
+
|
|
47
|
+
Create a `Client`, call `verify()` to check connectivity, then use `thumb()`,
|
|
48
|
+
`batch()`, or `stream()` to generate thumbnails.
|
|
49
|
+
|
|
50
|
+
The client reads `$TBR_CONNECT` for server config. Override it with a connect
|
|
51
|
+
string:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
tbr = thumbrella.Client("http://localhost:3114") # local dev
|
|
55
|
+
tbr = thumbrella.Client("https://cloud.thumbrella.dev,tbr_s_...") # cloud token
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Every URL gets a `Result` — failures get a placeholder image too. Use
|
|
59
|
+
`result.verify()` to raise on failure, or check `result.is_success()` for inline
|
|
60
|
+
handling. See the [client docs](https://thumbrella.dev/docs/client/) for the
|
|
61
|
+
full `Result` and `Media` field reference.
|
|
62
|
+
|
|
63
|
+
### Errors
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from thumbrella import ThumbError, ConnectionError, TimeoutError, VerifyError
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
All errors extend `ThumbError`. `Client.verify()` raises `VerifyError` on bad
|
|
70
|
+
config. Network issues raise `ConnectionError` or `TimeoutError`. Per-result
|
|
71
|
+
failures return a `Result` with a failure status — call `result.verify()` to
|
|
72
|
+
convert to an exception.
|
|
73
|
+
|
|
74
|
+
### Caching
|
|
75
|
+
|
|
76
|
+
Each `Client` defaults to an in-memory LRU cache (256 entries). Disable with
|
|
77
|
+
`caches=[]` or layer custom backends:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
tbr = thumbrella.Client(caches=[]) # no caching
|
|
81
|
+
tbr = thumbrella.Client(caches=[thumbrella.MemoryCache(max_items=1000)])
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Implement `thumbrella.Cache` to add persistent storage (SQLite, S3, etc.).
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
## Examples
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
# Download one thumbnail to disk (with PIL inspection)
|
|
91
|
+
python examples/basic.py https://demo.thumbrella.dev/media/raw-canon.cr2 cam.jpeg
|
|
92
|
+
|
|
93
|
+
# Stream batch progress
|
|
94
|
+
python examples/stream.py https://example.com/a.jpg https://example.com/b.png
|
|
95
|
+
|
|
96
|
+
# Build a collage grid from streamed thumbnails
|
|
97
|
+
python examples/collage.py urls.txt
|
|
98
|
+
|
|
99
|
+
# Batch download with persistent caching
|
|
100
|
+
python examples/gallery.py https://example.com/a.jpg https://example.com/b.png
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
See [`examples/`](./examples) for full source.
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
## Next Steps
|
|
107
|
+
|
|
108
|
+
- **[Client docs](https://thumbrella.dev/docs/client/)** — full API reference and examples
|
|
109
|
+
- **[Thumbrella](https://thumbrella.dev)** — main site
|
|
110
|
+
- **[GitHub](https://github.com/thumbrella-dev/clients)** — source and issues
|
|
111
|
+
|
|
112
|
+
## License
|
|
113
|
+
|
|
114
|
+
Apache-2.0. See [LICENSE](https://github.com/thumbrella-dev/clients/blob/main/LICENSE).
|
|
115
|
+
|
|
116
|
+
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""basic.py - download one thumbnail to a file.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
python basic.py https://www.python.org/static/img/python-logo.png thumb.jpeg
|
|
5
|
+
|
|
6
|
+
Gets a generated thumbnail for any URL. If there are problems connecting
|
|
7
|
+
to Thumbrella or accessing the media URL this will fail with an explanation.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import thumbrella
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def thumbnail(url: str, path: Path):
|
|
17
|
+
"""Generate thumbnail for url and save to disk."""
|
|
18
|
+
tbr = thumbrella.Client().verify()
|
|
19
|
+
result = tbr.thumb(url)
|
|
20
|
+
|
|
21
|
+
m = result.media
|
|
22
|
+
if m is None:
|
|
23
|
+
print("Thumbnail did not succeed:", result.status)
|
|
24
|
+
|
|
25
|
+
# Simple result metadata
|
|
26
|
+
print(
|
|
27
|
+
f"{m.kind if m else '?'} {m.file_size if m else '?':,} bytes -> "
|
|
28
|
+
f"{len(m.thumbnail) if m else 0:,} bytes {path}"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Load thumbnail into PIL and process
|
|
32
|
+
try:
|
|
33
|
+
from PIL import Image
|
|
34
|
+
img = Image.open(m.thumbnail.io)
|
|
35
|
+
print("mode:", img.mode, "width:", img.width, "height:", img.height)
|
|
36
|
+
pixel = img.getpixel((img.width // 2, img.height // 2))
|
|
37
|
+
print("center pixel:", pixel)
|
|
38
|
+
except ImportError:
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
# Write image to disk
|
|
42
|
+
path.write_bytes(m.thumbnail.bytes if m else b"")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def main():
|
|
46
|
+
parser = argparse.ArgumentParser(description="download a thumbnail")
|
|
47
|
+
parser.add_argument("url", help="media URL to thumbnail")
|
|
48
|
+
parser.add_argument("path", type=Path, help="output jpeg path")
|
|
49
|
+
args = parser.parse_args()
|
|
50
|
+
try:
|
|
51
|
+
thumbnail(args.url, args.path)
|
|
52
|
+
except thumbrella.ThumbError as err:
|
|
53
|
+
raise SystemExit(err)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
main()
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""collage.py — stream thumbnails into a grid collage.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
python collage.py urls.txt collage.jpg
|
|
5
|
+
|
|
6
|
+
urls.txt should contain one URL per line (# comments and blank lines ok).
|
|
7
|
+
|
|
8
|
+
Streams results live and reports timing + cache status. Thumbnails are
|
|
9
|
+
arranged on a 6-column grid (3 columns when fewer than 8 items).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import asyncio
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import thumbrella
|
|
18
|
+
|
|
19
|
+
THUMB_W, THUMB_H = 250, 200
|
|
20
|
+
SPACING = 4
|
|
21
|
+
COLS = 6
|
|
22
|
+
COLS_SMALL = 3
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def collage(urls_file: Path, out_path: Path) -> None:
|
|
26
|
+
urls = [
|
|
27
|
+
line.strip()
|
|
28
|
+
for line in urls_file.read_text().splitlines()
|
|
29
|
+
if line.strip() and not line.startswith("#")
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
# Client reads TBR_CONNECT env var for server URL or cloud token.
|
|
33
|
+
tbr = thumbrella.Client().verify()
|
|
34
|
+
|
|
35
|
+
placed: list[thumbrella.Result] = []
|
|
36
|
+
placeholders = 0
|
|
37
|
+
t0 = time.monotonic()
|
|
38
|
+
|
|
39
|
+
async for result in tbr.stream(urls):
|
|
40
|
+
elapsed = time.monotonic() - t0
|
|
41
|
+
|
|
42
|
+
# Intermediate result — tier1 delegating to a renderer.
|
|
43
|
+
if result.status == "intermediate":
|
|
44
|
+
placeholders += 1
|
|
45
|
+
print(f" ... {result.url}")
|
|
46
|
+
continue
|
|
47
|
+
|
|
48
|
+
if result.is_success():
|
|
49
|
+
placed.append(result)
|
|
50
|
+
tag = _cache_tag(result)
|
|
51
|
+
print(
|
|
52
|
+
f" {tag:6s} {result.duration:>5.0f}ms "
|
|
53
|
+
f"{result.kind:8s} {result.mime or '':20s} {result.url}"
|
|
54
|
+
)
|
|
55
|
+
else:
|
|
56
|
+
print(f" FAIL {result.status:16s} {result.url}")
|
|
57
|
+
|
|
58
|
+
if not placed:
|
|
59
|
+
raise SystemExit("no thumbnails to collage")
|
|
60
|
+
|
|
61
|
+
# Build collage grid.
|
|
62
|
+
cols = COLS_SMALL if len(placed) < 8 else COLS
|
|
63
|
+
rows = (len(placed) + cols - 1) // cols
|
|
64
|
+
cw, ch = THUMB_W + SPACING, THUMB_H + SPACING
|
|
65
|
+
canvas_w = cols * cw + SPACING
|
|
66
|
+
canvas_h = rows * ch + SPACING
|
|
67
|
+
|
|
68
|
+
from PIL import Image
|
|
69
|
+
|
|
70
|
+
grid = Image.new("RGB", (canvas_w, canvas_h), (255, 255, 255))
|
|
71
|
+
|
|
72
|
+
for i, r in enumerate(placed):
|
|
73
|
+
col, row = i % cols, i // cols
|
|
74
|
+
x, y = SPACING + col * cw, SPACING + row * ch
|
|
75
|
+
img = Image.open(r.thumbnail.io)
|
|
76
|
+
img.load()
|
|
77
|
+
grid.paste(img, (x, y))
|
|
78
|
+
|
|
79
|
+
grid.save(out_path, quality=85)
|
|
80
|
+
print(
|
|
81
|
+
f"\n{len(placed)} thumbnails "
|
|
82
|
+
f"{rows}x{cols} "
|
|
83
|
+
f"{grid.size[0]}x{grid.size[1]} px "
|
|
84
|
+
f"{placeholders} placeholders -> {out_path}"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _cache_tag(result: thumbrella.Result) -> str:
|
|
89
|
+
"""Short label for how this thumbnail was produced."""
|
|
90
|
+
src = result.source
|
|
91
|
+
if src == thumbrella.Source.CLIENT:
|
|
92
|
+
return "client"
|
|
93
|
+
if src == thumbrella.Source.CACHE:
|
|
94
|
+
return "cached"
|
|
95
|
+
if src == thumbrella.Source.SHORTCUT:
|
|
96
|
+
return "embed"
|
|
97
|
+
return "render"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def main() -> None:
|
|
101
|
+
parser = argparse.ArgumentParser(description="stream thumbnails into a collage")
|
|
102
|
+
parser.add_argument("urls", type=Path, help="file with one media URL per line")
|
|
103
|
+
parser.add_argument("out", type=Path, help="output collage JPEG path")
|
|
104
|
+
args = parser.parse_args()
|
|
105
|
+
try:
|
|
106
|
+
asyncio.run(collage(args.urls, args.out))
|
|
107
|
+
except thumbrella.ThumbError as err:
|
|
108
|
+
raise SystemExit(err)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__":
|
|
112
|
+
main()
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""gallery.py — stream a batch of URLs with live progress and local caching.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
python gallery.py urls.txt thumbs/
|
|
5
|
+
|
|
6
|
+
urls.txt should contain one URL per line (# comments and blank lines ok).
|
|
7
|
+
|
|
8
|
+
Re-running with the same output directory skips unchanged thumbnails by
|
|
9
|
+
persisting cache tokens in thumbs/.thumbrella_cache.json.
|
|
10
|
+
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import asyncio
|
|
15
|
+
import json
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import thumbrella
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def gallery(urls_file: Path, out_dir: Path) -> None:
|
|
22
|
+
"""Stream thumbnails for urls in *urls_file*, save to *out_dir*."""
|
|
23
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
urls = [
|
|
25
|
+
line.strip()
|
|
26
|
+
for line in urls_file.read_text().splitlines()
|
|
27
|
+
if line.strip() and not line.startswith("#")
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
# Restore persistent cache tokens from last run.
|
|
31
|
+
cache_path = out_dir / ".thumbrella_cache.json"
|
|
32
|
+
prev_cache: dict[str, str] = {}
|
|
33
|
+
try:
|
|
34
|
+
prev_cache = json.loads(cache_path.read_text())
|
|
35
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
# Client reads TBR_CONNECT env var for server URL or cloud token.
|
|
39
|
+
tbr = thumbrella.Client().verify()
|
|
40
|
+
image_loader = await _ensure_pil()
|
|
41
|
+
|
|
42
|
+
image_cache: dict[int, object] = {} # key → PIL Image
|
|
43
|
+
new_cache: dict[str, str] = {}
|
|
44
|
+
new_count, unchanged, failed = 0, 0, 0
|
|
45
|
+
|
|
46
|
+
async for result in tbr.stream(urls):
|
|
47
|
+
# Restore cache token so the server can 304 on unchanged media.
|
|
48
|
+
if result.url in prev_cache:
|
|
49
|
+
result.cache = prev_cache[result.url]
|
|
50
|
+
|
|
51
|
+
if result.is_success():
|
|
52
|
+
if result.is_fresh():
|
|
53
|
+
unchanged += 1
|
|
54
|
+
else:
|
|
55
|
+
new_count += 1
|
|
56
|
+
|
|
57
|
+
if result.cache:
|
|
58
|
+
new_cache[result.url] = result.cache
|
|
59
|
+
|
|
60
|
+
# Decode with PIL, deduplicated by thumbnail content key.
|
|
61
|
+
img = image_cache.get(result.thumbnail.key)
|
|
62
|
+
if img is None and image_loader is not None:
|
|
63
|
+
img = image_loader(result.thumbnail.io)
|
|
64
|
+
image_cache[result.thumbnail.key] = img
|
|
65
|
+
|
|
66
|
+
# Save thumbnail.
|
|
67
|
+
stem = result.url.rstrip("/").split("/")[-1] or "thumb"
|
|
68
|
+
safe = "".join(c if c.isalnum() or c in "._-" else "_" for c in stem)
|
|
69
|
+
path = out_dir / f"{safe}.jpg"
|
|
70
|
+
path.write_bytes(result.thumbnail.bytes)
|
|
71
|
+
|
|
72
|
+
w, h = img.size if img else (0, 0)
|
|
73
|
+
tag = "(cached)" if result.is_fresh() else ""
|
|
74
|
+
print(f" {tag:9s}{result.kind:8s} {w:>4d}x{h:<4d} {result.mime:20s} {result.url}")
|
|
75
|
+
else:
|
|
76
|
+
failed += 1
|
|
77
|
+
print(f" FAIL {result.status:16s} {result.url}")
|
|
78
|
+
|
|
79
|
+
if new_cache:
|
|
80
|
+
cache_path.write_text(json.dumps(new_cache, indent=2))
|
|
81
|
+
|
|
82
|
+
print(f"\n{new_count} new, {unchanged} unchanged, {failed} failed")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def _ensure_pil():
|
|
86
|
+
"""Return an image loader function, or None if Pillow is missing."""
|
|
87
|
+
try:
|
|
88
|
+
from PIL import Image
|
|
89
|
+
except ImportError:
|
|
90
|
+
print("(install Pillow for image dimensions)")
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
def load(io) -> object:
|
|
94
|
+
img = Image.open(io)
|
|
95
|
+
img.load()
|
|
96
|
+
return img
|
|
97
|
+
|
|
98
|
+
return load
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def main() -> None:
|
|
102
|
+
parser = argparse.ArgumentParser(description="stream thumbnails from a URL list")
|
|
103
|
+
parser.add_argument("urls", type=Path, help="file with one media URL per line")
|
|
104
|
+
parser.add_argument("out_dir", type=Path, help="directory for output JPEGs")
|
|
105
|
+
args = parser.parse_args()
|
|
106
|
+
try:
|
|
107
|
+
asyncio.run(gallery(args.urls, args.out_dir))
|
|
108
|
+
except thumbrella.ThumbError as err:
|
|
109
|
+
raise SystemExit(err)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
if __name__ == "__main__":
|
|
113
|
+
main()
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""stream.py - show progress on async thumbnail rendering.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
python stream.py https://www.python.org/static/img/python-logo.png https://www.pygame.org/docs/_images/pygame_powered.png https://pypi.org/unknown.jpg
|
|
5
|
+
|
|
6
|
+
Gets a generated thumbnail for any URL. If there are problems connecting
|
|
7
|
+
to Thumbrella or accessing the media URL this will fail with an explanation.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import argparse
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
import thumbrella
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main():
|
|
19
|
+
parser = argparse.ArgumentParser(description="monitor streamed results")
|
|
20
|
+
parser.add_argument("urls", nargs="+", help="media URLs to stream")
|
|
21
|
+
parser.add_argument("--batch", action="store_true", help="use sync back instead")
|
|
22
|
+
args = parser.parse_args()
|
|
23
|
+
|
|
24
|
+
tbr = thumbrella.Client().verify()
|
|
25
|
+
try:
|
|
26
|
+
if args.batch:
|
|
27
|
+
batched(tbr, args.urls)
|
|
28
|
+
else:
|
|
29
|
+
asyncio.run(streamed(tbr, args.urls))
|
|
30
|
+
except thumbrella.ThumbError as err:
|
|
31
|
+
raise SystemExit(err)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def streamed(tbr: thumbrella.Client, urls: list[str]):
|
|
35
|
+
"""Monitor streamed results."""
|
|
36
|
+
# async with thumbrella.Client() as tbr:
|
|
37
|
+
# tbr.verify()
|
|
38
|
+
async with tbr:
|
|
39
|
+
indices = _index(urls)
|
|
40
|
+
start = time.time()
|
|
41
|
+
stream = tbr.stream(urls)
|
|
42
|
+
async for result in stream:
|
|
43
|
+
_report(start, indices.get(result.url, "XXX"), result)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def batched(tbr: thumbrella.Client, urls: list[str]):
|
|
47
|
+
"""Get all results in one single call"""
|
|
48
|
+
indices = _index(urls)
|
|
49
|
+
start = time.time()
|
|
50
|
+
batch = tbr.batch(urls)
|
|
51
|
+
for result in batch:
|
|
52
|
+
_report(start, indices.get(result.url, "XXX"), result)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _index(urls: list[str]):
|
|
56
|
+
"""Assign report index to each url"""
|
|
57
|
+
indices = {u: f"{i:03d}" for i, u in enumerate(urls, 1)}
|
|
58
|
+
for u, i in indices.items():
|
|
59
|
+
print(f"{i}: {u}")
|
|
60
|
+
return indices
|
|
61
|
+
|
|
62
|
+
def _report(start: float, idx: str, result: thumbrella.Result):
|
|
63
|
+
"""One line report for each result"""
|
|
64
|
+
kind = f"{result.media.kind}({result.media.extension})" if result.media else "<nomedia>"
|
|
65
|
+
print(
|
|
66
|
+
f"{(time.time() - start) * 1000:,.0f}ms {idx}"
|
|
67
|
+
f" - {result.status} {kind} {result.source} {result.message or ''}"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if __name__ == "__main__":
|
|
71
|
+
main()
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "thumbrella-client"
|
|
3
|
+
version = "0.5.0"
|
|
4
|
+
description = "Python client for the Thumbrella thumbnail API — typed results, async streaming, pluggable caching"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = "Apache-2.0"
|
|
8
|
+
keywords = ["thumbrella", "thumbnail", "thumbnails", "image", "media", "streaming"]
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
11
|
+
"Intended Audience :: Developers",
|
|
12
|
+
"Programming Language :: Python :: 3",
|
|
13
|
+
"Programming Language :: Python :: 3.10",
|
|
14
|
+
"Programming Language :: Python :: 3.11",
|
|
15
|
+
"Programming Language :: Python :: 3.12",
|
|
16
|
+
"Programming Language :: Python :: 3.13",
|
|
17
|
+
"Programming Language :: Python :: 3.14",
|
|
18
|
+
"Topic :: Multimedia :: Graphics",
|
|
19
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
dependencies = [
|
|
24
|
+
"requests>=2.32.0",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
async = ["aiohttp>=3.9.0"]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://thumbrella.dev"
|
|
32
|
+
Repository = "https://github.com/thumbrella-dev/clients"
|
|
33
|
+
Issues = "https://github.com/thumbrella-dev/clients/issues"
|
|
34
|
+
Documentation = "https://thumbrella.dev/docs/client"
|
|
35
|
+
|
|
36
|
+
[build-system]
|
|
37
|
+
requires = ["setuptools>=68", "wheel"]
|
|
38
|
+
build-backend = "setuptools.build_meta"
|
|
39
|
+
|
|
40
|
+
[tool.setuptools.packages.find]
|
|
41
|
+
where = ["src"]
|
|
42
|
+
|
|
43
|
+
[tool.setuptools.package-data]
|
|
44
|
+
thumbrella = ["*.jpg"]
|
|
45
|
+
|