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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ captionpack