carrac-sdk 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,8 @@
1
+ *.egg-info/
2
+ dist/
3
+ build/
4
+ __pycache__/
5
+ *.pyc
6
+ .pytest_cache/
7
+ .venv/
8
+ venv/
@@ -0,0 +1,80 @@
1
+ Metadata-Version: 2.4
2
+ Name: carrac-sdk
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for Carrac — market data, plays, alerts, usage
5
+ Project-URL: Homepage, https://carrac.cc
6
+ Project-URL: Documentation, https://app.carrac.cc/api/docs
7
+ Project-URL: Source, https://github.com/pgyula86/carrac-sdk-python
8
+ Author-email: Carrac <hello@carrac.cc>
9
+ License-Expression: MIT
10
+ Keywords: carrac,crypto,market-data,sdk,trading
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: Financial and Insurance Industry
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Office/Business :: Financial :: Investment
21
+ Requires-Python: >=3.9
22
+ Requires-Dist: httpx>=0.25
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest-httpx>=0.30; extra == 'dev'
25
+ Requires-Dist: pytest>=7; extra == 'dev'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # Carrac Python SDK
29
+
30
+ Official Python client for the [Carrac](https://carrac.cc) API. Use it to fetch market data, manage plays, create alerts, and inspect your API usage from any Python application.
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ pip install carrac-sdk
36
+ ```
37
+
38
+ Requires Python ≥ 3.9 and a Carrac Premium subscription (API keys require Premium).
39
+
40
+ ## Quick start
41
+
42
+ ```python
43
+ from carrac import Client
44
+
45
+ cvx = Client(api_key="carrac_k_...")
46
+
47
+ # Market data
48
+ chart = cvx.charts.get("BTC", timeframe="4h", indicators="CPR,RSI")
49
+
50
+ # Composite call — everything about an asset in one request
51
+ detail = cvx.composite.asset_detail("BTC", format="compact")
52
+
53
+ # Create an alert
54
+ cvx.alerts.create(symbol="BTC", alert_type="price", condition="above", price=120000)
55
+
56
+ # Scanner
57
+ ranked = cvx.scanner.list(direction="long", verdict="STRONG,GOOD")
58
+
59
+ # Your usage
60
+ usage = cvx.usage.get()
61
+ print(usage["rest"]["usage"]) # [{window:"minute", used:7, limit:240, remaining:233}, ...]
62
+ ```
63
+
64
+ ## Authentication
65
+
66
+ 1. Go to **Settings → API & Developer** in the Carrac dashboard.
67
+ 2. Click **Create Key**, copy the full key (shown once).
68
+ 3. Use it with `Client(api_key=...)` or set `CARRAC_API_KEY` in your environment.
69
+
70
+ ## Features
71
+
72
+ - Per-user scoped keys — every call is rate-limited against your own Premium budget.
73
+ - Automatic 429 backoff honouring `Retry-After`.
74
+ - Pagination iterators for list endpoints.
75
+ - Works in any Python ≥ 3.9 runtime (CPython, PyPy, Lambda, serverless).
76
+
77
+ ## Docs
78
+
79
+ Interactive API reference: https://app.carrac.cc/api/docs
80
+ OpenAPI spec: https://app.carrac.cc/api/openapi.json
@@ -0,0 +1,53 @@
1
+ # Carrac Python SDK
2
+
3
+ Official Python client for the [Carrac](https://carrac.cc) API. Use it to fetch market data, manage plays, create alerts, and inspect your API usage from any Python application.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install carrac-sdk
9
+ ```
10
+
11
+ Requires Python ≥ 3.9 and a Carrac Premium subscription (API keys require Premium).
12
+
13
+ ## Quick start
14
+
15
+ ```python
16
+ from carrac import Client
17
+
18
+ cvx = Client(api_key="carrac_k_...")
19
+
20
+ # Market data
21
+ chart = cvx.charts.get("BTC", timeframe="4h", indicators="CPR,RSI")
22
+
23
+ # Composite call — everything about an asset in one request
24
+ detail = cvx.composite.asset_detail("BTC", format="compact")
25
+
26
+ # Create an alert
27
+ cvx.alerts.create(symbol="BTC", alert_type="price", condition="above", price=120000)
28
+
29
+ # Scanner
30
+ ranked = cvx.scanner.list(direction="long", verdict="STRONG,GOOD")
31
+
32
+ # Your usage
33
+ usage = cvx.usage.get()
34
+ print(usage["rest"]["usage"]) # [{window:"minute", used:7, limit:240, remaining:233}, ...]
35
+ ```
36
+
37
+ ## Authentication
38
+
39
+ 1. Go to **Settings → API & Developer** in the Carrac dashboard.
40
+ 2. Click **Create Key**, copy the full key (shown once).
41
+ 3. Use it with `Client(api_key=...)` or set `CARRAC_API_KEY` in your environment.
42
+
43
+ ## Features
44
+
45
+ - Per-user scoped keys — every call is rate-limited against your own Premium budget.
46
+ - Automatic 429 backoff honouring `Retry-After`.
47
+ - Pagination iterators for list endpoints.
48
+ - Works in any Python ≥ 3.9 runtime (CPython, PyPy, Lambda, serverless).
49
+
50
+ ## Docs
51
+
52
+ Interactive API reference: https://app.carrac.cc/api/docs
53
+ OpenAPI spec: https://app.carrac.cc/api/openapi.json
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "carrac-sdk"
7
+ version = "0.1.0"
8
+ description = "Official Python SDK for Carrac — market data, plays, alerts, usage"
9
+ readme = "README.md"
10
+ authors = [{ name = "Carrac", email = "hello@carrac.cc" }]
11
+ license = "MIT"
12
+ requires-python = ">=3.9"
13
+ keywords = ["carrac", "trading", "crypto", "market-data", "sdk"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "Intended Audience :: Financial and Insurance Industry",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.9",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Topic :: Office/Business :: Financial :: Investment",
25
+ ]
26
+ dependencies = [
27
+ "httpx>=0.25",
28
+ ]
29
+
30
+ [project.optional-dependencies]
31
+ dev = ["pytest>=7", "pytest-httpx>=0.30"]
32
+
33
+ [project.urls]
34
+ Homepage = "https://carrac.cc"
35
+ Documentation = "https://app.carrac.cc/api/docs"
36
+ Source = "https://github.com/pgyula86/carrac-sdk-python"
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["src/carrac"]
@@ -0,0 +1,34 @@
1
+ """Carrac Python SDK — trading market data, plays, alerts, usage."""
2
+
3
+ from .client import Client
4
+ from .errors import (
5
+ CarracError,
6
+ AuthError,
7
+ RateLimitError,
8
+ NotFoundError,
9
+ ApiError,
10
+ # Backward-compat aliases; remove in next major version
11
+ ConvexityError,
12
+ ConvexityAuthError,
13
+ ConvexityNotFoundError,
14
+ ConvexityRateLimitError,
15
+ ConvexityApiError,
16
+ )
17
+
18
+ __version__ = "0.1.0"
19
+
20
+ __all__ = [
21
+ "Client",
22
+ # New names
23
+ "CarracError",
24
+ "AuthError",
25
+ "RateLimitError",
26
+ "NotFoundError",
27
+ "ApiError",
28
+ # Backward-compat aliases; remove in next major version
29
+ "ConvexityError",
30
+ "ConvexityAuthError",
31
+ "ConvexityNotFoundError",
32
+ "ConvexityRateLimitError",
33
+ "ConvexityApiError",
34
+ ]
@@ -0,0 +1,146 @@
1
+ """Carrac API client.
2
+
3
+ Thin wrapper around httpx. Resource namespaces (charts, plays, alerts, ...)
4
+ organize the surface; all I/O goes through Client._request so retries and
5
+ error handling stay in one place.
6
+ """
7
+
8
+ import os
9
+ import time
10
+
11
+ import httpx
12
+
13
+ from .errors import AuthError, NotFoundError, RateLimitError, ApiError
14
+
15
+ DEFAULT_BASE_URL = "https://app.carrac.cc"
16
+ DEFAULT_TIMEOUT = 30.0
17
+ DEFAULT_USER_AGENT = "carrac-python-sdk/0.1.0"
18
+
19
+
20
+ class Client:
21
+ """HTTP client for the Carrac API.
22
+
23
+ Args:
24
+ api_key: your Carrac API key. Falls back to CARRAC_API_KEY env var.
25
+ Also accepts the legacy CONVEXITY_API_KEY for backward compatibility.
26
+ base_url: override for staging / self-hosted deployments.
27
+ timeout: request timeout in seconds.
28
+ max_retries: how many times to automatically retry on 429 / 5xx.
29
+ """
30
+
31
+ def __init__(self, api_key=None, base_url=None, timeout=DEFAULT_TIMEOUT,
32
+ max_retries=3, user_agent=DEFAULT_USER_AGENT):
33
+ self.api_key = (
34
+ api_key
35
+ or os.environ.get("CARRAC_API_KEY")
36
+ or os.environ.get("CONVEXITY_API_KEY") # backward-compat
37
+ )
38
+ if not self.api_key:
39
+ raise ValueError(
40
+ "No API key provided. Pass api_key=... or set CARRAC_API_KEY."
41
+ )
42
+ self.base_url = (
43
+ base_url
44
+ or os.environ.get("CARRAC_BASE_URL")
45
+ or os.environ.get("CONVEXITY_BASE_URL") # backward-compat
46
+ or DEFAULT_BASE_URL
47
+ ).rstrip("/")
48
+ self.timeout = timeout
49
+ self.max_retries = max_retries
50
+ self._http = httpx.Client(
51
+ base_url=self.base_url,
52
+ timeout=timeout,
53
+ headers={
54
+ "X-API-Key": self.api_key,
55
+ "User-Agent": user_agent,
56
+ "Accept": "application/json",
57
+ },
58
+ )
59
+
60
+ # Resource namespaces
61
+ from .resources import Charts, Plays, Alerts, Composite, Scanner, Usage, Keys
62
+ self.charts = Charts(self)
63
+ self.plays = Plays(self)
64
+ self.alerts = Alerts(self)
65
+ self.composite = Composite(self)
66
+ self.scanner = Scanner(self)
67
+ self.usage = Usage(self)
68
+ self.keys = Keys(self)
69
+
70
+ # ── Context manager support ──
71
+ def __enter__(self):
72
+ return self
73
+
74
+ def __exit__(self, *a):
75
+ self.close()
76
+
77
+ def close(self):
78
+ self._http.close()
79
+
80
+ # ── Core request ──
81
+
82
+ def _request(self, method, path, *, params=None, json=None):
83
+ attempts = 0
84
+ while True:
85
+ attempts += 1
86
+ try:
87
+ resp = self._http.request(method, path, params=params, json=json)
88
+ except httpx.TransportError as exc:
89
+ if attempts > self.max_retries:
90
+ raise ApiError(f"Network error: {exc}") from exc
91
+ time.sleep(min(2 ** attempts, 10))
92
+ continue
93
+
94
+ if resp.status_code == 429 and attempts <= self.max_retries:
95
+ retry_after = int(resp.headers.get("Retry-After", "1"))
96
+ time.sleep(min(retry_after, 30))
97
+ continue
98
+
99
+ if 500 <= resp.status_code < 600 and attempts <= self.max_retries:
100
+ time.sleep(min(2 ** attempts, 10))
101
+ continue
102
+
103
+ self._raise_for_status(resp)
104
+
105
+ if resp.status_code == 204 or not resp.content:
106
+ return None
107
+ try:
108
+ return resp.json()
109
+ except ValueError as exc:
110
+ raise ApiError(f"Non-JSON response: {resp.text[:200]}") from exc
111
+
112
+ def _raise_for_status(self, resp):
113
+ if resp.is_success:
114
+ return
115
+ body = {}
116
+ try:
117
+ body = resp.json()
118
+ except ValueError:
119
+ pass
120
+ message = body.get("error") or resp.text or resp.reason_phrase
121
+
122
+ if resp.status_code in (401, 403):
123
+ raise AuthError(message, status_code=resp.status_code, response=body)
124
+ if resp.status_code == 404:
125
+ raise NotFoundError(message, status_code=resp.status_code, response=body)
126
+ if resp.status_code == 429:
127
+ raise RateLimitError(
128
+ message,
129
+ retry_after=int(resp.headers.get("Retry-After", "0")),
130
+ status_code=429,
131
+ response=body,
132
+ )
133
+ raise ApiError(message, status_code=resp.status_code, response=body)
134
+
135
+ # ── Shortcut methods ──
136
+ def get(self, path, **params):
137
+ return self._request("GET", path, params=params or None)
138
+
139
+ def post(self, path, json=None, **params):
140
+ return self._request("POST", path, params=params or None, json=json)
141
+
142
+ def put(self, path, json=None, **params):
143
+ return self._request("PUT", path, params=params or None, json=json)
144
+
145
+ def delete(self, path, **params):
146
+ return self._request("DELETE", path, params=params or None)
@@ -0,0 +1,46 @@
1
+ """SDK error hierarchy.
2
+
3
+ All API failures raise a subclass of CarracError. Callers can catch the
4
+ base class to handle any API failure, or a specific subclass for targeted
5
+ recovery (e.g. retrying on RateLimitError).
6
+ """
7
+
8
+
9
+ class CarracError(Exception):
10
+ """Base class for all SDK errors."""
11
+
12
+ def __init__(self, message, status_code=None, response=None):
13
+ super().__init__(message)
14
+ self.status_code = status_code
15
+ self.response = response
16
+
17
+
18
+ class AuthError(CarracError):
19
+ """401/403 — missing, invalid, or insufficient API key / tier."""
20
+
21
+
22
+ class NotFoundError(CarracError):
23
+ """404 — resource does not exist or is not visible to this key."""
24
+
25
+
26
+ class RateLimitError(CarracError):
27
+ """429 — sliding-window budget exceeded.
28
+
29
+ `retry_after` is seconds until the soonest window frees up.
30
+ """
31
+
32
+ def __init__(self, message, retry_after=0, status_code=429, response=None):
33
+ super().__init__(message, status_code=status_code, response=response)
34
+ self.retry_after = retry_after
35
+
36
+
37
+ class ApiError(CarracError):
38
+ """5xx or unclassified 4xx — server-side failure, treat as transient."""
39
+
40
+
41
+ # Backward-compat aliases; remove in next major version
42
+ ConvexityError = CarracError
43
+ ConvexityAuthError = AuthError
44
+ ConvexityNotFoundError = NotFoundError
45
+ ConvexityRateLimitError = RateLimitError
46
+ ConvexityApiError = ApiError
@@ -0,0 +1,114 @@
1
+ """Resource namespaces — thin wrappers that build paths and delegate to the client."""
2
+
3
+
4
+ class _Resource:
5
+ def __init__(self, client):
6
+ self._client = client
7
+
8
+
9
+ class Charts(_Resource):
10
+ def get(self, symbol, *, timeframe=None, indicators=None):
11
+ """OHLCV + indicator data for a symbol (see /api/chart-internal)."""
12
+ params = {}
13
+ if timeframe:
14
+ params["timeframe"] = timeframe
15
+ if indicators:
16
+ params["indicators"] = indicators
17
+ return self._client.get(f"/api/chart-internal/{symbol}", **params)
18
+
19
+
20
+ class Plays(_Resource):
21
+ def list(self, *, status=None, asset=None, type=None,
22
+ include_resolved=False, limit=50, offset=0):
23
+ params = {"limit": limit, "offset": offset, "include_resolved": str(include_resolved).lower()}
24
+ if status:
25
+ params["status"] = status
26
+ if asset:
27
+ params["asset"] = asset
28
+ if type:
29
+ params["type"] = type
30
+ return self._client.get("/api/plays", **params)
31
+
32
+ def active(self):
33
+ return self._client.get("/api/plays/active")
34
+
35
+ def context(self):
36
+ """Compact plays context for AI briefings."""
37
+ return self._client.get("/api/plays/context")
38
+
39
+ def get(self, play_id):
40
+ return self._client.get(f"/api/plays/{play_id}")
41
+
42
+ def create(self, **fields):
43
+ return self._client.post("/api/plays", json=fields)
44
+
45
+ def update(self, play_id, **fields):
46
+ return self._client.put(f"/api/plays/{play_id}", json=fields)
47
+
48
+ def delete(self, play_id):
49
+ return self._client.delete(f"/api/plays/{play_id}")
50
+
51
+
52
+ class Alerts(_Resource):
53
+ def list(self, *, active=True, triggered_since=None):
54
+ params = {"active": str(active).lower()}
55
+ if triggered_since:
56
+ params["triggered_since"] = triggered_since
57
+ return self._client.get("/api/alerts", **params)
58
+
59
+ def create(self, **fields):
60
+ return self._client.post("/api/alerts", json=fields)
61
+
62
+ def delete(self, alert_id):
63
+ return self._client.delete(f"/api/alerts/{alert_id}")
64
+
65
+ def toggle(self, alert_id):
66
+ return self._client.post(f"/api/alerts/{alert_id}/toggle")
67
+
68
+
69
+ class Composite(_Resource):
70
+ def asset_detail(self, symbol, *, format=None):
71
+ params = {"format": format} if format else {}
72
+ return self._client.get(f"/api/asset/{symbol}/detail", **params)
73
+
74
+ def live_positions(self, *, asset=None, format=None):
75
+ params = {}
76
+ if asset:
77
+ params["asset"] = asset
78
+ if format:
79
+ params["format"] = format
80
+ return self._client.get("/api/positions/live", **params)
81
+
82
+ def batch(self, assets, *, include=None, format=None):
83
+ if isinstance(assets, (list, tuple, set)):
84
+ assets = ",".join(assets)
85
+ params = {"assets": assets}
86
+ if include:
87
+ params["include"] = ",".join(include) if not isinstance(include, str) else include
88
+ if format:
89
+ params["format"] = format
90
+ return self._client.get("/api/batch", **params)
91
+
92
+
93
+ class Scanner(_Resource):
94
+ def list(self, *, verdict=None, direction="both"):
95
+ params = {"direction": direction}
96
+ if verdict:
97
+ params["verdict"] = verdict
98
+ return self._client.get("/api/scanner", **params)
99
+
100
+
101
+ class Usage(_Resource):
102
+ def get(self, *, history=True, days=30):
103
+ return self._client.get("/api/usage", history=str(history).lower(), days=days)
104
+
105
+
106
+ class Keys(_Resource):
107
+ def list(self):
108
+ return self._client.get("/api/keys")
109
+
110
+ def create(self, name, *, scope="full"):
111
+ return self._client.post("/api/keys", json={"name": name, "scope": scope})
112
+
113
+ def revoke(self, key_id):
114
+ return self._client.delete(f"/api/keys/{key_id}")
@@ -0,0 +1,172 @@
1
+ """Unit tests for the Python SDK (httpx MockTransport — no network)."""
2
+
3
+ import pytest
4
+ import httpx
5
+
6
+ from carrac import Client, AuthError, RateLimitError, NotFoundError
7
+
8
+
9
+ def _mock_client(handler):
10
+ """Create a Client backed by a MockTransport handler."""
11
+ transport = httpx.MockTransport(handler)
12
+ c = Client(api_key="test-key")
13
+ c._http = httpx.Client(
14
+ base_url=c.base_url,
15
+ transport=transport,
16
+ headers={"X-API-Key": c.api_key, "Accept": "application/json"},
17
+ )
18
+ return c
19
+
20
+
21
+ class TestAuth:
22
+ def test_requires_api_key(self, monkeypatch):
23
+ monkeypatch.delenv("CARRAC_API_KEY", raising=False)
24
+ monkeypatch.delenv("CONVEXITY_API_KEY", raising=False)
25
+ with pytest.raises(ValueError):
26
+ Client()
27
+
28
+ def test_uses_env_var_fallback(self, monkeypatch):
29
+ monkeypatch.setenv("CARRAC_API_KEY", "carrac_k_fromenv")
30
+ c = Client()
31
+ assert c.api_key == "carrac_k_fromenv"
32
+
33
+ def test_legacy_env_var_fallback(self, monkeypatch):
34
+ monkeypatch.delenv("CARRAC_API_KEY", raising=False)
35
+ monkeypatch.setenv("CONVEXITY_API_KEY", "cvx_k_fromenv")
36
+ c = Client()
37
+ assert c.api_key == "cvx_k_fromenv"
38
+
39
+
40
+ class TestResources:
41
+ def test_charts_get_builds_correct_url(self):
42
+ captured = {}
43
+
44
+ def handler(req):
45
+ captured["url"] = str(req.url)
46
+ captured["headers"] = dict(req.headers)
47
+ return httpx.Response(200, json={"candles": [], "symbol": "BTC"})
48
+
49
+ c = _mock_client(handler)
50
+ out = c.charts.get("BTC", timeframe="1h", indicators="CPR,RSI")
51
+ assert "/api/chart-internal/BTC" in captured["url"]
52
+ assert "timeframe=1h" in captured["url"]
53
+ assert "indicators=CPR%2CRSI" in captured["url"]
54
+ assert captured["headers"]["x-api-key"] == "test-key"
55
+ assert out["symbol"] == "BTC"
56
+
57
+ def test_plays_create_posts_json(self):
58
+ captured = {}
59
+
60
+ def handler(req):
61
+ captured["method"] = req.method
62
+ captured["body"] = req.content.decode()
63
+ return httpx.Response(201, json={"id": 99, "title": "Test"})
64
+
65
+ c = _mock_client(handler)
66
+ out = c.plays.create(title="Test", type="directional", primary_asset="BTC")
67
+ assert captured["method"] == "POST"
68
+ assert '"title":"Test"' in captured["body"] or '"title": "Test"' in captured["body"]
69
+ assert out["id"] == 99
70
+
71
+ def test_composite_batch_serializes_list_assets(self):
72
+ captured = {}
73
+
74
+ def handler(req):
75
+ captured["url"] = str(req.url)
76
+ return httpx.Response(200, json={"assets": {}})
77
+
78
+ c = _mock_client(handler)
79
+ c.composite.batch(["BTC", "ETH"], include=["market", "scanner"])
80
+ assert "assets=BTC%2CETH" in captured["url"]
81
+ assert "include=market%2Cscanner" in captured["url"]
82
+
83
+ def test_usage_get_includes_history_param(self):
84
+ captured = {}
85
+
86
+ def handler(req):
87
+ captured["url"] = str(req.url)
88
+ return httpx.Response(200, json={"user_id": 1, "rest": {}, "mcp": {}})
89
+
90
+ c = _mock_client(handler)
91
+ c.usage.get(history=False, days=7)
92
+ assert "history=false" in captured["url"]
93
+ assert "days=7" in captured["url"]
94
+
95
+
96
+ class TestErrorHandling:
97
+ def test_401_raises_auth_error(self):
98
+ def handler(req):
99
+ return httpx.Response(401, json={"error": "bad key"})
100
+
101
+ c = _mock_client(handler)
102
+ c.max_retries = 0
103
+ with pytest.raises(AuthError) as exc:
104
+ c.charts.get("BTC")
105
+ assert exc.value.status_code == 401
106
+
107
+ def test_404_raises_notfound(self):
108
+ def handler(req):
109
+ return httpx.Response(404, json={"error": "not found"})
110
+
111
+ c = _mock_client(handler)
112
+ c.max_retries = 0
113
+ with pytest.raises(NotFoundError):
114
+ c.plays.get(999)
115
+
116
+ def test_429_raises_after_retries(self):
117
+ calls = {"n": 0}
118
+
119
+ def handler(req):
120
+ calls["n"] += 1
121
+ return httpx.Response(429, headers={"Retry-After": "2"},
122
+ json={"error": "rate limited", "limit": 240,
123
+ "window": "minute", "retry_after": 2})
124
+
125
+ c = _mock_client(handler)
126
+ c.max_retries = 2
127
+ with pytest.raises(RateLimitError) as exc:
128
+ c.charts.get("BTC")
129
+ assert calls["n"] == 3 # initial + 2 retries
130
+ # retry_after comes from the Retry-After header, not the JSON body.
131
+ assert exc.value.retry_after == 2
132
+
133
+
134
+ class TestPaths:
135
+ def test_alerts_delete_builds_correct_url(self):
136
+ captured = {}
137
+
138
+ def handler(req):
139
+ captured["method"] = req.method
140
+ captured["path"] = req.url.path
141
+ return httpx.Response(204)
142
+
143
+ c = _mock_client(handler)
144
+ c.alerts.delete(42)
145
+ assert captured["method"] == "DELETE"
146
+ assert captured["path"] == "/api/alerts/42"
147
+
148
+ def test_alerts_toggle_posts_to_correct_url(self):
149
+ captured = {}
150
+
151
+ def handler(req):
152
+ captured["method"] = req.method
153
+ captured["path"] = req.url.path
154
+ return httpx.Response(200, json={"id": 7, "active": False})
155
+
156
+ c = _mock_client(handler)
157
+ c.alerts.toggle(7)
158
+ assert captured["method"] == "POST"
159
+ assert captured["path"] == "/api/alerts/7/toggle"
160
+
161
+ def test_keys_revoke_builds_correct_url(self):
162
+ captured = {}
163
+
164
+ def handler(req):
165
+ captured["method"] = req.method
166
+ captured["path"] = req.url.path
167
+ return httpx.Response(200, json={"success": True})
168
+
169
+ c = _mock_client(handler)
170
+ c.keys.revoke(5)
171
+ assert captured["method"] == "DELETE"
172
+ assert captured["path"] == "/api/keys/5"