captionpack-api 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- captionpack_api-0.1.0/PKG-INFO +91 -0
- captionpack_api-0.1.0/README.md +78 -0
- captionpack_api-0.1.0/captionpack/__init__.py +35 -0
- captionpack_api-0.1.0/captionpack/client.py +259 -0
- captionpack_api-0.1.0/captionpack/exceptions.py +65 -0
- captionpack_api-0.1.0/captionpack_api.egg-info/PKG-INFO +91 -0
- captionpack_api-0.1.0/captionpack_api.egg-info/SOURCES.txt +12 -0
- captionpack_api-0.1.0/captionpack_api.egg-info/dependency_links.txt +1 -0
- captionpack_api-0.1.0/captionpack_api.egg-info/requires.txt +1 -0
- captionpack_api-0.1.0/captionpack_api.egg-info/top_level.txt +1 -0
- captionpack_api-0.1.0/pyproject.toml +22 -0
- captionpack_api-0.1.0/setup.cfg +4 -0
- captionpack_api-0.1.0/tests/test_errors.py +168 -0
- captionpack_api-0.1.0/tests/test_live.py +108 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: captionpack-api
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the Caption-Pack API — social captions as a service
|
|
5
|
+
Author: Jeshua Domingo
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://muse.ai/s/caption-pack-api-xfxt62ya0xcxlxhh
|
|
8
|
+
Project-URL: Repository, https://github.com/jeshuadomingo-byte/caption-pack-api
|
|
9
|
+
Keywords: captions,social-media,api-client,ai-agents
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: requests>=2.28
|
|
13
|
+
|
|
14
|
+
# captionpack — Python client for the Caption-Pack API
|
|
15
|
+
|
|
16
|
+
Turn a topic, audience, and tone into ready-to-post social captions + hashtags, as JSON. Built for developers and AI agents.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install captionpack
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
(Get your API key at the [Caption-Pack site](https://muse.ai/s/caption-pack-api-xfxt62ya0xcxlxhh) — $10 for 500 calls.)
|
|
25
|
+
|
|
26
|
+
## Quickstart
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from captionpack import Client
|
|
30
|
+
|
|
31
|
+
client = Client(api_key="cp_live_...")
|
|
32
|
+
pack = client.caption_pack(topic="morning routines", audience="busy parents")
|
|
33
|
+
print(pack.captions[0].body, pack.hashtags)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
You can also set the `CAPTIONPACK_API_KEY` environment variable instead of passing `api_key=`.
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from captionpack import (
|
|
42
|
+
Client,
|
|
43
|
+
AuthenticationError,
|
|
44
|
+
InsufficientCreditsError,
|
|
45
|
+
RateLimitedError,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
client = Client() # reads CAPTIONPACK_API_KEY
|
|
49
|
+
|
|
50
|
+
# Generate captions (1 credit per call)
|
|
51
|
+
pack = client.caption_pack(
|
|
52
|
+
topic="password managers",
|
|
53
|
+
audience="small business owners",
|
|
54
|
+
tone="warm", # warm | bold | professional | playful
|
|
55
|
+
platform="linkedin", # instagram | linkedin | x | tiktok
|
|
56
|
+
count=5, # 1-10
|
|
57
|
+
include_hashtags=True,
|
|
58
|
+
)
|
|
59
|
+
for caption in pack.captions:
|
|
60
|
+
print(caption.hook, "|", caption.body, "|", caption.cta)
|
|
61
|
+
print(pack.hashtags)
|
|
62
|
+
print("credits left:", pack.credits_remaining) # from X-Credits-Remaining
|
|
63
|
+
|
|
64
|
+
# Check balance
|
|
65
|
+
balance = client.balance()
|
|
66
|
+
print(balance.credits)
|
|
67
|
+
|
|
68
|
+
# Handle errors explicitly
|
|
69
|
+
try:
|
|
70
|
+
pack = client.caption_pack(topic="x", audience="y")
|
|
71
|
+
except InsufficientCreditsError as e:
|
|
72
|
+
print("Out of credits — top up:", e.top_up_url)
|
|
73
|
+
except RateLimitedError:
|
|
74
|
+
print("Slow down — 60 requests/minute per key.")
|
|
75
|
+
except AuthenticationError:
|
|
76
|
+
print("Bad API key.")
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Behavior notes
|
|
80
|
+
|
|
81
|
+
- One automatic retry on HTTP 429 (with backoff, honoring `Retry-After`).
|
|
82
|
+
- `X-Credits-Remaining` is surfaced on every `caption_pack` result.
|
|
83
|
+
- Point at a local/dev server with `Client(api_key=..., base_url="http://localhost:8000")`.
|
|
84
|
+
|
|
85
|
+
## Development
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
pip install -e ".[dev]" # or: pip install -e . && pip install pytest
|
|
89
|
+
# integration tests need the API source; set CAPTIONPACK_TEST_API_DIR
|
|
90
|
+
CAPTIONPACK_TEST_API_DIR=/path/to/caption-pack-api pytest
|
|
91
|
+
```
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# captionpack — Python client for the Caption-Pack API
|
|
2
|
+
|
|
3
|
+
Turn a topic, audience, and tone into ready-to-post social captions + hashtags, as JSON. Built for developers and AI agents.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install captionpack
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
(Get your API key at the [Caption-Pack site](https://muse.ai/s/caption-pack-api-xfxt62ya0xcxlxhh) — $10 for 500 calls.)
|
|
12
|
+
|
|
13
|
+
## Quickstart
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from captionpack import Client
|
|
17
|
+
|
|
18
|
+
client = Client(api_key="cp_live_...")
|
|
19
|
+
pack = client.caption_pack(topic="morning routines", audience="busy parents")
|
|
20
|
+
print(pack.captions[0].body, pack.hashtags)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
You can also set the `CAPTIONPACK_API_KEY` environment variable instead of passing `api_key=`.
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from captionpack import (
|
|
29
|
+
Client,
|
|
30
|
+
AuthenticationError,
|
|
31
|
+
InsufficientCreditsError,
|
|
32
|
+
RateLimitedError,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
client = Client() # reads CAPTIONPACK_API_KEY
|
|
36
|
+
|
|
37
|
+
# Generate captions (1 credit per call)
|
|
38
|
+
pack = client.caption_pack(
|
|
39
|
+
topic="password managers",
|
|
40
|
+
audience="small business owners",
|
|
41
|
+
tone="warm", # warm | bold | professional | playful
|
|
42
|
+
platform="linkedin", # instagram | linkedin | x | tiktok
|
|
43
|
+
count=5, # 1-10
|
|
44
|
+
include_hashtags=True,
|
|
45
|
+
)
|
|
46
|
+
for caption in pack.captions:
|
|
47
|
+
print(caption.hook, "|", caption.body, "|", caption.cta)
|
|
48
|
+
print(pack.hashtags)
|
|
49
|
+
print("credits left:", pack.credits_remaining) # from X-Credits-Remaining
|
|
50
|
+
|
|
51
|
+
# Check balance
|
|
52
|
+
balance = client.balance()
|
|
53
|
+
print(balance.credits)
|
|
54
|
+
|
|
55
|
+
# Handle errors explicitly
|
|
56
|
+
try:
|
|
57
|
+
pack = client.caption_pack(topic="x", audience="y")
|
|
58
|
+
except InsufficientCreditsError as e:
|
|
59
|
+
print("Out of credits — top up:", e.top_up_url)
|
|
60
|
+
except RateLimitedError:
|
|
61
|
+
print("Slow down — 60 requests/minute per key.")
|
|
62
|
+
except AuthenticationError:
|
|
63
|
+
print("Bad API key.")
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Behavior notes
|
|
67
|
+
|
|
68
|
+
- One automatic retry on HTTP 429 (with backoff, honoring `Retry-After`).
|
|
69
|
+
- `X-Credits-Remaining` is surfaced on every `caption_pack` result.
|
|
70
|
+
- Point at a local/dev server with `Client(api_key=..., base_url="http://localhost:8000")`.
|
|
71
|
+
|
|
72
|
+
## Development
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
pip install -e ".[dev]" # or: pip install -e . && pip install pytest
|
|
76
|
+
# integration tests need the API source; set CAPTIONPACK_TEST_API_DIR
|
|
77
|
+
CAPTIONPACK_TEST_API_DIR=/path/to/caption-pack-api pytest
|
|
78
|
+
```
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""captionpack — Python client for the Caption-Pack API."""
|
|
2
|
+
|
|
3
|
+
from .client import (
|
|
4
|
+
API_KEY_ENV_VAR,
|
|
5
|
+
DEFAULT_BASE_URL,
|
|
6
|
+
BalanceResult,
|
|
7
|
+
Caption,
|
|
8
|
+
CaptionPackResult,
|
|
9
|
+
Client,
|
|
10
|
+
)
|
|
11
|
+
from .exceptions import (
|
|
12
|
+
AuthenticationError,
|
|
13
|
+
CaptionPackError,
|
|
14
|
+
InsufficientCreditsError,
|
|
15
|
+
InvalidParamsError,
|
|
16
|
+
RateLimitedError,
|
|
17
|
+
ServerError,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"API_KEY_ENV_VAR",
|
|
22
|
+
"DEFAULT_BASE_URL",
|
|
23
|
+
"BalanceResult",
|
|
24
|
+
"Caption",
|
|
25
|
+
"CaptionPackResult",
|
|
26
|
+
"Client",
|
|
27
|
+
"AuthenticationError",
|
|
28
|
+
"CaptionPackError",
|
|
29
|
+
"InsufficientCreditsError",
|
|
30
|
+
"InvalidParamsError",
|
|
31
|
+
"RateLimitedError",
|
|
32
|
+
"ServerError",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Synchronous client for the Caption-Pack API.
|
|
2
|
+
|
|
3
|
+
Quickstart::
|
|
4
|
+
|
|
5
|
+
from captionpack import Client
|
|
6
|
+
|
|
7
|
+
client = Client(api_key="cp_live_...")
|
|
8
|
+
pack = client.caption_pack(topic="morning routines", audience="busy parents")
|
|
9
|
+
print(pack.captions[0].body, pack.hashtags)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
|
|
18
|
+
import requests
|
|
19
|
+
|
|
20
|
+
from .exceptions import (
|
|
21
|
+
AuthenticationError,
|
|
22
|
+
CaptionPackError,
|
|
23
|
+
InsufficientCreditsError,
|
|
24
|
+
InvalidParamsError,
|
|
25
|
+
RateLimitedError,
|
|
26
|
+
ServerError,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
DEFAULT_BASE_URL = "https://caption-pack-api.onrender.com"
|
|
30
|
+
API_KEY_ENV_VAR = "CAPTIONPACK_API_KEY"
|
|
31
|
+
|
|
32
|
+
# One retry on 429 only; everything else fails fast so callers see real errors.
|
|
33
|
+
_MAX_429_RETRIES = 1
|
|
34
|
+
_DEFAULT_BACKOFF_SECONDS = 1.0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Caption:
|
|
39
|
+
"""A single generated caption: hook + body + call to action."""
|
|
40
|
+
|
|
41
|
+
hook: str
|
|
42
|
+
body: str
|
|
43
|
+
cta: str
|
|
44
|
+
hashtags: list[str] = field(default_factory=list)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class CaptionPackResult:
|
|
49
|
+
"""Result of :meth:`Client.caption_pack`."""
|
|
50
|
+
|
|
51
|
+
captions: list[Caption] = field(default_factory=list)
|
|
52
|
+
hashtags: list[str] = field(default_factory=list)
|
|
53
|
+
credits_used: int = 1
|
|
54
|
+
credits_remaining: int | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class BalanceResult:
|
|
59
|
+
"""Result of :meth:`Client.balance`."""
|
|
60
|
+
|
|
61
|
+
credits: int
|
|
62
|
+
key_id: int | None = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _error_from_response(resp: requests.Response) -> CaptionPackError:
|
|
66
|
+
"""Map an HTTP error response to the matching typed exception."""
|
|
67
|
+
code: str | None = None
|
|
68
|
+
message = f"Caption-Pack API request failed (HTTP {resp.status_code})."
|
|
69
|
+
top_up_url: str | None = None
|
|
70
|
+
try:
|
|
71
|
+
payload = resp.json()
|
|
72
|
+
err = payload.get("error", {}) if isinstance(payload, dict) else {}
|
|
73
|
+
code = err.get("code")
|
|
74
|
+
if err.get("message"):
|
|
75
|
+
message = err["message"]
|
|
76
|
+
top_up_url = err.get("top_up_url")
|
|
77
|
+
except ValueError:
|
|
78
|
+
pass # non-JSON body: keep the generic message
|
|
79
|
+
|
|
80
|
+
status = resp.status_code
|
|
81
|
+
if status == 400:
|
|
82
|
+
return InvalidParamsError(message, code=code, status_code=status)
|
|
83
|
+
if status == 401:
|
|
84
|
+
return AuthenticationError(message, code=code, status_code=status)
|
|
85
|
+
if status == 402:
|
|
86
|
+
return InsufficientCreditsError(
|
|
87
|
+
message, code=code, status_code=status, top_up_url=top_up_url
|
|
88
|
+
)
|
|
89
|
+
if status == 429:
|
|
90
|
+
return RateLimitedError(message, code=code, status_code=status)
|
|
91
|
+
if 500 <= status < 600:
|
|
92
|
+
return ServerError(message, code=code, status_code=status)
|
|
93
|
+
return CaptionPackError(message, code=code, status_code=status)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Client:
|
|
97
|
+
"""Client for the Caption-Pack API.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
api_key: Your ``cp_live_...`` key. Falls back to the
|
|
101
|
+
``CAPTIONPACK_API_KEY`` environment variable. Required.
|
|
102
|
+
base_url: API base URL. Defaults to the production service.
|
|
103
|
+
timeout: Seconds to wait for a response before giving up.
|
|
104
|
+
user_agent: Optional custom User-Agent suffix for your app/agent.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def __init__(
|
|
108
|
+
self,
|
|
109
|
+
api_key: str | None = None,
|
|
110
|
+
*,
|
|
111
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
112
|
+
timeout: float = 30.0,
|
|
113
|
+
user_agent: str | None = None,
|
|
114
|
+
) -> None:
|
|
115
|
+
key = api_key or os.environ.get(API_KEY_ENV_VAR)
|
|
116
|
+
if not key:
|
|
117
|
+
raise ValueError(
|
|
118
|
+
"An API key is required: pass api_key=... or set the "
|
|
119
|
+
f"{API_KEY_ENV_VAR} environment variable."
|
|
120
|
+
)
|
|
121
|
+
self.api_key = key
|
|
122
|
+
self.base_url = base_url.rstrip("/")
|
|
123
|
+
self.timeout = timeout
|
|
124
|
+
self.session = requests.Session()
|
|
125
|
+
ua = "captionpack-python/0.1.0"
|
|
126
|
+
if user_agent:
|
|
127
|
+
ua = f"{ua} {user_agent}"
|
|
128
|
+
self.session.headers.update(
|
|
129
|
+
{
|
|
130
|
+
"Authorization": f"Bearer {key}",
|
|
131
|
+
"Content-Type": "application/json",
|
|
132
|
+
"User-Agent": ua,
|
|
133
|
+
}
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# -- internals ----------------------------------------------------
|
|
137
|
+
|
|
138
|
+
def _request(
|
|
139
|
+
self, method: str, path: str, *, json: dict | None = None
|
|
140
|
+
) -> tuple[dict, requests.Response]:
|
|
141
|
+
"""Send a request; retry once on 429, then raise typed errors."""
|
|
142
|
+
url = f"{self.base_url}{path}"
|
|
143
|
+
attempts = 0
|
|
144
|
+
while True:
|
|
145
|
+
try:
|
|
146
|
+
resp = self.session.request(
|
|
147
|
+
method, url, json=json, timeout=self.timeout
|
|
148
|
+
)
|
|
149
|
+
except requests.RequestException as exc:
|
|
150
|
+
raise CaptionPackError(f"Could not reach {url}: {exc}") from exc
|
|
151
|
+
|
|
152
|
+
if resp.status_code == 429 and attempts < _MAX_429_RETRIES:
|
|
153
|
+
attempts += 1
|
|
154
|
+
wait = self._retry_wait_seconds(resp)
|
|
155
|
+
time.sleep(wait)
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
if resp.status_code >= 400:
|
|
159
|
+
raise _error_from_response(resp)
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
return resp.json(), resp
|
|
163
|
+
except ValueError as exc:
|
|
164
|
+
raise CaptionPackError(
|
|
165
|
+
f"API returned a non-JSON response (HTTP {resp.status_code}).",
|
|
166
|
+
status_code=resp.status_code,
|
|
167
|
+
) from exc
|
|
168
|
+
|
|
169
|
+
@staticmethod
|
|
170
|
+
def _retry_wait_seconds(resp: requests.Response) -> float:
|
|
171
|
+
retry_after = resp.headers.get("Retry-After")
|
|
172
|
+
if retry_after:
|
|
173
|
+
try:
|
|
174
|
+
return max(0.0, float(retry_after))
|
|
175
|
+
except ValueError:
|
|
176
|
+
pass
|
|
177
|
+
return _DEFAULT_BACKOFF_SECONDS
|
|
178
|
+
|
|
179
|
+
# -- public API ---------------------------------------------------
|
|
180
|
+
|
|
181
|
+
def caption_pack(
|
|
182
|
+
self,
|
|
183
|
+
topic: str,
|
|
184
|
+
audience: str,
|
|
185
|
+
*,
|
|
186
|
+
tone: str = "warm",
|
|
187
|
+
platform: str = "instagram",
|
|
188
|
+
count: int = 5,
|
|
189
|
+
include_hashtags: bool = True,
|
|
190
|
+
niche: str | None = None,
|
|
191
|
+
) -> CaptionPackResult:
|
|
192
|
+
"""Generate a pack of captions + hashtags.
|
|
193
|
+
|
|
194
|
+
Costs 1 credit per call. ``X-Credits-Remaining`` from the API is
|
|
195
|
+
surfaced on the result so callers always know their balance.
|
|
196
|
+
"""
|
|
197
|
+
body: dict = {
|
|
198
|
+
"topic": topic,
|
|
199
|
+
"audience": audience,
|
|
200
|
+
"tone": tone,
|
|
201
|
+
"platform": platform,
|
|
202
|
+
"count": count,
|
|
203
|
+
"include_hashtags": include_hashtags,
|
|
204
|
+
}
|
|
205
|
+
if niche is not None:
|
|
206
|
+
body["niche"] = niche
|
|
207
|
+
|
|
208
|
+
payload, resp = self._request("POST", "/v1/caption-pack", json=body)
|
|
209
|
+
|
|
210
|
+
captions = []
|
|
211
|
+
seen_tags: list[str] = []
|
|
212
|
+
for c in payload.get("captions", []):
|
|
213
|
+
tags = list(c.get("hashtags", []))
|
|
214
|
+
for tag in tags:
|
|
215
|
+
if tag not in seen_tags:
|
|
216
|
+
seen_tags.append(tag)
|
|
217
|
+
captions.append(
|
|
218
|
+
Caption(
|
|
219
|
+
hook=c.get("hook", ""),
|
|
220
|
+
body=c.get("body", ""),
|
|
221
|
+
cta=c.get("cta", ""),
|
|
222
|
+
hashtags=tags,
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
# The API may return hashtags per-caption rather than top-level;
|
|
226
|
+
# aggregate a de-duplicated list so callers get one simple field.
|
|
227
|
+
top_level_tags = payload.get("hashtags")
|
|
228
|
+
hashtags = (
|
|
229
|
+
list(top_level_tags) if isinstance(top_level_tags, list) else seen_tags
|
|
230
|
+
)
|
|
231
|
+
remaining = payload.get("credits_remaining")
|
|
232
|
+
if remaining is None:
|
|
233
|
+
header_val = resp.headers.get("X-Credits-Remaining")
|
|
234
|
+
remaining = int(header_val) if header_val is not None else None
|
|
235
|
+
|
|
236
|
+
return CaptionPackResult(
|
|
237
|
+
captions=captions,
|
|
238
|
+
hashtags=hashtags,
|
|
239
|
+
credits_used=int(payload.get("credits_used", 1)),
|
|
240
|
+
credits_remaining=remaining,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
def balance(self) -> BalanceResult:
|
|
244
|
+
"""Return the remaining credit balance for this API key."""
|
|
245
|
+
payload, _ = self._request("GET", "/v1/balance")
|
|
246
|
+
return BalanceResult(
|
|
247
|
+
credits=int(payload.get("credits", 0)),
|
|
248
|
+
key_id=payload.get("key_id"),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
def close(self) -> None:
|
|
252
|
+
"""Close the underlying HTTP session."""
|
|
253
|
+
self.session.close()
|
|
254
|
+
|
|
255
|
+
def __enter__(self) -> "Client": # pragma: no cover - convenience
|
|
256
|
+
return self
|
|
257
|
+
|
|
258
|
+
def __exit__(self, *exc: object) -> None: # pragma: no cover - convenience
|
|
259
|
+
self.close()
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Typed exceptions for the Caption-Pack API client.
|
|
2
|
+
|
|
3
|
+
The API returns errors as JSON envelopes like::
|
|
4
|
+
|
|
5
|
+
{"error": {"code": "invalid_key", "message": "API key is missing or invalid."}}
|
|
6
|
+
|
|
7
|
+
with the HTTP status carrying the error class. This module maps those
|
|
8
|
+
statuses to typed exceptions so callers can handle each case explicitly.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CaptionPackError(Exception):
|
|
15
|
+
"""Base error for all Caption-Pack client failures."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
message: str,
|
|
20
|
+
*,
|
|
21
|
+
code: str | None = None,
|
|
22
|
+
status_code: int | None = None,
|
|
23
|
+
) -> None:
|
|
24
|
+
super().__init__(message)
|
|
25
|
+
self.message = message
|
|
26
|
+
self.code = code
|
|
27
|
+
self.status_code = status_code
|
|
28
|
+
|
|
29
|
+
def __str__(self) -> str: # pragma: no cover - trivial
|
|
30
|
+
return self.message
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AuthenticationError(CaptionPackError):
|
|
34
|
+
"""HTTP 401 — the API key is missing, invalid, or revoked."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class InsufficientCreditsError(CaptionPackError):
|
|
38
|
+
"""HTTP 402 — the key is out of credits.
|
|
39
|
+
|
|
40
|
+
The API points at a top-up URL when one is configured; it is exposed
|
|
41
|
+
here when present so callers can direct the user to buy more.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
message: str,
|
|
47
|
+
*,
|
|
48
|
+
code: str | None = None,
|
|
49
|
+
status_code: int | None = None,
|
|
50
|
+
top_up_url: str | None = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
super().__init__(message, code=code, status_code=status_code)
|
|
53
|
+
self.top_up_url = top_up_url
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class RateLimitedError(CaptionPackError):
|
|
57
|
+
"""HTTP 429 — too many requests for this key. Slow down and retry."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class InvalidParamsError(CaptionPackError):
|
|
61
|
+
"""HTTP 400 — a request parameter was missing or invalid."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ServerError(CaptionPackError):
|
|
65
|
+
"""HTTP 5xx — the API had an internal problem. Safe to retry later."""
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: captionpack-api
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the Caption-Pack API — social captions as a service
|
|
5
|
+
Author: Jeshua Domingo
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://muse.ai/s/caption-pack-api-xfxt62ya0xcxlxhh
|
|
8
|
+
Project-URL: Repository, https://github.com/jeshuadomingo-byte/caption-pack-api
|
|
9
|
+
Keywords: captions,social-media,api-client,ai-agents
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: requests>=2.28
|
|
13
|
+
|
|
14
|
+
# captionpack — Python client for the Caption-Pack API
|
|
15
|
+
|
|
16
|
+
Turn a topic, audience, and tone into ready-to-post social captions + hashtags, as JSON. Built for developers and AI agents.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install captionpack
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
(Get your API key at the [Caption-Pack site](https://muse.ai/s/caption-pack-api-xfxt62ya0xcxlxhh) — $10 for 500 calls.)
|
|
25
|
+
|
|
26
|
+
## Quickstart
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from captionpack import Client
|
|
30
|
+
|
|
31
|
+
client = Client(api_key="cp_live_...")
|
|
32
|
+
pack = client.caption_pack(topic="morning routines", audience="busy parents")
|
|
33
|
+
print(pack.captions[0].body, pack.hashtags)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
You can also set the `CAPTIONPACK_API_KEY` environment variable instead of passing `api_key=`.
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from captionpack import (
|
|
42
|
+
Client,
|
|
43
|
+
AuthenticationError,
|
|
44
|
+
InsufficientCreditsError,
|
|
45
|
+
RateLimitedError,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
client = Client() # reads CAPTIONPACK_API_KEY
|
|
49
|
+
|
|
50
|
+
# Generate captions (1 credit per call)
|
|
51
|
+
pack = client.caption_pack(
|
|
52
|
+
topic="password managers",
|
|
53
|
+
audience="small business owners",
|
|
54
|
+
tone="warm", # warm | bold | professional | playful
|
|
55
|
+
platform="linkedin", # instagram | linkedin | x | tiktok
|
|
56
|
+
count=5, # 1-10
|
|
57
|
+
include_hashtags=True,
|
|
58
|
+
)
|
|
59
|
+
for caption in pack.captions:
|
|
60
|
+
print(caption.hook, "|", caption.body, "|", caption.cta)
|
|
61
|
+
print(pack.hashtags)
|
|
62
|
+
print("credits left:", pack.credits_remaining) # from X-Credits-Remaining
|
|
63
|
+
|
|
64
|
+
# Check balance
|
|
65
|
+
balance = client.balance()
|
|
66
|
+
print(balance.credits)
|
|
67
|
+
|
|
68
|
+
# Handle errors explicitly
|
|
69
|
+
try:
|
|
70
|
+
pack = client.caption_pack(topic="x", audience="y")
|
|
71
|
+
except InsufficientCreditsError as e:
|
|
72
|
+
print("Out of credits — top up:", e.top_up_url)
|
|
73
|
+
except RateLimitedError:
|
|
74
|
+
print("Slow down — 60 requests/minute per key.")
|
|
75
|
+
except AuthenticationError:
|
|
76
|
+
print("Bad API key.")
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Behavior notes
|
|
80
|
+
|
|
81
|
+
- One automatic retry on HTTP 429 (with backoff, honoring `Retry-After`).
|
|
82
|
+
- `X-Credits-Remaining` is surfaced on every `caption_pack` result.
|
|
83
|
+
- Point at a local/dev server with `Client(api_key=..., base_url="http://localhost:8000")`.
|
|
84
|
+
|
|
85
|
+
## Development
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
pip install -e ".[dev]" # or: pip install -e . && pip install pytest
|
|
89
|
+
# integration tests need the API source; set CAPTIONPACK_TEST_API_DIR
|
|
90
|
+
CAPTIONPACK_TEST_API_DIR=/path/to/caption-pack-api pytest
|
|
91
|
+
```
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
captionpack/__init__.py
|
|
4
|
+
captionpack/client.py
|
|
5
|
+
captionpack/exceptions.py
|
|
6
|
+
captionpack_api.egg-info/PKG-INFO
|
|
7
|
+
captionpack_api.egg-info/SOURCES.txt
|
|
8
|
+
captionpack_api.egg-info/dependency_links.txt
|
|
9
|
+
captionpack_api.egg-info/requires.txt
|
|
10
|
+
captionpack_api.egg-info/top_level.txt
|
|
11
|
+
tests/test_errors.py
|
|
12
|
+
tests/test_live.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
requests>=2.28
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
captionpack
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "captionpack-api"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python client for the Caption-Pack API — social captions as a service"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Jeshua Domingo" }]
|
|
13
|
+
dependencies = ["requests>=2.28"]
|
|
14
|
+
keywords = ["captions", "social-media", "api-client", "ai-agents"]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Homepage = "https://muse.ai/s/caption-pack-api-xfxt62ya0xcxlxhh"
|
|
18
|
+
Repository = "https://github.com/jeshuadomingo-byte/caption-pack-api"
|
|
19
|
+
|
|
20
|
+
[tool.setuptools.packages.find]
|
|
21
|
+
where = ["."]
|
|
22
|
+
include = ["captionpack*"]
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Unit tests for error mapping, retries, and key handling.
|
|
2
|
+
|
|
3
|
+
These tests never touch the network: they stub out
|
|
4
|
+
``requests.Session.request`` with canned responses.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
import requests
|
|
12
|
+
|
|
13
|
+
from captionpack import (
|
|
14
|
+
API_KEY_ENV_VAR,
|
|
15
|
+
AuthenticationError,
|
|
16
|
+
CaptionPackError,
|
|
17
|
+
Client,
|
|
18
|
+
InsufficientCreditsError,
|
|
19
|
+
InvalidParamsError,
|
|
20
|
+
RateLimitedError,
|
|
21
|
+
ServerError,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _fake_response(status_code, payload=None, headers=None, raw_body=None):
|
|
26
|
+
resp = requests.Response()
|
|
27
|
+
resp.status_code = status_code
|
|
28
|
+
if raw_body is not None:
|
|
29
|
+
resp._content = raw_body
|
|
30
|
+
else:
|
|
31
|
+
resp._content = json.dumps(payload or {}).encode()
|
|
32
|
+
resp.headers.update(headers or {})
|
|
33
|
+
return resp
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _client_with_stub(monkeypatch, responses, sleep_recorder=None):
|
|
37
|
+
"""Build a Client whose session.request yields the given responses."""
|
|
38
|
+
calls = {"n": 0, "sleeps": []}
|
|
39
|
+
recorder = sleep_recorder if sleep_recorder is not None else calls["sleeps"]
|
|
40
|
+
|
|
41
|
+
def fake_request(self, method, url, **kwargs):
|
|
42
|
+
idx = min(calls["n"], len(responses) - 1)
|
|
43
|
+
calls["n"] += 1
|
|
44
|
+
return responses[idx]
|
|
45
|
+
|
|
46
|
+
def fake_sleep(seconds):
|
|
47
|
+
recorder.append(seconds)
|
|
48
|
+
|
|
49
|
+
monkeypatch.setattr(requests.Session, "request", fake_request)
|
|
50
|
+
monkeypatch.setattr("captionpack.client.time.sleep", fake_sleep)
|
|
51
|
+
client = Client(api_key="cp_live_test")
|
|
52
|
+
return client, calls
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_missing_key_raises_value_error(monkeypatch):
|
|
56
|
+
monkeypatch.delenv(API_KEY_ENV_VAR, raising=False)
|
|
57
|
+
with pytest.raises(ValueError, match="API key is required"):
|
|
58
|
+
Client(api_key=None)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_key_from_env_var(monkeypatch):
|
|
62
|
+
monkeypatch.setenv(API_KEY_ENV_VAR, "cp_live_from_env")
|
|
63
|
+
monkeypatch.delenv("nope", raising=False)
|
|
64
|
+
client = Client(api_key=None)
|
|
65
|
+
assert client.session.headers["Authorization"] == "Bearer cp_live_from_env"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_401_maps_to_authentication_error(monkeypatch):
|
|
69
|
+
client, _ = _client_with_stub(
|
|
70
|
+
monkeypatch,
|
|
71
|
+
[_fake_response(401, {"error": {"code": "invalid_key", "message": "bad key"}})],
|
|
72
|
+
)
|
|
73
|
+
with pytest.raises(AuthenticationError) as exc_info:
|
|
74
|
+
client.balance()
|
|
75
|
+
assert exc_info.value.code == "invalid_key"
|
|
76
|
+
assert exc_info.value.status_code == 401
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_400_maps_to_invalid_params_error(monkeypatch):
|
|
80
|
+
client, _ = _client_with_stub(
|
|
81
|
+
monkeypatch,
|
|
82
|
+
[_fake_response(400, {"error": {"code": "invalid_params", "message": "nope"}})],
|
|
83
|
+
)
|
|
84
|
+
with pytest.raises(InvalidParamsError):
|
|
85
|
+
client.caption_pack(topic="t", audience="a", tone="bogus")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_402_maps_to_insufficient_credits_with_top_up_url(monkeypatch):
|
|
89
|
+
client, _ = _client_with_stub(
|
|
90
|
+
monkeypatch,
|
|
91
|
+
[
|
|
92
|
+
_fake_response(
|
|
93
|
+
402,
|
|
94
|
+
{
|
|
95
|
+
"error": {
|
|
96
|
+
"code": "insufficient_credits",
|
|
97
|
+
"message": "Out of credits.",
|
|
98
|
+
"top_up_url": "https://example.com/topup",
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
)
|
|
102
|
+
],
|
|
103
|
+
)
|
|
104
|
+
with pytest.raises(InsufficientCreditsError) as exc_info:
|
|
105
|
+
client.caption_pack(topic="t", audience="a")
|
|
106
|
+
assert exc_info.value.top_up_url == "https://example.com/topup"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_429_retries_once_then_succeeds(monkeypatch):
|
|
110
|
+
ok = _fake_response(200, {"credits": 7, "key_id": 1})
|
|
111
|
+
client, calls = _client_with_stub(
|
|
112
|
+
monkeypatch,
|
|
113
|
+
[_fake_response(429, {"error": {"code": "rate_limited", "message": "slow"}}), ok],
|
|
114
|
+
)
|
|
115
|
+
assert client.balance().credits == 7
|
|
116
|
+
assert calls["n"] == 2 # one retry happened
|
|
117
|
+
assert calls["sleeps"] == [1.0] # default backoff, no Retry-After header
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_429_honors_retry_after_header(monkeypatch):
|
|
121
|
+
ok = _fake_response(200, {"credits": 3, "key_id": 1})
|
|
122
|
+
client, calls = _client_with_stub(
|
|
123
|
+
monkeypatch,
|
|
124
|
+
[
|
|
125
|
+
_fake_response(
|
|
126
|
+
429, {"error": {"code": "rate_limited"}}, headers={"Retry-After": "2"}
|
|
127
|
+
),
|
|
128
|
+
ok,
|
|
129
|
+
],
|
|
130
|
+
)
|
|
131
|
+
assert client.balance().credits == 3
|
|
132
|
+
assert calls["sleeps"] == [2.0]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_429_twice_raises_rate_limited_error(monkeypatch):
|
|
136
|
+
client, calls = _client_with_stub(
|
|
137
|
+
monkeypatch,
|
|
138
|
+
[_fake_response(429, {"error": {"code": "rate_limited", "message": "slow"}})],
|
|
139
|
+
)
|
|
140
|
+
with pytest.raises(RateLimitedError):
|
|
141
|
+
client.balance()
|
|
142
|
+
assert calls["n"] == 2 # initial + exactly one retry
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_500_maps_to_server_error(monkeypatch):
|
|
146
|
+
client, _ = _client_with_stub(
|
|
147
|
+
monkeypatch, [_fake_response(500, {"error": {"code": "boom"}})]
|
|
148
|
+
)
|
|
149
|
+
with pytest.raises(ServerError):
|
|
150
|
+
client.balance()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_non_json_error_body_still_raises(monkeypatch):
|
|
154
|
+
client, _ = _client_with_stub(
|
|
155
|
+
monkeypatch, [_fake_response(401, raw_body=b"<html>nope</html>")]
|
|
156
|
+
)
|
|
157
|
+
with pytest.raises(AuthenticationError):
|
|
158
|
+
client.balance()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_connection_failure_raises_caption_pack_error(monkeypatch):
|
|
162
|
+
def boom(self, method, url, **kwargs):
|
|
163
|
+
raise requests.ConnectionError("down")
|
|
164
|
+
|
|
165
|
+
monkeypatch.setattr(requests.Session, "request", boom)
|
|
166
|
+
client = Client(api_key="cp_live_test")
|
|
167
|
+
with pytest.raises(CaptionPackError, match="Could not reach"):
|
|
168
|
+
client.balance()
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Integration tests against a local run of the real Caption-Pack API.
|
|
2
|
+
|
|
3
|
+
These tests mint keys in the LOCAL credits.db only (backed up and restored
|
|
4
|
+
by the session fixture) — they never touch production.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from captionpack import (
|
|
10
|
+
AuthenticationError,
|
|
11
|
+
Client,
|
|
12
|
+
InsufficientCreditsError,
|
|
13
|
+
InvalidParamsError,
|
|
14
|
+
RateLimitedError,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from .conftest import mint_key
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _client(base_url, key):
|
|
21
|
+
return Client(api_key=key, base_url=base_url, timeout=15)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_caption_pack_success(live_server, venv_python):
|
|
25
|
+
key = mint_key(venv_python, 10)
|
|
26
|
+
client = _client(live_server, key)
|
|
27
|
+
pack = client.caption_pack(topic="morning routines", audience="busy parents")
|
|
28
|
+
|
|
29
|
+
assert len(pack.captions) == 5 # default count
|
|
30
|
+
first = pack.captions[0]
|
|
31
|
+
assert first.hook and first.body and first.cta
|
|
32
|
+
assert pack.hashtags, "expected hashtags by default"
|
|
33
|
+
assert first.hashtags, "expected per-caption hashtags"
|
|
34
|
+
assert pack.hashtags[0] in first.hashtags # aggregated from captions
|
|
35
|
+
assert pack.credits_used == 1
|
|
36
|
+
assert pack.credits_remaining == 9
|
|
37
|
+
client.close()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_caption_pack_options(live_server, venv_python):
|
|
41
|
+
key = mint_key(venv_python, 10)
|
|
42
|
+
client = _client(live_server, key)
|
|
43
|
+
pack = client.caption_pack(
|
|
44
|
+
topic="password managers",
|
|
45
|
+
audience="small business owners",
|
|
46
|
+
tone="bold",
|
|
47
|
+
platform="linkedin",
|
|
48
|
+
count=3,
|
|
49
|
+
include_hashtags=False,
|
|
50
|
+
)
|
|
51
|
+
assert len(pack.captions) == 3
|
|
52
|
+
assert pack.hashtags == []
|
|
53
|
+
assert pack.credits_remaining == 9
|
|
54
|
+
client.close()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_balance_reflects_spend(live_server, venv_python):
|
|
58
|
+
key = mint_key(venv_python, 5)
|
|
59
|
+
client = _client(live_server, key)
|
|
60
|
+
assert client.balance().credits == 5
|
|
61
|
+
client.caption_pack(topic="t", audience="a", count=1)
|
|
62
|
+
client.caption_pack(topic="t", audience="a", count=1)
|
|
63
|
+
assert client.balance().credits == 3
|
|
64
|
+
client.close()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_bad_key_raises_authentication_error(live_server):
|
|
68
|
+
client = _client(live_server, "cp_live_thiskeydoesnotexist")
|
|
69
|
+
with pytest.raises(AuthenticationError):
|
|
70
|
+
client.balance()
|
|
71
|
+
with pytest.raises(AuthenticationError):
|
|
72
|
+
client.caption_pack(topic="t", audience="a")
|
|
73
|
+
client.close()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_zero_credits_raises_insufficient_credits(live_server, venv_python):
|
|
77
|
+
key = mint_key(venv_python, 0)
|
|
78
|
+
client = _client(live_server, key)
|
|
79
|
+
with pytest.raises(InsufficientCreditsError) as exc_info:
|
|
80
|
+
client.caption_pack(topic="t", audience="a")
|
|
81
|
+
assert exc_info.value.top_up_url # API always includes the top-up link
|
|
82
|
+
# balance still works with 0 credits
|
|
83
|
+
assert client.balance().credits == 0
|
|
84
|
+
client.close()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_invalid_tone_raises_invalid_params(live_server, venv_python):
|
|
88
|
+
key = mint_key(venv_python, 5)
|
|
89
|
+
client = _client(live_server, key)
|
|
90
|
+
with pytest.raises(InvalidParamsError):
|
|
91
|
+
client.caption_pack(topic="t", audience="a", tone="nope")
|
|
92
|
+
# the rejected call must not spend a credit
|
|
93
|
+
assert client.balance().credits == 5
|
|
94
|
+
client.close()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_rate_limit_raises_after_retry(live_server, venv_python):
|
|
98
|
+
key = mint_key(venv_python, 100)
|
|
99
|
+
client = _client(live_server, key)
|
|
100
|
+
hit_limit = False
|
|
101
|
+
for _ in range(80): # limit is 60/min; the 61st trips it
|
|
102
|
+
try:
|
|
103
|
+
client.caption_pack(topic="t", audience="a", count=1)
|
|
104
|
+
except RateLimitedError:
|
|
105
|
+
hit_limit = True
|
|
106
|
+
break
|
|
107
|
+
assert hit_limit, "expected the 60 req/min limit to trip"
|
|
108
|
+
client.close()
|