dopememe 0.1.0a1__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.
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.3
2
+ Name: dopememe
3
+ Version: 0.1.0a1
4
+ Summary: dope.meme for Python, and the dopememe CLI: find meme templates, fill them, get images and permanent URLs.
5
+ Keywords: memes,images,api,sdk,cli
6
+ Author: Patrick Higgins
7
+ Author-email: Patrick Higgins <patrick@nicena.me>
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Typing :: Typed
10
+ Requires-Dist: httpx2>=2.16.0
11
+ Requires-Python: >=3.11
12
+ Project-URL: Homepage, https://dope.meme
13
+ Project-URL: Documentation, https://docs.dope.meme
14
+ Description-Content-Type: text/markdown
15
+
16
+ # dopememe
17
+
18
+ dope.meme for Python, and the `dopememe` command line: find meme templates, fill them,
19
+ and get images and permanent URLs.
20
+
21
+ ```sh
22
+ pip install dopememe # the library
23
+ uvx dopememe --help # or just the CLI
24
+ ```
25
+
26
+ ## The library
27
+
28
+ ```python
29
+ from dopememe import Client
30
+
31
+ client = Client() # an API key is optional: DOPEMEME_API_KEY, or api_key=
32
+
33
+ # Find a template by what you want to say, and read what each slot is for.
34
+ page = client.list_templates(q="preferring the new thing", tag=["slots:2"])
35
+ drake = client.retrieve_template(page.data[0].ref)
36
+
37
+ # Fill it. The URL is permanent, and nothing is stored.
38
+ render = client.render_meme(
39
+ {
40
+ "template": "drake-hotline",
41
+ "slots": {"reject": "Writing SDKs by hand", "prefer": "Generating them"},
42
+ }
43
+ )
44
+ print(render.url, render.alt)
45
+
46
+ # The image itself, as bytes.
47
+ image = client.render_image({"template": "drake-hotline", "slots": {"reject": "a", "prefer": "b"}})
48
+ image.save("drake.webp")
49
+
50
+ # An embeddable URL, built without a request.
51
+ client.embed_url("drake-hotline", ["Writing SDKs by hand", "Generating them"])
52
+
53
+ # Save one for a short URL that keeps working.
54
+ meme = client.create_meme(recipe={"template": "drake-hotline", "slots": {"prefer": "this"}})
55
+ ```
56
+
57
+ `AsyncClient` does the same with `await`. Lists page with `list_templates_all(…)`.
58
+ Errors are `dopememe.APIError`, with a stable `code` and every problem's `path` and
59
+ `hint`; `RateLimitError` says how long to wait. A `429` is retried after `Retry-After`,
60
+ and so are brief server errors when the request is safe to repeat.
61
+
62
+ Python 3.11 or newer. One dependency: `httpx2`.
63
+
64
+ ## The command line
65
+
66
+ ```sh
67
+ dopememe search "preferring the new thing"
68
+ dopememe show drake-hotline
69
+ dopememe render drake-hotline "Writing SDKs by hand" "Generating them" # its URL
70
+ dopememe render drake-hotline "Writing SDKs by hand" "Generating them" -o drake.png
71
+ dopememe render drake-hotline --slot prefer="Generating them" --preview # inline, in iTerm2, WezTerm or kitty
72
+ dopememe url drake-hotline "Writing SDKs by hand" "Generating them" # /g/, no request
73
+ dopememe save drake-hotline "Writing SDKs by hand" "Generating them"
74
+ dopememe login # save an API key
75
+ ```
76
+
77
+ Texts fill the template's text slots in reading order; `--slot name=text` names them.
78
+ `--recipe file.json` (or `-` for stdin) takes a whole recipe. `--json` prints what the
79
+ API returned, for scripts and agents.
@@ -0,0 +1,64 @@
1
+ # dopememe
2
+
3
+ dope.meme for Python, and the `dopememe` command line: find meme templates, fill them,
4
+ and get images and permanent URLs.
5
+
6
+ ```sh
7
+ pip install dopememe # the library
8
+ uvx dopememe --help # or just the CLI
9
+ ```
10
+
11
+ ## The library
12
+
13
+ ```python
14
+ from dopememe import Client
15
+
16
+ client = Client() # an API key is optional: DOPEMEME_API_KEY, or api_key=
17
+
18
+ # Find a template by what you want to say, and read what each slot is for.
19
+ page = client.list_templates(q="preferring the new thing", tag=["slots:2"])
20
+ drake = client.retrieve_template(page.data[0].ref)
21
+
22
+ # Fill it. The URL is permanent, and nothing is stored.
23
+ render = client.render_meme(
24
+ {
25
+ "template": "drake-hotline",
26
+ "slots": {"reject": "Writing SDKs by hand", "prefer": "Generating them"},
27
+ }
28
+ )
29
+ print(render.url, render.alt)
30
+
31
+ # The image itself, as bytes.
32
+ image = client.render_image({"template": "drake-hotline", "slots": {"reject": "a", "prefer": "b"}})
33
+ image.save("drake.webp")
34
+
35
+ # An embeddable URL, built without a request.
36
+ client.embed_url("drake-hotline", ["Writing SDKs by hand", "Generating them"])
37
+
38
+ # Save one for a short URL that keeps working.
39
+ meme = client.create_meme(recipe={"template": "drake-hotline", "slots": {"prefer": "this"}})
40
+ ```
41
+
42
+ `AsyncClient` does the same with `await`. Lists page with `list_templates_all(…)`.
43
+ Errors are `dopememe.APIError`, with a stable `code` and every problem's `path` and
44
+ `hint`; `RateLimitError` says how long to wait. A `429` is retried after `Retry-After`,
45
+ and so are brief server errors when the request is safe to repeat.
46
+
47
+ Python 3.11 or newer. One dependency: `httpx2`.
48
+
49
+ ## The command line
50
+
51
+ ```sh
52
+ dopememe search "preferring the new thing"
53
+ dopememe show drake-hotline
54
+ dopememe render drake-hotline "Writing SDKs by hand" "Generating them" # its URL
55
+ dopememe render drake-hotline "Writing SDKs by hand" "Generating them" -o drake.png
56
+ dopememe render drake-hotline --slot prefer="Generating them" --preview # inline, in iTerm2, WezTerm or kitty
57
+ dopememe url drake-hotline "Writing SDKs by hand" "Generating them" # /g/, no request
58
+ dopememe save drake-hotline "Writing SDKs by hand" "Generating them"
59
+ dopememe login # save an API key
60
+ ```
61
+
62
+ Texts fill the template's text slots in reading order; `--slot name=text` names them.
63
+ `--recipe file.json` (or `-` for stdin) takes a whole recipe. `--json` prints what the
64
+ API returned, for scripts and agents.
@@ -0,0 +1,33 @@
1
+ [project]
2
+ name = "dopememe"
3
+ version = "0.1.0a1"
4
+ description = "dope.meme for Python, and the dopememe CLI: find meme templates, fill them, get images and permanent URLs."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ dependencies = ["httpx2>=2.16.0"]
8
+ keywords = [
9
+ "memes",
10
+ "images",
11
+ "api",
12
+ "sdk",
13
+ "cli",
14
+ ]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Typing :: Typed",
18
+ ]
19
+
20
+ [[project.authors]]
21
+ name = "Patrick Higgins"
22
+ email = "patrick@nicena.me"
23
+
24
+ [project.urls]
25
+ Homepage = "https://dope.meme"
26
+ Documentation = "https://docs.dope.meme"
27
+
28
+ [project.scripts]
29
+ dopememe = "dopememe.cli:main"
30
+
31
+ [build-system]
32
+ requires = ["uv_build>=0.13.5,<0.14.0"]
33
+ build-backend = "uv_build"
@@ -0,0 +1,24 @@
1
+ [project]
2
+ name = "dopememe"
3
+ version = "0.1.0a1"
4
+ description = "dope.meme for Python, and the dopememe CLI: find meme templates, fill them, get images and permanent URLs."
5
+ readme = "README.md"
6
+ authors = [{ name = "Patrick Higgins", email = "patrick@nicena.me" }]
7
+ requires-python = ">=3.11"
8
+ dependencies = ["httpx2>=2.16.0"]
9
+ keywords = ["memes", "images", "api", "sdk", "cli"]
10
+ classifiers = [
11
+ "Programming Language :: Python :: 3",
12
+ "Typing :: Typed",
13
+ ]
14
+
15
+ [project.urls]
16
+ Homepage = "https://dope.meme"
17
+ Documentation = "https://docs.dope.meme"
18
+
19
+ [project.scripts]
20
+ dopememe = "dopememe.cli:main"
21
+
22
+ [build-system]
23
+ requires = ["uv_build>=0.13.5,<0.14.0"]
24
+ build-backend = "uv_build"
@@ -0,0 +1,116 @@
1
+ """dope.meme for Python: find meme templates, fill them, and get images and permanent URLs.
2
+
3
+ from dopememe import Client
4
+
5
+ client = Client()
6
+ render = client.render_meme(
7
+ {"template": "drake-hotline", "slots": {"reject": "Writing SDKs", "prefer": "Generating them"}}
8
+ )
9
+ print(render.url, render.alt)
10
+ """
11
+
12
+ from dopememe._client import DEFAULT_BASE_URL, DEFAULT_IMAGE_BASE_URL, AsyncClient, Client
13
+ from dopememe._operations import API_VERSION, ERROR_CODES
14
+ from dopememe._version import __version__
15
+ from dopememe.errors import (
16
+ APIConnectionError,
17
+ APIError,
18
+ APITimeoutError,
19
+ DopememeError,
20
+ RateLimitError,
21
+ )
22
+ from dopememe.images import RenderedImage
23
+
24
+ # generated: begin
25
+ from dopememe.types import (
26
+ Anchor,
27
+ ApiKey,
28
+ ApiKeyList,
29
+ AssetFill,
30
+ At,
31
+ CreateKey,
32
+ Example,
33
+ Facet,
34
+ Fill,
35
+ Image,
36
+ Instance,
37
+ Item,
38
+ ItemList,
39
+ ItemSummary,
40
+ Me,
41
+ Meme,
42
+ MemeList,
43
+ NewApiKey,
44
+ Number,
45
+ Output,
46
+ Placement,
47
+ PointAt,
48
+ Problem,
49
+ ProblemDetail,
50
+ Recipe,
51
+ Render,
52
+ SaveMeme,
53
+ Slot,
54
+ SlotSummary,
55
+ StyleOverride,
56
+ TagValue,
57
+ TextFill,
58
+ Version,
59
+ VersionList,
60
+ Vocabulary,
61
+ )
62
+
63
+ # generated: end
64
+
65
+ __all__ = [
66
+ "API_VERSION",
67
+ "DEFAULT_BASE_URL",
68
+ "DEFAULT_IMAGE_BASE_URL",
69
+ "ERROR_CODES",
70
+ "APIConnectionError",
71
+ "APIError",
72
+ "APITimeoutError",
73
+ "AsyncClient",
74
+ "Client",
75
+ "DopememeError",
76
+ "RateLimitError",
77
+ "RenderedImage",
78
+ "__version__",
79
+ # generated: begin
80
+ "Anchor",
81
+ "ApiKey",
82
+ "ApiKeyList",
83
+ "AssetFill",
84
+ "At",
85
+ "CreateKey",
86
+ "Example",
87
+ "Facet",
88
+ "Fill",
89
+ "Image",
90
+ "Instance",
91
+ "Item",
92
+ "ItemList",
93
+ "ItemSummary",
94
+ "Me",
95
+ "Meme",
96
+ "MemeList",
97
+ "NewApiKey",
98
+ "Number",
99
+ "Output",
100
+ "Placement",
101
+ "PointAt",
102
+ "Problem",
103
+ "ProblemDetail",
104
+ "Recipe",
105
+ "Render",
106
+ "SaveMeme",
107
+ "Slot",
108
+ "SlotSummary",
109
+ "StyleOverride",
110
+ "TagValue",
111
+ "TextFill",
112
+ "Version",
113
+ "VersionList",
114
+ "Vocabulary",
115
+ # generated: end
116
+ ]
@@ -0,0 +1,363 @@
1
+ """The HTTP clients: configuration, retries, errors and images. The operations themselves
2
+ are generated (`_operations.py`)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ import os
8
+ import random
9
+ import time
10
+ from collections.abc import Iterable, Mapping
11
+ from types import TracebackType
12
+ from typing import Any, Self
13
+
14
+ import httpx2
15
+
16
+ from dopememe._operations import AsyncOperations, SyncOperations
17
+ from dopememe._version import __version__
18
+ from dopememe.errors import APIConnectionError, APITimeoutError, error_for
19
+ from dopememe.images import EXTENSIONS, Format, RenderedImage, Size, embed_path, image_query
20
+ from dopememe.types import Recipe
21
+
22
+ DEFAULT_BASE_URL = "https://api.dope.meme"
23
+ DEFAULT_IMAGE_BASE_URL = "https://i.dope.meme"
24
+ # Statuses worth another try: over a limit, or a server or proxy that's briefly unwell.
25
+ RETRYABLE = {429, 502, 503, 504}
26
+ # Longer than this, and a 429 is raised rather than waited out.
27
+ MAX_RETRY_AFTER = 60.0
28
+
29
+
30
+ class _Config:
31
+ def __init__(
32
+ self,
33
+ api_key: str | None,
34
+ base_url: str | None,
35
+ image_base_url: str | None,
36
+ max_retries: int,
37
+ ) -> None:
38
+ self.api_key = api_key if api_key is not None else os.environ.get("DOPEMEME_API_KEY")
39
+ base = base_url or os.environ.get("DOPEMEME_BASE_URL")
40
+ self.base_url = (base or DEFAULT_BASE_URL).rstrip("/")
41
+ # Images have their own host in production; elsewhere the API serves them too.
42
+ image_base = image_base_url or os.environ.get("DOPEMEME_IMAGE_BASE_URL")
43
+ self.image_base_url = (image_base or (base or DEFAULT_IMAGE_BASE_URL)).rstrip("/")
44
+ self.max_retries = max_retries
45
+ self.headers = {"User-Agent": f"dopememe-python/{__version__}"}
46
+
47
+ def request_args(
48
+ self, method: str, path: str, query: Mapping[str, Any] | None, body: Any, accept: str
49
+ ) -> dict[str, Any]:
50
+ url = path if "://" in path else self.base_url + path
51
+ headers = {**self.headers, "Accept": accept}
52
+ # Only the API sees the key: image URLs are public, and may be another host's.
53
+ if self.api_key and url.startswith(self.base_url + "/v1/"):
54
+ headers["Authorization"] = f"Bearer {self.api_key}"
55
+ args: dict[str, Any] = {"method": method, "url": url, "headers": headers}
56
+ if query:
57
+ args["params"] = {k: v for k, v in query.items() if v is not None}
58
+ if body is not None:
59
+ # Commands (create_meme's fields) arrive with every argument; send the ones set.
60
+ args["json"] = (
61
+ {k: v for k, v in body.items() if v is not None} if isinstance(body, dict) else body
62
+ )
63
+ return args
64
+
65
+ def retry_delay(
66
+ self, attempt: int, status: int | None, headers: Mapping[str, str], idempotent: bool
67
+ ) -> float | None:
68
+ """Seconds to wait before trying again, or None to give up."""
69
+ if attempt >= self.max_retries:
70
+ return None
71
+ if status is not None and status not in RETRYABLE:
72
+ return None
73
+ # A 429 was refused before it did anything; anything else only retries if it's safe.
74
+ if status != 429 and not idempotent:
75
+ return None
76
+ after = headers.get("retry-after")
77
+ if after and after.isdigit():
78
+ return float(after) if float(after) <= MAX_RETRY_AFTER else None
79
+ return min(0.5 * 2**attempt, 8.0) * random.uniform(0.75, 1.25)
80
+
81
+
82
+ class Client(SyncOperations):
83
+ """dope.meme, synchronously.
84
+
85
+ The API key comes from `api_key` or `DOPEMEME_API_KEY`, and is optional: anonymous
86
+ callers can search, render and save unlisted memes, with lower rate limits.
87
+ """
88
+
89
+ def __init__(
90
+ self,
91
+ *,
92
+ api_key: str | None = None,
93
+ base_url: str | None = None,
94
+ image_base_url: str | None = None,
95
+ timeout: float = 60.0,
96
+ max_retries: int = 2,
97
+ http_client: httpx2.Client | None = None,
98
+ ) -> None:
99
+ self._config = _Config(api_key, base_url, image_base_url, max_retries)
100
+ self._http = http_client or httpx2.Client(timeout=timeout)
101
+
102
+ @property
103
+ def base_url(self) -> str:
104
+ return self._config.base_url
105
+
106
+ @property
107
+ def image_base_url(self) -> str:
108
+ return self._config.image_base_url
109
+
110
+ def close(self) -> None:
111
+ self._http.close()
112
+
113
+ def __enter__(self) -> Self:
114
+ return self
115
+
116
+ def __exit__(
117
+ self,
118
+ exc_type: type[BaseException] | None,
119
+ exc: BaseException | None,
120
+ tb: TracebackType | None,
121
+ ) -> None:
122
+ self.close()
123
+
124
+ def _send(
125
+ self,
126
+ method: str,
127
+ path: str,
128
+ *,
129
+ query: Mapping[str, Any] | None = None,
130
+ body: Any = None,
131
+ idempotent: bool = True,
132
+ accept: str = "application/json",
133
+ ) -> httpx2.Response:
134
+ args = self._config.request_args(method, path, query, body, accept)
135
+ attempt = 0
136
+ while True:
137
+ try:
138
+ response = self._http.request(**args)
139
+ except httpx2.TransportError as error:
140
+ delay = self._config.retry_delay(attempt, None, {}, idempotent)
141
+ if delay is None:
142
+ kind = (
143
+ APITimeoutError
144
+ if isinstance(error, httpx2.TimeoutException)
145
+ else APIConnectionError
146
+ )
147
+ raise kind(str(error) or type(error).__name__) from error
148
+ else:
149
+ if response.is_success:
150
+ return response
151
+ delay = self._config.retry_delay(
152
+ attempt, response.status_code, response.headers, idempotent
153
+ )
154
+ if delay is None:
155
+ raise error_for(response.status_code, response.content, response.headers)
156
+ time.sleep(delay)
157
+ attempt += 1
158
+
159
+ def _request(
160
+ self,
161
+ method: str,
162
+ path: str,
163
+ *,
164
+ query: dict[str, Any] | None = None,
165
+ body: Any = None,
166
+ idempotent: bool = True,
167
+ ) -> Any:
168
+ response = self._send(method, path, query=query, body=body, idempotent=idempotent)
169
+ return response.json() if response.content else None
170
+
171
+ def render_image(self, recipe: Recipe) -> RenderedImage:
172
+ """Render a meme and get the image itself, in the recipe's `output` format.
173
+
174
+ Its permanent URL, identity and alt text come with it. Nothing is stored."""
175
+ response = self._send("POST", "/v1/render", body=recipe, accept="image/*")
176
+ return RenderedImage._from_response(response.content, response.headers)
177
+
178
+ def embed_url(
179
+ self,
180
+ template: str,
181
+ texts: Iterable[str] = (),
182
+ *,
183
+ format: Format = "png",
184
+ size: Size | None = None,
185
+ width: int | None = None,
186
+ height: int | None = None,
187
+ ) -> str:
188
+ """An embeddable `/g/` URL, built here without a request: texts fill the
189
+ template's text slots in reading order. Unpinned templates follow their latest
190
+ version; pin one (`drake-hotline@1`) for an image that never changes."""
191
+ path = embed_path(template, texts, format=format, size=size, width=width, height=height)
192
+ return self.image_base_url + path
193
+
194
+ def fetch_image(self, url: str) -> RenderedImage:
195
+ """Any dope.meme image URL (`/i/…` or `/g/…`), as bytes."""
196
+ response = self._send("GET", url, accept="image/*")
197
+ return RenderedImage._from_response(response.content, response.headers)
198
+
199
+ def generate_image(
200
+ self,
201
+ template: str,
202
+ texts: Iterable[str] = (),
203
+ *,
204
+ format: Format = "png",
205
+ size: Size | None = None,
206
+ width: int | None = None,
207
+ height: int | None = None,
208
+ ) -> RenderedImage:
209
+ """The image at `embed_url(template, texts, …)`."""
210
+ return self.fetch_image(
211
+ self.embed_url(template, texts, format=format, size=size, width=width, height=height)
212
+ )
213
+
214
+ def retrieve_image(
215
+ self,
216
+ id: str,
217
+ *,
218
+ format: Format = "png",
219
+ size: Size | None = None,
220
+ width: int | None = None,
221
+ height: int | None = None,
222
+ ) -> RenderedImage:
223
+ """A permanent image by its token (`r1.…`) or saved meme ID (`m_…`)."""
224
+ query = image_query(size, width, height)
225
+ return self.fetch_image(f"{self.image_base_url}/i/{id}.{EXTENSIONS[format]}{query}")
226
+
227
+
228
+ class AsyncClient(AsyncOperations):
229
+ """dope.meme, with `async`/`await`. Everything `Client` does, awaited."""
230
+
231
+ def __init__(
232
+ self,
233
+ *,
234
+ api_key: str | None = None,
235
+ base_url: str | None = None,
236
+ image_base_url: str | None = None,
237
+ timeout: float = 60.0,
238
+ max_retries: int = 2,
239
+ http_client: httpx2.AsyncClient | None = None,
240
+ ) -> None:
241
+ self._config = _Config(api_key, base_url, image_base_url, max_retries)
242
+ self._http = http_client or httpx2.AsyncClient(timeout=timeout)
243
+
244
+ @property
245
+ def base_url(self) -> str:
246
+ return self._config.base_url
247
+
248
+ @property
249
+ def image_base_url(self) -> str:
250
+ return self._config.image_base_url
251
+
252
+ async def close(self) -> None:
253
+ await self._http.aclose()
254
+
255
+ async def __aenter__(self) -> Self:
256
+ return self
257
+
258
+ async def __aexit__(
259
+ self,
260
+ exc_type: type[BaseException] | None,
261
+ exc: BaseException | None,
262
+ tb: TracebackType | None,
263
+ ) -> None:
264
+ await self.close()
265
+
266
+ async def _send(
267
+ self,
268
+ method: str,
269
+ path: str,
270
+ *,
271
+ query: Mapping[str, Any] | None = None,
272
+ body: Any = None,
273
+ idempotent: bool = True,
274
+ accept: str = "application/json",
275
+ ) -> httpx2.Response:
276
+ args = self._config.request_args(method, path, query, body, accept)
277
+ attempt = 0
278
+ while True:
279
+ try:
280
+ response = await self._http.request(**args)
281
+ except httpx2.TransportError as error:
282
+ delay = self._config.retry_delay(attempt, None, {}, idempotent)
283
+ if delay is None:
284
+ kind = (
285
+ APITimeoutError
286
+ if isinstance(error, httpx2.TimeoutException)
287
+ else APIConnectionError
288
+ )
289
+ raise kind(str(error) or type(error).__name__) from error
290
+ else:
291
+ if response.is_success:
292
+ return response
293
+ delay = self._config.retry_delay(
294
+ attempt, response.status_code, response.headers, idempotent
295
+ )
296
+ if delay is None:
297
+ raise error_for(response.status_code, response.content, response.headers)
298
+ await asyncio.sleep(delay)
299
+ attempt += 1
300
+
301
+ async def _request(
302
+ self,
303
+ method: str,
304
+ path: str,
305
+ *,
306
+ query: dict[str, Any] | None = None,
307
+ body: Any = None,
308
+ idempotent: bool = True,
309
+ ) -> Any:
310
+ response = await self._send(method, path, query=query, body=body, idempotent=idempotent)
311
+ return response.json() if response.content else None
312
+
313
+ async def render_image(self, recipe: Recipe) -> RenderedImage:
314
+ """Render a meme and get the image itself. See `Client.render_image`."""
315
+ response = await self._send("POST", "/v1/render", body=recipe, accept="image/*")
316
+ return RenderedImage._from_response(response.content, response.headers)
317
+
318
+ def embed_url(
319
+ self,
320
+ template: str,
321
+ texts: Iterable[str] = (),
322
+ *,
323
+ format: Format = "png",
324
+ size: Size | None = None,
325
+ width: int | None = None,
326
+ height: int | None = None,
327
+ ) -> str:
328
+ """An embeddable `/g/` URL. See `Client.embed_url`."""
329
+ path = embed_path(template, texts, format=format, size=size, width=width, height=height)
330
+ return self.image_base_url + path
331
+
332
+ async def fetch_image(self, url: str) -> RenderedImage:
333
+ """Any dope.meme image URL (`/i/…` or `/g/…`), as bytes."""
334
+ response = await self._send("GET", url, accept="image/*")
335
+ return RenderedImage._from_response(response.content, response.headers)
336
+
337
+ async def generate_image(
338
+ self,
339
+ template: str,
340
+ texts: Iterable[str] = (),
341
+ *,
342
+ format: Format = "png",
343
+ size: Size | None = None,
344
+ width: int | None = None,
345
+ height: int | None = None,
346
+ ) -> RenderedImage:
347
+ """The image at `embed_url(template, texts, …)`."""
348
+ return await self.fetch_image(
349
+ self.embed_url(template, texts, format=format, size=size, width=width, height=height)
350
+ )
351
+
352
+ async def retrieve_image(
353
+ self,
354
+ id: str,
355
+ *,
356
+ format: Format = "png",
357
+ size: Size | None = None,
358
+ width: int | None = None,
359
+ height: int | None = None,
360
+ ) -> RenderedImage:
361
+ """A permanent image by its token (`r1.…`) or saved meme ID (`m_…`)."""
362
+ query = image_query(size, width, height)
363
+ return await self.fetch_image(f"{self.image_base_url}/i/{id}.{EXTENSIONS[format]}{query}")