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.
- dopememe-0.1.0a1/PKG-INFO +79 -0
- dopememe-0.1.0a1/README.md +64 -0
- dopememe-0.1.0a1/pyproject.toml +33 -0
- dopememe-0.1.0a1/pyproject.toml.orig +24 -0
- dopememe-0.1.0a1/src/dopememe/__init__.py +116 -0
- dopememe-0.1.0a1/src/dopememe/_client.py +363 -0
- dopememe-0.1.0a1/src/dopememe/_operations.py +567 -0
- dopememe-0.1.0a1/src/dopememe/_version.py +1 -0
- dopememe-0.1.0a1/src/dopememe/cli.py +410 -0
- dopememe-0.1.0a1/src/dopememe/errors.py +73 -0
- dopememe-0.1.0a1/src/dopememe/images.py +84 -0
- dopememe-0.1.0a1/src/dopememe/py.typed +0 -0
- dopememe-0.1.0a1/src/dopememe/types.py +635 -0
|
@@ -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}")
|