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 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
+