captionpack-api 0.1.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.
captionpack/__init__.py
ADDED
|
@@ -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"
|
captionpack/client.py
ADDED
|
@@ -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,7 @@
|
|
|
1
|
+
captionpack/__init__.py,sha256=Un0w4rBkOaavezo5VMeTljLsctG2y6X6AHdGGxXAc4E,677
|
|
2
|
+
captionpack/client.py,sha256=hI9KQsgb3qspEy-LZ7VI7wEhEY21MZhzfOSIPVUKtVU,8303
|
|
3
|
+
captionpack/exceptions.py,sha256=pC0M8oLD6OkKGsC9fkPQGWxoOYXzt_2HdNu0ezoLV3o,1850
|
|
4
|
+
captionpack_api-0.1.0.dist-info/METADATA,sha256=f4xSfn-iIxLXrxOcuU1WzHDhHBZ02R3DxWFT9NeB4SA,2726
|
|
5
|
+
captionpack_api-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
6
|
+
captionpack_api-0.1.0.dist-info/top_level.txt,sha256=xdElQIUtbTFUP4vIQ7XZ4bK_fh1IT9WK0HOyNis5Rhw,12
|
|
7
|
+
captionpack_api-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
captionpack
|