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.
@@ -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
+ 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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()