thumbrella-client 0.5.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.
- thumbrella/__init__.py +63 -0
- thumbrella/cache.py +184 -0
- thumbrella/client.py +442 -0
- thumbrella/constants.py +73 -0
- thumbrella/errors.py +25 -0
- thumbrella/failed.jpeg +0 -0
- thumbrella/http.py +162 -0
- thumbrella/py.typed +0 -0
- thumbrella/result.py +384 -0
- thumbrella_client-0.5.0.dist-info/METADATA +143 -0
- thumbrella_client-0.5.0.dist-info/RECORD +13 -0
- thumbrella_client-0.5.0.dist-info/WHEEL +5 -0
- thumbrella_client-0.5.0.dist-info/top_level.txt +1 -0
thumbrella/__init__.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Thumbrella - fast thumbnails for online media.
|
|
2
|
+
|
|
3
|
+
Thumbrella is an online thumbnailing service. It is easy to self host or run
|
|
4
|
+
self hosted servers or use Thumbrella Cloud. Learn more at
|
|
5
|
+
https://thumbrella.dev
|
|
6
|
+
|
|
7
|
+
This Python client makes it simple and efficient to access the thumbnailer.
|
|
8
|
+
There are several main classes that manage the process.
|
|
9
|
+
|
|
10
|
+
- `Client` is the main configuration and interface to accessing a server. It
|
|
11
|
+
provides methods like `batch` and `stream` to generate a collection of `Result
|
|
12
|
+
objects.
|
|
13
|
+
- Each `Result` contains a small collection of attributes about the thumbnail,
|
|
14
|
+
the most important is the `media` attribute, which contains details about the
|
|
15
|
+
origin file and the binary encoded jpeg data.
|
|
16
|
+
- There are also a set of `Cache` objects which make it easy to persist caching
|
|
17
|
+
to systems like SQLite.
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
import thumbrella
|
|
21
|
+
|
|
22
|
+
tbr = thumbrella.Client()
|
|
23
|
+
result = tbr.thumb("https://example.com/photo.jpg")
|
|
24
|
+
result.media.thumbnail.bytes # JPEG encoded binary data
|
|
25
|
+
|
|
26
|
+
The Thumbrella server and a collection of client libraries and tools are all
|
|
27
|
+
released under the Apache 2 license. Visit https://thumbrella.dev/docs/ for more
|
|
28
|
+
information.
|
|
29
|
+
|
|
30
|
+
The server generates thumbnails for a variety of media; images, video, vector,
|
|
31
|
+
documents, 3d geometry, and more. This client makes it straightforward to use
|
|
32
|
+
advanced server features; advanced caching, partial file reads, streaming
|
|
33
|
+
asynchronous batch results, fallbacks, rate control, and more.
|
|
34
|
+
|
|
35
|
+
The `Client.stream()` method provides asynchronous and efficient results. These
|
|
36
|
+
require the optional dependency ``aiohttp``. This can be installed
|
|
37
|
+
independently, or included with thumbrella as a feature, installing
|
|
38
|
+
``thumbrella-client[async]``.
|
|
39
|
+
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from .client import Client
|
|
43
|
+
from .result import EncodedJpeg, Media, Result
|
|
44
|
+
from .cache import Cache, MemoryCache
|
|
45
|
+
from .constants import Source, Status, FileKind
|
|
46
|
+
from .errors import ThumbError, ConnectionError, TimeoutError, VerifyError, ServerError
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"Client",
|
|
50
|
+
"Media",
|
|
51
|
+
"EncodedJpeg",
|
|
52
|
+
"Result",
|
|
53
|
+
"Cache",
|
|
54
|
+
"MemoryCache",
|
|
55
|
+
"Source",
|
|
56
|
+
"Status",
|
|
57
|
+
"FileKind",
|
|
58
|
+
"ThumbError",
|
|
59
|
+
"ConnectionError",
|
|
60
|
+
"TimeoutError",
|
|
61
|
+
"VerifyError",
|
|
62
|
+
"ServerError",
|
|
63
|
+
]
|
thumbrella/cache.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Caches attached to clients, internal code"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from typing import TYPE_CHECKING, Sequence
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from .result import Media
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Cache(ABC):
|
|
14
|
+
"""Abstract base class for result caches.
|
|
15
|
+
|
|
16
|
+
Caches are passed to the `Client` when constructed. Each client works
|
|
17
|
+
with a stack of cache objects, and will use a small `MemoryCache`
|
|
18
|
+
by default.
|
|
19
|
+
|
|
20
|
+
The caches offer limited management (`clear`) methods and simple
|
|
21
|
+
statistics tracking (`hits` and `misses`)
|
|
22
|
+
|
|
23
|
+
A cache can be used with multiple clients at the same time.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __eq__(self, other: object) -> bool:
|
|
27
|
+
return self is other
|
|
28
|
+
|
|
29
|
+
def __ne__(self, other: object) -> bool:
|
|
30
|
+
return self is not other
|
|
31
|
+
|
|
32
|
+
def __hash__(self) -> int:
|
|
33
|
+
return object.__hash__(self)
|
|
34
|
+
|
|
35
|
+
@abstractmethod
|
|
36
|
+
def get(self, url: str) -> "Media | None":
|
|
37
|
+
"""Get the possible cached media for an url."""
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def put(self, media: "Media") -> None:
|
|
41
|
+
"""Store cached media for an url."""
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
def remove(self, url: str) -> None:
|
|
45
|
+
"""Remove possible cached media for an url."""
|
|
46
|
+
|
|
47
|
+
@abstractmethod
|
|
48
|
+
def reset(self) -> None:
|
|
49
|
+
"""Clear all cached urls and reset statistics."""
|
|
50
|
+
|
|
51
|
+
@abstractmethod
|
|
52
|
+
def __len__(self) -> int:
|
|
53
|
+
"""Number of cached entries."""
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
@abstractmethod
|
|
57
|
+
def hits(self) -> int:
|
|
58
|
+
"""Number of cache hits since creation or last reset."""
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
@abstractmethod
|
|
62
|
+
def misses(self) -> int:
|
|
63
|
+
"""Number of cache misses since creation or last reset."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class MemoryCache(Cache):
|
|
67
|
+
"""A small temporary cache for the current process.
|
|
68
|
+
|
|
69
|
+
The default cache stores a small amount of thumbnails in memory. Nothing
|
|
70
|
+
is stored after the cache is removed.
|
|
71
|
+
|
|
72
|
+
Each Thumbrella `Client` works with a stack of cache objects, assigned
|
|
73
|
+
at construction time. By default the client creates and uses this
|
|
74
|
+
`MemoryCache` with the default arguments.
|
|
75
|
+
|
|
76
|
+
This cache uses an LRU strategy to keep the number of thumbnails
|
|
77
|
+
within the specified `max_items` limit.
|
|
78
|
+
|
|
79
|
+
Most thumbnails will use approximately 5K worth of data each.
|
|
80
|
+
|
|
81
|
+
Usage:
|
|
82
|
+
cache = thumbrella.MemoryCache(max_items=100)
|
|
83
|
+
tbr = thumbrella.Client(caches=[cache])
|
|
84
|
+
tbr.batch(urls)
|
|
85
|
+
|
|
86
|
+
assert len(cache) == len(urls)
|
|
87
|
+
assert (cache.hits + cache.misses) == len(urls)
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __init__(self, max_items: int = 256) -> None:
|
|
91
|
+
self._max_items = max_items
|
|
92
|
+
self._store: dict[str, "Media"] = {}
|
|
93
|
+
self._order: list[str] = [] # LRU order: front = most recent
|
|
94
|
+
self._hits = 0
|
|
95
|
+
self._misses = 0
|
|
96
|
+
|
|
97
|
+
def __repr__(self) -> str:
|
|
98
|
+
return (
|
|
99
|
+
f"<thumbrella.MemoryCache "
|
|
100
|
+
f"items={len(self._store)}/{self._max_items} "
|
|
101
|
+
f"hits={self._hits}/{self._hits + self._misses}>"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def get(self, url: str) -> "Media | None":
|
|
105
|
+
try:
|
|
106
|
+
media = self._store[url]
|
|
107
|
+
except KeyError:
|
|
108
|
+
self._misses += 1
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
self._hits += 1
|
|
112
|
+
|
|
113
|
+
# Move to front (most-recently-used).
|
|
114
|
+
self._order.remove(url)
|
|
115
|
+
self._order.insert(0, url)
|
|
116
|
+
return media
|
|
117
|
+
|
|
118
|
+
def put(self, media: "Media") -> None:
|
|
119
|
+
url = media.url
|
|
120
|
+
if not url:
|
|
121
|
+
return
|
|
122
|
+
if url in self._store:
|
|
123
|
+
self._order.remove(url)
|
|
124
|
+
elif len(self._store) >= self._max_items:
|
|
125
|
+
stale = self._order.pop()
|
|
126
|
+
del self._store[stale]
|
|
127
|
+
|
|
128
|
+
self._store[url] = media
|
|
129
|
+
self._order.insert(0, url)
|
|
130
|
+
|
|
131
|
+
def remove(self, url: str) -> None:
|
|
132
|
+
try:
|
|
133
|
+
del self._store[url]
|
|
134
|
+
self._order.remove(url)
|
|
135
|
+
except (KeyError, ValueError):
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
def reset(self) -> None:
|
|
139
|
+
self._store.clear()
|
|
140
|
+
self._order.clear()
|
|
141
|
+
self._hits = 0
|
|
142
|
+
self._misses = 0
|
|
143
|
+
|
|
144
|
+
def __len__(self) -> int:
|
|
145
|
+
return len(self._store)
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def hits(self) -> int:
|
|
149
|
+
return self._hits
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def misses(self) -> int:
|
|
153
|
+
return self._misses
|
|
154
|
+
|
|
155
|
+
def __iter__(self):
|
|
156
|
+
"""Iterate over cached results (MRU order)."""
|
|
157
|
+
return (self._store[u] for u in self._order)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# internal
|
|
161
|
+
def is_cache_fresh(cache_value: str) -> bool:
|
|
162
|
+
"""Check if the contents of a cache screen identify it as still fresh.
|
|
163
|
+
|
|
164
|
+
A fresh cache means the server does not need to be queried to check
|
|
165
|
+
if the thumbnail should be updated.
|
|
166
|
+
"""
|
|
167
|
+
if not cache_value:
|
|
168
|
+
return False
|
|
169
|
+
partitions = cache_value.partition(":")
|
|
170
|
+
if not all(partitions):
|
|
171
|
+
return False
|
|
172
|
+
try:
|
|
173
|
+
expires = int(partitions[0], 16)
|
|
174
|
+
except ValueError:
|
|
175
|
+
return False
|
|
176
|
+
return expires > 0 and expires > time.time()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# internal
|
|
180
|
+
def put_all_caches(caches: Sequence[Cache], media: Media | None) -> None:
|
|
181
|
+
"""Store media in all registered caches."""
|
|
182
|
+
if media is not None:
|
|
183
|
+
for cache in caches:
|
|
184
|
+
cache.put(media)
|
thumbrella/client.py
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
"""Thumbrella HTTP client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.metadata
|
|
6
|
+
from typing import Any, AsyncIterator, Sequence
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from .cache import Cache, MemoryCache, put_all_caches
|
|
11
|
+
from .constants import DEFAULT_BASE, HTTP_TIMEOUT, Source, Status
|
|
12
|
+
from .result import Result, Media, EncodedJpeg
|
|
13
|
+
from .errors import ConnectionError, TimeoutError, VerifyError, ThumbError
|
|
14
|
+
from .http import parse_connect, requests_json, aio_ndjson
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Client:
|
|
18
|
+
"""Thumbrella API client.
|
|
19
|
+
|
|
20
|
+
A centralized configuration for a Thumbrella server and client side caches.
|
|
21
|
+
The connection is described by a "connect string". By default this uses the
|
|
22
|
+
``$TBR_CONNECT`` environment variable.
|
|
23
|
+
|
|
24
|
+
Most thumbnails will be handled in batches with the ``batch()`` or
|
|
25
|
+
``stream()`` methods. These will return (or iterate) a set of ``Result``
|
|
26
|
+
objects. Which can individually succeed, fail, or reuse cached contents. All
|
|
27
|
+
result objects will have a placeholder or failure image, even if one could
|
|
28
|
+
not be rendered.
|
|
29
|
+
|
|
30
|
+
The ``stream()`` is asynchronous and requires an additional optional
|
|
31
|
+
dependency on `aiohttp`.
|
|
32
|
+
|
|
33
|
+
Creating the client makes no immediate connection to the server. When a
|
|
34
|
+
connection is misconfigured calls will still provide ``Result`` objects with
|
|
35
|
+
incomplete results. Use the ``verify()`` to ensure the configuration is
|
|
36
|
+
good, which will raise exceptions if there any server side or client side
|
|
37
|
+
issues.
|
|
38
|
+
|
|
39
|
+
A collection of caches can be passed to the client. These are integrated
|
|
40
|
+
with each of the lookup methods to improve performance. By default the
|
|
41
|
+
client will use a single ``MemoryCache`` with the default settings. A client
|
|
42
|
+
client can also be created with no caching by explicitly passing an empty sequence
|
|
43
|
+
for the ``caches`` argument.
|
|
44
|
+
|
|
45
|
+
This exposes a ``session`` attribute. This a ``requests.Session` object that
|
|
46
|
+
can be used to customize the http calls being made. Add custom proxies,
|
|
47
|
+
cookies, tls certificates, and more. The Thumbrella connection string is
|
|
48
|
+
usually responsible for defining additional http headers. But more extensive
|
|
49
|
+
customization can be done on an created client.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
connect: Optional connection string that overrides ``$TBR_CONNECT``.
|
|
53
|
+
caches: One or more :class:`Cache` backends. ``None`` (the
|
|
54
|
+
default) creates a :class:`MemoryCache` with 256 entries.
|
|
55
|
+
Pass an empty sequence to disable caching.
|
|
56
|
+
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
connect: str | None = None,
|
|
62
|
+
*,
|
|
63
|
+
caches: Sequence[Cache] | None = None,
|
|
64
|
+
) -> None:
|
|
65
|
+
self.session: requests.Session = requests.Session()
|
|
66
|
+
self._asession: Any = None # lazy aiohttp.ClientSession
|
|
67
|
+
self.base_url, self.host_name = parse_connect(connect, self.session)
|
|
68
|
+
self.session.headers["User-Agent"] = _client_user_agent()
|
|
69
|
+
|
|
70
|
+
if caches is None:
|
|
71
|
+
self.caches: tuple[Cache, ...] = (MemoryCache(), )
|
|
72
|
+
else:
|
|
73
|
+
self.caches = tuple(caches)
|
|
74
|
+
|
|
75
|
+
def __repr__(self) -> str:
|
|
76
|
+
return f"<thumbrella.Client {self.base_url!r}>"
|
|
77
|
+
|
|
78
|
+
def __eq__(self, other: object) -> bool:
|
|
79
|
+
return self is other
|
|
80
|
+
|
|
81
|
+
def __ne__(self, other: object) -> bool:
|
|
82
|
+
return not self == other
|
|
83
|
+
|
|
84
|
+
def __hash__(self) -> int:
|
|
85
|
+
return object.__hash__(self)
|
|
86
|
+
|
|
87
|
+
def verify(self) -> Client:
|
|
88
|
+
"""Check configuration and server connectivity.
|
|
89
|
+
|
|
90
|
+
Check that the server is operational and the configuration string
|
|
91
|
+
is valid. If the connection string defines tokens or custom http
|
|
92
|
+
headers those will also be validated.
|
|
93
|
+
|
|
94
|
+
On success this returns itself, to allow method chaining for
|
|
95
|
+
simplistic use cases.
|
|
96
|
+
|
|
97
|
+
Usage:
|
|
98
|
+
url = "http://demo.thumbrella.dev/cat.jpeg"
|
|
99
|
+
tbr = thumbrella.Client()
|
|
100
|
+
tbr.verify().thumb(url)
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
VerifyError: if the server is unreachable or misconfigured.
|
|
104
|
+
"""
|
|
105
|
+
try:
|
|
106
|
+
data = requests_json(
|
|
107
|
+
self.session, self.host_name, self.base_url, "GET", "/health",
|
|
108
|
+
)
|
|
109
|
+
except (ConnectionError, TimeoutError) as exc:
|
|
110
|
+
raise VerifyError(
|
|
111
|
+
"connect server not responding"
|
|
112
|
+
) from exc
|
|
113
|
+
except ValueError:
|
|
114
|
+
# Non-JSON response — the server at the other end isn't thumbrella.
|
|
115
|
+
raise VerifyError("connect is not a thumbrella server") from None
|
|
116
|
+
except requests.RequestException as exc:
|
|
117
|
+
raise VerifyError(
|
|
118
|
+
f"connect server not responding ({exc})"
|
|
119
|
+
) from exc
|
|
120
|
+
|
|
121
|
+
if "thumbrella" not in data:
|
|
122
|
+
raise VerifyError("connect is not a thumbrella server")
|
|
123
|
+
if data.get("status") != "ok":
|
|
124
|
+
raise VerifyError(f"connect unexpected response: {data}")
|
|
125
|
+
if data.get("token") is False:
|
|
126
|
+
if "Authorization" in self.session.headers:
|
|
127
|
+
raise VerifyError("thumbrella connect invalid token")
|
|
128
|
+
else:
|
|
129
|
+
raise VerifyError("thumbrella connect requires a token")
|
|
130
|
+
return self
|
|
131
|
+
|
|
132
|
+
def thumb(self, url: str) -> Result:
|
|
133
|
+
"""Get a single url result and fail if unsuccessful.
|
|
134
|
+
|
|
135
|
+
This is a shortcut to regular `batch()` for simple use cases. If
|
|
136
|
+
there is any problem generating a thumbnail this will result in an
|
|
137
|
+
exception, instead of a placeholder `Result`.
|
|
138
|
+
|
|
139
|
+
Individual results can get the same effect by using `Result.verify`.
|
|
140
|
+
|
|
141
|
+
This call waits for the result to complete before returning. It is
|
|
142
|
+
synchronous and blocking.
|
|
143
|
+
|
|
144
|
+
See the https://thumbrella.dev/docs/api/batch.html server documentation
|
|
145
|
+
on the batch call for more details on how the server processes these
|
|
146
|
+
results.
|
|
147
|
+
|
|
148
|
+
Usage:
|
|
149
|
+
url = "http://demo.thumbrella.dev/cat.jpeg"
|
|
150
|
+
tbr = thumbrella.Client()
|
|
151
|
+
tbr.thumb(url)
|
|
152
|
+
|
|
153
|
+
tbr.batch([url])[0].verify() # equivalent batch call
|
|
154
|
+
|
|
155
|
+
Raises:
|
|
156
|
+
ThumbError: if the server returned an error for this URL.
|
|
157
|
+
"""
|
|
158
|
+
return self.batch((url, ))[0].verify()
|
|
159
|
+
|
|
160
|
+
def batch(self, urls: Sequence[str]) -> list[Result]:
|
|
161
|
+
"""Generate multiple thumbnail results.
|
|
162
|
+
|
|
163
|
+
Generate a list of ``Result`` objects for the given urls. The returned
|
|
164
|
+
results are provided in the same order as the input urls.
|
|
165
|
+
|
|
166
|
+
This call waits for all results to complete before returning. It is
|
|
167
|
+
synchronous and blocking. For incremental results, see the `stream()`
|
|
168
|
+
method.
|
|
169
|
+
|
|
170
|
+
This call won't raise exceptions. On errors, results will be marked
|
|
171
|
+
with a failure status, but will still contain placeholder thumbnails.
|
|
172
|
+
|
|
173
|
+
See the https://thumbrella.dev/docs/api/batch.html server documentation
|
|
174
|
+
on the batch call for more details on how the server processes these
|
|
175
|
+
results.
|
|
176
|
+
|
|
177
|
+
Usage:
|
|
178
|
+
urls = ["http://demo.thumbrella.dev/cat.jpeg", "http://demo.thumbrella.dev/dog.png"]
|
|
179
|
+
tbr = thumbrella.Client()
|
|
180
|
+
results = tbr.batch(urls)
|
|
181
|
+
print([f"{r.status} {r.url}" for r in results])
|
|
182
|
+
"""
|
|
183
|
+
done, stale = _preflight_urls(urls, self.caches)
|
|
184
|
+
|
|
185
|
+
if stale:
|
|
186
|
+
try:
|
|
187
|
+
body = requests_json(
|
|
188
|
+
self.session, self.host_name, self.base_url, "POST", "/batch",
|
|
189
|
+
json={"items": stale},
|
|
190
|
+
)
|
|
191
|
+
except (ConnectionError, TimeoutError) as exc:
|
|
192
|
+
return _fail_all(done, stale, urls, str(exc))
|
|
193
|
+
|
|
194
|
+
items = body.get("items")
|
|
195
|
+
if not isinstance(items, list):
|
|
196
|
+
return _fail_all(done, stale, urls, "unexpected server response")
|
|
197
|
+
|
|
198
|
+
for item in items:
|
|
199
|
+
result = _result_from_server(item, caches=self.caches, server_key=self.base_url)
|
|
200
|
+
done[result.url] = result
|
|
201
|
+
|
|
202
|
+
return _ordered_results(done, urls)
|
|
203
|
+
|
|
204
|
+
async def stream(self, urls: Sequence[str]) -> AsyncIterator[Result]:
|
|
205
|
+
"""Stream multiple thumbnail results as they complete.
|
|
206
|
+
|
|
207
|
+
This efficiently provides thumbnail results as they become available.
|
|
208
|
+
Media that requires longer rendering can receive intermediate updates
|
|
209
|
+
and placeholders as they are processed.
|
|
210
|
+
|
|
211
|
+
This async method requires the optional ``aiohttp`` module to be
|
|
212
|
+
importable. If the module cannot be found this raises an exception.
|
|
213
|
+
|
|
214
|
+
Every url will receive one result in the iterator, on success or failure.
|
|
215
|
+
Some media also receives intermediate results as the thumbnail is
|
|
216
|
+
processed. That can be determined with `Result.status` being
|
|
217
|
+
`thumbrella.Status.INTERMEDIATE`.
|
|
218
|
+
|
|
219
|
+
Python asynchronous code should often use a context to help control
|
|
220
|
+
lifetime of resources and processing with the ``async with`` or
|
|
221
|
+
``async for`` operators.
|
|
222
|
+
|
|
223
|
+
The ``session`` attribute used to customize the http operations is
|
|
224
|
+
not used natively by aiohttp. The major information is translated
|
|
225
|
+
to ``aiohttp`` but not all features are expected to work.
|
|
226
|
+
|
|
227
|
+
See the https://thumbrella.dev/docs/api/batch.html server documentation
|
|
228
|
+
on the batch call for more details on how the server processes these
|
|
229
|
+
results.
|
|
230
|
+
|
|
231
|
+
Usage:
|
|
232
|
+
urls = ["http://demo.thumbrella.dev/cat.jpeg", "http://demo.thumbrella.dev/dog.png"]
|
|
233
|
+
tbr = thumbrella.Client()
|
|
234
|
+
async for result in tbr.stream(urls):
|
|
235
|
+
print([f"{r.status} {r.url}" for r in results])
|
|
236
|
+
"""
|
|
237
|
+
done, stale = _preflight_urls(urls, self.caches)
|
|
238
|
+
for url in urls:
|
|
239
|
+
if url in done:
|
|
240
|
+
yield done[url]
|
|
241
|
+
if not stale:
|
|
242
|
+
return
|
|
243
|
+
|
|
244
|
+
pending: set[str] = {item["url"] for item in stale}
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
import aiohttp
|
|
248
|
+
except ImportError:
|
|
249
|
+
raise ThumbError("The `stream` method requires aiohttp which cannot be imported")
|
|
250
|
+
|
|
251
|
+
if self._asession is None:
|
|
252
|
+
timeout = aiohttp.ClientTimeout(total=HTTP_TIMEOUT)
|
|
253
|
+
self._asession = aiohttp.ClientSession(timeout=timeout)
|
|
254
|
+
|
|
255
|
+
msg = ""
|
|
256
|
+
try:
|
|
257
|
+
async for item in aio_ndjson(
|
|
258
|
+
self._asession, self.host_name, self.base_url, "/batch",
|
|
259
|
+
json={"items": stale}, headers={"Accept": "application/x-ndjson"},
|
|
260
|
+
):
|
|
261
|
+
kind = item.get("type", "")
|
|
262
|
+
result_data = item.get("result")
|
|
263
|
+
if not result_data or kind not in ("item.intermediate", "item.result"):
|
|
264
|
+
continue
|
|
265
|
+
item_url = result_data.get("url", "")
|
|
266
|
+
if kind == "item.result":
|
|
267
|
+
pending.discard(item_url)
|
|
268
|
+
result = _result_from_server(
|
|
269
|
+
result_data, caches=self.caches, server_key=self.base_url
|
|
270
|
+
)
|
|
271
|
+
yield result
|
|
272
|
+
except Exception as exc:
|
|
273
|
+
msg = str(exc) or type(exc).__name__
|
|
274
|
+
|
|
275
|
+
for item_url in pending:
|
|
276
|
+
yield Result.client_fail(item_url, msg or "stream connection lost")
|
|
277
|
+
|
|
278
|
+
def reset_caches(self) -> None:
|
|
279
|
+
"""Reset all attached caches.
|
|
280
|
+
|
|
281
|
+
The cache reset is intended to clear the cache contents and reset
|
|
282
|
+
statistics and tracking information.
|
|
283
|
+
"""
|
|
284
|
+
for cache in self.caches:
|
|
285
|
+
cache.reset()
|
|
286
|
+
|
|
287
|
+
async def close(self) -> None:
|
|
288
|
+
"""Close asyncronous sessions.
|
|
289
|
+
|
|
290
|
+
The asyncronous workers of the `stream()` call are cleaned up with
|
|
291
|
+
this async method if needed.
|
|
292
|
+
|
|
293
|
+
It is safe to call this method multiple times.
|
|
294
|
+
"""
|
|
295
|
+
if self._asession is not None:
|
|
296
|
+
await self._asession.close()
|
|
297
|
+
self._asession = None
|
|
298
|
+
|
|
299
|
+
async def __aenter__(self) -> Client:
|
|
300
|
+
return self
|
|
301
|
+
|
|
302
|
+
async def __aexit__(self, *_: Any) -> None:
|
|
303
|
+
await self.close()
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _client_user_agent() -> str:
|
|
307
|
+
"""Build a User-Agent string from the installed package version."""
|
|
308
|
+
try:
|
|
309
|
+
ver = importlib.metadata.version("thumbrella-client")
|
|
310
|
+
except Exception:
|
|
311
|
+
ver = "dev"
|
|
312
|
+
return f"thumbrella-python/{ver}"
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# Per-server placeholder thumbnail cache — permanent, keyed by connect string.
|
|
316
|
+
_PLACEHOLDER_CACHE: dict[str, dict[str, EncodedJpeg]] = {}
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _result_from_server(
|
|
320
|
+
item: dict[str, Any],
|
|
321
|
+
*,
|
|
322
|
+
caches: Sequence[Cache],
|
|
323
|
+
server_key: str,
|
|
324
|
+
) -> Result:
|
|
325
|
+
"""Build a :class:`Result` from a server response item, with identity sharing.
|
|
326
|
+
|
|
327
|
+
- ``not_modified`` responses reuse cached :class:`Media` (same instance).
|
|
328
|
+
- Placeholder thumbnails share :class:`EncodedJpeg` blobs across results.
|
|
329
|
+
- Normal results construct fresh objects.
|
|
330
|
+
|
|
331
|
+
On success the result's media is stored in all *caches*.
|
|
332
|
+
"""
|
|
333
|
+
url = item.get("url", "")
|
|
334
|
+
source = item.get("source")
|
|
335
|
+
placeholder = item.get("placeholder")
|
|
336
|
+
|
|
337
|
+
if source == Source.NOT_MODIFIED:
|
|
338
|
+
media = _media_from_caches(url, caches)
|
|
339
|
+
result = Result(item, media=media) if media else Result(item)
|
|
340
|
+
elif placeholder:
|
|
341
|
+
thumb_b64 = item.get("media", {}).get("thumbnail", "")
|
|
342
|
+
thumb = _placeholder_thumb(server_key, placeholder, thumb_b64)
|
|
343
|
+
result = Result(item, thumbnail=thumb)
|
|
344
|
+
else:
|
|
345
|
+
result = Result(item)
|
|
346
|
+
|
|
347
|
+
put_all_caches(caches, result.media)
|
|
348
|
+
return result
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _media_from_caches(url: str, caches: Sequence[Cache]) -> Media | None:
|
|
352
|
+
"""Return cached Media for *url*, or ``None``."""
|
|
353
|
+
for cache in caches:
|
|
354
|
+
media = cache.get(url)
|
|
355
|
+
if media is not None:
|
|
356
|
+
return media
|
|
357
|
+
return None
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _placeholder_thumb(server_key: str, placeholder: str, b64: str) -> EncodedJpeg:
|
|
361
|
+
"""Get or create a shared EncodedJpeg for a placeholder icon."""
|
|
362
|
+
pool = _PLACEHOLDER_CACHE.setdefault(server_key, {})
|
|
363
|
+
shared = pool.get(placeholder)
|
|
364
|
+
if shared is not None:
|
|
365
|
+
return shared
|
|
366
|
+
blob = EncodedJpeg(b64=b64)
|
|
367
|
+
pool[placeholder] = blob
|
|
368
|
+
return blob
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _fail_all(
|
|
372
|
+
done: dict[str, Result],
|
|
373
|
+
items: Sequence[dict[str, str]],
|
|
374
|
+
urls: Sequence[str],
|
|
375
|
+
message: str,
|
|
376
|
+
) -> list[Result]:
|
|
377
|
+
"""Mark *items* as failed and return ordered Results for *urls*."""
|
|
378
|
+
for item in items:
|
|
379
|
+
url = item["url"]
|
|
380
|
+
done[url] = Result.client_fail(url, message)
|
|
381
|
+
return _ordered_results(done, urls)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _ordered_results(
|
|
385
|
+
done: dict[str, Result],
|
|
386
|
+
urls: Sequence[str],
|
|
387
|
+
) -> list[Result]:
|
|
388
|
+
"""Return Results in *urls* order, filling gaps with client-fail Results."""
|
|
389
|
+
results: list[Result] = []
|
|
390
|
+
for url in urls:
|
|
391
|
+
result = done.get(url)
|
|
392
|
+
if result is None:
|
|
393
|
+
result = Result.client_fail(url, "internal error: no result")
|
|
394
|
+
results.append(result)
|
|
395
|
+
return results
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _preflight_urls(
|
|
399
|
+
urls: Sequence[str],
|
|
400
|
+
caches: Sequence[Cache],
|
|
401
|
+
) -> tuple[dict[str, Result], list[dict[str, str]]]:
|
|
402
|
+
"""Check caches and validate URLs.
|
|
403
|
+
|
|
404
|
+
Returns ``(done, stale)`` where *done* maps url → Result for items
|
|
405
|
+
resolved without a server call, and *stale* is a list of
|
|
406
|
+
``{"url": ..., "cache": ...}`` dicts to send to the server.
|
|
407
|
+
"""
|
|
408
|
+
done: dict[str, Result] = {}
|
|
409
|
+
stale: list[dict[str, str]] = []
|
|
410
|
+
|
|
411
|
+
for url in urls:
|
|
412
|
+
# Basic URL validation — must have a scheme.
|
|
413
|
+
if not url or "://" not in url:
|
|
414
|
+
done[url] = Result.client_fail(url, "invalid URL")
|
|
415
|
+
continue
|
|
416
|
+
|
|
417
|
+
# Check all caches for a fresh entry.
|
|
418
|
+
fresh = False
|
|
419
|
+
for cache in caches:
|
|
420
|
+
media = cache.get(url)
|
|
421
|
+
if media is not None and media.is_fresh():
|
|
422
|
+
data = {"url": url, "status": Status.SUCCESS, "source": Source.CLIENT}
|
|
423
|
+
done[url] = Result(data, media=media)
|
|
424
|
+
fresh = True
|
|
425
|
+
break
|
|
426
|
+
|
|
427
|
+
if fresh:
|
|
428
|
+
continue
|
|
429
|
+
|
|
430
|
+
# Stale — build the server request. Include the cache token
|
|
431
|
+
# from any cached media so the server can do a conditional revalidation.
|
|
432
|
+
item: dict[str, str] = {"url": url}
|
|
433
|
+
for cache in caches:
|
|
434
|
+
media = cache.get(url)
|
|
435
|
+
if media is not None and media.cache:
|
|
436
|
+
item["cache"] = media.cache
|
|
437
|
+
break
|
|
438
|
+
stale.append(item)
|
|
439
|
+
|
|
440
|
+
return done, stale
|
|
441
|
+
|
|
442
|
+
|