chatads-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,74 @@
1
+ Metadata-Version: 2.4
2
+ Name: chatads-sdk
3
+ Version: 0.1.0
4
+ Summary: Lightweight Python client for the ChatAds affiliate scoring API
5
+ Author-email: ChatAds <support@getchatads.com>
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: httpx<1.0,>=0.27
9
+ Provides-Extra: async
10
+ Requires-Dist: httpx[http2]<1.0,>=0.27; extra == "async"
11
+
12
+ # ChatAds Python SDK
13
+
14
+ A tiny, dependency-light wrapper around the ChatAds `/v1/chatads-script` endpoint. It mirrors the response payloads returned by the FastAPI service so you can drop it into CLIs, serverless functions, or orchestration tools.
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ pip install .
20
+ ```
21
+
22
+ (Or build/upload to your internal index as needed.)
23
+
24
+ ## Quickstart
25
+
26
+ ```python
27
+ from chatads_sdk import ChatAdsClient, FunctionItemPayload
28
+
29
+ client = ChatAdsClient(
30
+ api_key="YOUR_X_API_KEY",
31
+ base_url="https://<your-chatads-domain>",
32
+ raise_on_failure=True, # Treat success=False payloads as exceptions
33
+ max_retries=2, # Optional automatic retries for 429/5xx responses
34
+ retry_backoff_factor=0.75, # Exponential backoff multiplier
35
+ )
36
+
37
+ payload = FunctionItemPayload(
38
+ message="Looking for a CRM to close more deals",
39
+ ip="1.2.3.4",
40
+ user_agent="Mozilla/5.0",
41
+ )
42
+
43
+ result = client.analyze(payload)
44
+
45
+ if result.success:
46
+ print(result.data.ad)
47
+ else:
48
+ print(result.error.code, result.error.message)
49
+ ```
50
+
51
+ ## Error Handling
52
+
53
+ Non-2xx responses raise `ChatAdsAPIError` and include the parsed error payload plus the original HTTP status code so you can branch on quota/validation failures. Set `raise_on_failure=True` if you want 200 responses with `success=false` to raise the same exception class.
54
+
55
+ ## Notes
56
+
57
+ - Retries are opt-in. Provide `max_retries>0` to automatically retry transport errors and retryable status codes. The client honors `Retry-After` headers and falls back to exponential backoff.
58
+ - `FunctionItemPayload` matches the server-side `FunctionItem` pydantic model. Keyword arguments passed to `ChatAdsClient.analyze_message()` accept either snake_case (`user_agent`) or camelCase (`userAgent`) keys.
59
+ - Reserved payload keys (e.g., `message`, `pageUrl`, `userAgent`) cannot be overridden through `extra_fields`; doing so raises `ValueError` to prevent silent mutations.
60
+
61
+ ## CLI Smoke Test
62
+
63
+ For a super-quick check, edit the config block at the top of `python_sdk/run_sdk_smoke.py` (or set the
64
+ `CHATADS_*` env vars) and run:
65
+
66
+ ```bash
67
+ PYTHONPATH=python_sdk python python_sdk/run_sdk_smoke.py
68
+ ```
69
+
70
+ It prints the raw JSON response or surfaces a `ChatAdsAPIError` with status/error fields so you can see
71
+ exactly what the API returned.
72
+
73
+ - `API_KEY` and `MESSAGE` are the only required values. Leave `CALLER_IP`, `USER_AGENT`, or `CHATADS_EXTRA_FIELDS`
74
+ blank to omit them from the request.
@@ -0,0 +1,63 @@
1
+ # ChatAds Python SDK
2
+
3
+ A tiny, dependency-light wrapper around the ChatAds `/v1/chatads-script` endpoint. It mirrors the response payloads returned by the FastAPI service so you can drop it into CLIs, serverless functions, or orchestration tools.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install .
9
+ ```
10
+
11
+ (Or build/upload to your internal index as needed.)
12
+
13
+ ## Quickstart
14
+
15
+ ```python
16
+ from chatads_sdk import ChatAdsClient, FunctionItemPayload
17
+
18
+ client = ChatAdsClient(
19
+ api_key="YOUR_X_API_KEY",
20
+ base_url="https://<your-chatads-domain>",
21
+ raise_on_failure=True, # Treat success=False payloads as exceptions
22
+ max_retries=2, # Optional automatic retries for 429/5xx responses
23
+ retry_backoff_factor=0.75, # Exponential backoff multiplier
24
+ )
25
+
26
+ payload = FunctionItemPayload(
27
+ message="Looking for a CRM to close more deals",
28
+ ip="1.2.3.4",
29
+ user_agent="Mozilla/5.0",
30
+ )
31
+
32
+ result = client.analyze(payload)
33
+
34
+ if result.success:
35
+ print(result.data.ad)
36
+ else:
37
+ print(result.error.code, result.error.message)
38
+ ```
39
+
40
+ ## Error Handling
41
+
42
+ Non-2xx responses raise `ChatAdsAPIError` and include the parsed error payload plus the original HTTP status code so you can branch on quota/validation failures. Set `raise_on_failure=True` if you want 200 responses with `success=false` to raise the same exception class.
43
+
44
+ ## Notes
45
+
46
+ - Retries are opt-in. Provide `max_retries>0` to automatically retry transport errors and retryable status codes. The client honors `Retry-After` headers and falls back to exponential backoff.
47
+ - `FunctionItemPayload` matches the server-side `FunctionItem` pydantic model. Keyword arguments passed to `ChatAdsClient.analyze_message()` accept either snake_case (`user_agent`) or camelCase (`userAgent`) keys.
48
+ - Reserved payload keys (e.g., `message`, `pageUrl`, `userAgent`) cannot be overridden through `extra_fields`; doing so raises `ValueError` to prevent silent mutations.
49
+
50
+ ## CLI Smoke Test
51
+
52
+ For a super-quick check, edit the config block at the top of `python_sdk/run_sdk_smoke.py` (or set the
53
+ `CHATADS_*` env vars) and run:
54
+
55
+ ```bash
56
+ PYTHONPATH=python_sdk python python_sdk/run_sdk_smoke.py
57
+ ```
58
+
59
+ It prints the raw JSON response or surfaces a `ChatAdsAPIError` with status/error fields so you can see
60
+ exactly what the API returned.
61
+
62
+ - `API_KEY` and `MESSAGE` are the only required values. Leave `CALLER_IP`, `USER_AGENT`, or `CHATADS_EXTRA_FIELDS`
63
+ blank to omit them from the request.
@@ -0,0 +1,27 @@
1
+ """Public exports for the ChatAds Python SDK."""
2
+
3
+ from .client import ChatAdsClient, AsyncChatAdsClient
4
+ from .models import (
5
+ ChatAdsAd,
6
+ ChatAdsData,
7
+ ChatAdsError,
8
+ ChatAdsMeta,
9
+ ChatAdsResponse,
10
+ FunctionItemPayload,
11
+ UsageInfo,
12
+ )
13
+ from .exceptions import ChatAdsAPIError, ChatAdsSDKError
14
+
15
+ __all__ = [
16
+ "ChatAdsClient",
17
+ "AsyncChatAdsClient",
18
+ "ChatAdsAd",
19
+ "ChatAdsData",
20
+ "ChatAdsError",
21
+ "ChatAdsMeta",
22
+ "ChatAdsResponse",
23
+ "FunctionItemPayload",
24
+ "UsageInfo",
25
+ "ChatAdsAPIError",
26
+ "ChatAdsSDKError",
27
+ ]
@@ -0,0 +1,385 @@
1
+ """HTTP clients for interacting with the ChatAds API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import time
9
+ from datetime import datetime, timezone
10
+ from email.utils import parsedate_to_datetime
11
+ from typing import Any, Dict, Iterable, Optional, Set
12
+
13
+ import httpx
14
+
15
+ from .exceptions import ChatAdsAPIError, ChatAdsSDKError
16
+ from .models import (
17
+ ChatAdsResponse,
18
+ FunctionItemPayload,
19
+ FUNCTION_ITEM_FIELD_ALIASES,
20
+ FUNCTION_ITEM_OPTIONAL_FIELDS,
21
+ )
22
+
23
+ _DEFAULT_ENDPOINT = "/chatads-script"
24
+ _DEFAULT_RETRY_STATUSES = frozenset({408, 429, 500, 502, 503, 504})
25
+ _FUNCTION_ITEM_OPTIONAL_FIELDS = set(FUNCTION_ITEM_OPTIONAL_FIELDS)
26
+ _FIELD_ALIAS_LOOKUP = {alias.lower(): field for alias, field in FUNCTION_ITEM_FIELD_ALIASES.items()}
27
+
28
+
29
+ class ChatAdsClient:
30
+ """Synchronous ChatAds API client."""
31
+
32
+ def __init__(
33
+ self,
34
+ api_key: str,
35
+ base_url: str,
36
+ *,
37
+ endpoint: str = _DEFAULT_ENDPOINT,
38
+ timeout: float = 10.0,
39
+ http_client: Optional[httpx.Client] = None,
40
+ raise_on_failure: bool = False,
41
+ max_retries: int = 0,
42
+ retry_backoff_factor: float = 0.5,
43
+ retry_statuses: Optional[Iterable[int]] = None,
44
+ logger: Optional[logging.Logger] = None,
45
+ debug: bool = False,
46
+ ) -> None:
47
+ if not api_key:
48
+ raise ValueError("api_key is required")
49
+ if not base_url:
50
+ raise ValueError("base_url is required")
51
+
52
+ self._api_key = api_key
53
+ self._base_url = base_url.rstrip("/")
54
+ self._endpoint = endpoint if endpoint.startswith("/") else f"/{endpoint}"
55
+ self._timeout = timeout
56
+ self._client = http_client or httpx.Client(timeout=timeout)
57
+ self._owns_client = http_client is None
58
+ self._raise_on_failure = raise_on_failure
59
+ self._max_retries = max(0, int(max_retries))
60
+ self._retry_backoff_factor = max(0.0, float(retry_backoff_factor))
61
+ self._retry_statuses: Set[int] = (
62
+ set(retry_statuses) if retry_statuses is not None else set(_DEFAULT_RETRY_STATUSES)
63
+ )
64
+ self._logger = logger or logging.getLogger("chatads_sdk")
65
+ self._debug = debug
66
+
67
+ def close(self) -> None:
68
+ if self._owns_client:
69
+ self._client.close()
70
+
71
+ def __enter__(self) -> "ChatAdsClient":
72
+ return self
73
+
74
+ def __exit__(self, exc_type, exc, tb) -> None: # type: ignore[override]
75
+ self.close()
76
+
77
+ def analyze(
78
+ self,
79
+ payload: FunctionItemPayload,
80
+ *,
81
+ timeout: Optional[float] = None,
82
+ headers: Optional[Dict[str, str]] = None,
83
+ ) -> ChatAdsResponse:
84
+ """Send a FunctionItem payload to the ChatAds endpoint."""
85
+ body = payload.to_payload()
86
+ return self._post(body, timeout=timeout, headers=headers)
87
+
88
+ def analyze_message(
89
+ self,
90
+ message: str,
91
+ *,
92
+ timeout: Optional[float] = None,
93
+ headers: Optional[Dict[str, str]] = None,
94
+ **extra_fields: Any,
95
+ ) -> ChatAdsResponse:
96
+ """
97
+ Convenience wrapper taking only the message plus optional FunctionItem fields.
98
+ """
99
+ payload = _build_payload_from_kwargs(message, extra_fields)
100
+ return self.analyze(payload, timeout=timeout, headers=headers)
101
+
102
+ def _post(
103
+ self,
104
+ body: Dict[str, Any],
105
+ *,
106
+ timeout: Optional[float],
107
+ headers: Optional[Dict[str, str]],
108
+ ) -> ChatAdsResponse:
109
+ request_headers = {"x-api-key": self._api_key, **(headers or {})}
110
+ url = f"{self._base_url}{self._endpoint}"
111
+ attempt = 0
112
+ while True:
113
+ try:
114
+ self._log_request(url, request_headers, body)
115
+ response = self._client.post(
116
+ url,
117
+ json=body,
118
+ headers=request_headers,
119
+ timeout=timeout or self._timeout,
120
+ )
121
+ except httpx.RequestError as exc:
122
+ if attempt >= self._max_retries:
123
+ raise ChatAdsSDKError(f"Transport error while calling ChatAds: {exc}") from exc
124
+ _sleep_sync(
125
+ _compute_retry_delay(attempt, self._retry_backoff_factor, None)
126
+ )
127
+ attempt += 1
128
+ continue
129
+
130
+ parsed = _parse_response(response)
131
+ self._log_response(response, parsed)
132
+ is_error = response.is_error or (self._raise_on_failure and not parsed.success)
133
+ if not is_error:
134
+ return parsed
135
+
136
+ api_error = ChatAdsAPIError(
137
+ status_code=response.status_code,
138
+ payload=parsed.raw,
139
+ response=parsed,
140
+ headers=dict(response.headers),
141
+ request_body=body,
142
+ url=url,
143
+ )
144
+ if attempt < self._max_retries and self._should_retry_status(response.status_code):
145
+ _sleep_sync(
146
+ _compute_retry_delay(attempt, self._retry_backoff_factor, api_error.retry_after)
147
+ )
148
+ attempt += 1
149
+ continue
150
+ raise api_error
151
+
152
+ def _should_retry_status(self, status_code: int) -> bool:
153
+ return status_code in self._retry_statuses
154
+
155
+ def _log_request(self, url: str, headers: Dict[str, str], body: Dict[str, Any]) -> None:
156
+ if not self._debug:
157
+ return
158
+ safe_headers = {k: v for k, v in headers.items() if k.lower() != "x-api-key"}
159
+ self._logger.info("ChatAds request -> %s", url)
160
+ self._logger.info("Headers: %s", safe_headers)
161
+ self._logger.info("Body: %s", json.dumps(body, indent=2))
162
+
163
+ def _log_response(self, response: httpx.Response, parsed: ChatAdsResponse) -> None:
164
+ if not self._debug:
165
+ return
166
+ self._logger.info(
167
+ "ChatAds response <- %s %s (status=%s)",
168
+ response.request.method if response.request else "POST",
169
+ response.request.url if response.request else "<unknown>",
170
+ response.status_code,
171
+ )
172
+ self._logger.info("Payload: %s", json.dumps(parsed.raw, indent=2))
173
+
174
+
175
+ class AsyncChatAdsClient:
176
+ """Asynchronous version backed by httpx.AsyncClient."""
177
+
178
+ def __init__(
179
+ self,
180
+ api_key: str,
181
+ base_url: str,
182
+ *,
183
+ endpoint: str = _DEFAULT_ENDPOINT,
184
+ timeout: float = 10.0,
185
+ http_client: Optional[httpx.AsyncClient] = None,
186
+ raise_on_failure: bool = False,
187
+ max_retries: int = 0,
188
+ retry_backoff_factor: float = 0.5,
189
+ retry_statuses: Optional[Iterable[int]] = None,
190
+ logger: Optional[logging.Logger] = None,
191
+ debug: bool = False,
192
+ ) -> None:
193
+ if not api_key:
194
+ raise ValueError("api_key is required")
195
+ if not base_url:
196
+ raise ValueError("base_url is required")
197
+
198
+ self._api_key = api_key
199
+ self._base_url = base_url.rstrip("/")
200
+ self._endpoint = endpoint if endpoint.startswith("/") else f"/{endpoint}"
201
+ self._timeout = timeout
202
+ self._client = http_client or httpx.AsyncClient(timeout=timeout)
203
+ self._owns_client = http_client is None
204
+ self._raise_on_failure = raise_on_failure
205
+ self._max_retries = max(0, int(max_retries))
206
+ self._retry_backoff_factor = max(0.0, float(retry_backoff_factor))
207
+ self._retry_statuses: Set[int] = (
208
+ set(retry_statuses) if retry_statuses is not None else set(_DEFAULT_RETRY_STATUSES)
209
+ )
210
+ self._logger = logger or logging.getLogger("chatads_sdk")
211
+ self._debug = debug
212
+
213
+ async def aclose(self) -> None:
214
+ if self._owns_client:
215
+ await self._client.aclose()
216
+
217
+ async def __aenter__(self) -> "AsyncChatAdsClient":
218
+ return self
219
+
220
+ async def __aexit__(self, exc_type, exc, tb) -> None: # type: ignore[override]
221
+ await self.aclose()
222
+
223
+ async def analyze(
224
+ self,
225
+ payload: FunctionItemPayload,
226
+ *,
227
+ timeout: Optional[float] = None,
228
+ headers: Optional[Dict[str, str]] = None,
229
+ ) -> ChatAdsResponse:
230
+ body = payload.to_payload()
231
+ return await self._post(body, timeout=timeout, headers=headers)
232
+
233
+ async def analyze_message(
234
+ self,
235
+ message: str,
236
+ *,
237
+ timeout: Optional[float] = None,
238
+ headers: Optional[Dict[str, str]] = None,
239
+ **extra_fields: Any,
240
+ ) -> ChatAdsResponse:
241
+ payload = _build_payload_from_kwargs(message, extra_fields)
242
+ return await self.analyze(payload, timeout=timeout, headers=headers)
243
+
244
+ async def _post(
245
+ self,
246
+ body: Dict[str, Any],
247
+ *,
248
+ timeout: Optional[float],
249
+ headers: Optional[Dict[str, str]],
250
+ ) -> ChatAdsResponse:
251
+ request_headers = {"x-api-key": self._api_key, **(headers or {})}
252
+ url = f"{self._base_url}{self._endpoint}"
253
+ attempt = 0
254
+ while True:
255
+ try:
256
+ self._log_request(url, request_headers, body)
257
+ response = await self._client.post(
258
+ url,
259
+ json=body,
260
+ headers=request_headers,
261
+ timeout=timeout or self._timeout,
262
+ )
263
+ except httpx.RequestError as exc:
264
+ if attempt >= self._max_retries:
265
+ raise ChatAdsSDKError(f"Transport error while calling ChatAds: {exc}") from exc
266
+ await _sleep_async(
267
+ _compute_retry_delay(attempt, self._retry_backoff_factor, None)
268
+ )
269
+ attempt += 1
270
+ continue
271
+
272
+ parsed = _parse_response(response)
273
+ self._log_response(response, parsed)
274
+ is_error = response.is_error or (self._raise_on_failure and not parsed.success)
275
+ if not is_error:
276
+ return parsed
277
+
278
+ api_error = ChatAdsAPIError(
279
+ status_code=response.status_code,
280
+ payload=parsed.raw,
281
+ response=parsed,
282
+ headers=dict(response.headers),
283
+ request_body=body,
284
+ url=url,
285
+ )
286
+ if attempt < self._max_retries and self._should_retry_status(response.status_code):
287
+ await _sleep_async(
288
+ _compute_retry_delay(attempt, self._retry_backoff_factor, api_error.retry_after)
289
+ )
290
+ attempt += 1
291
+ continue
292
+ raise api_error
293
+
294
+ def _should_retry_status(self, status_code: int) -> bool:
295
+ return status_code in self._retry_statuses
296
+
297
+ def _log_request(self, url: str, headers: Dict[str, str], body: Dict[str, Any]) -> None:
298
+ if not self._debug:
299
+ return
300
+ safe_headers = {k: v for k, v in headers.items() if k.lower() != "x-api-key"}
301
+ self._logger.info("ChatAds request -> %s", url)
302
+ self._logger.info("Headers: %s", safe_headers)
303
+ self._logger.info("Body: %s", json.dumps(body, indent=2))
304
+
305
+ def _log_response(self, response: httpx.Response, parsed: ChatAdsResponse) -> None:
306
+ if not self._debug:
307
+ return
308
+ self._logger.info(
309
+ "ChatAds response <- %s %s (status=%s)",
310
+ response.request.method if response.request else "POST",
311
+ response.request.url if response.request else "<unknown>",
312
+ response.status_code,
313
+ )
314
+ self._logger.info("Payload: %s", json.dumps(parsed.raw, indent=2))
315
+
316
+
317
+ def _parse_response(response: httpx.Response) -> ChatAdsResponse:
318
+ try:
319
+ payload = response.json()
320
+ except ValueError as exc:
321
+ raise ChatAdsSDKError("ChatAds returned a non-JSON response") from exc
322
+ return ChatAdsResponse.from_dict(payload)
323
+
324
+
325
+ def _build_payload_from_kwargs(message: str, kwargs: Dict[str, Any]) -> FunctionItemPayload:
326
+ known: Dict[str, Any] = {}
327
+ extra: Dict[str, Any] = {}
328
+ for key, value in kwargs.items():
329
+ normalized = _normalize_field_name(key)
330
+ if normalized:
331
+ known[normalized] = value
332
+ else:
333
+ extra[key] = value
334
+ return FunctionItemPayload(message=message, extra_fields=extra, **known)
335
+
336
+
337
+ def _normalize_field_name(field: str) -> Optional[str]:
338
+ if field in _FUNCTION_ITEM_OPTIONAL_FIELDS:
339
+ return field
340
+ return _FIELD_ALIAS_LOOKUP.get(field.lower())
341
+
342
+
343
+ def _compute_retry_delay(
344
+ attempt: int,
345
+ backoff_factor: float,
346
+ retry_after_header: Optional[str],
347
+ ) -> float:
348
+ header_delay = _parse_retry_after(retry_after_header)
349
+ if header_delay is not None:
350
+ return header_delay
351
+ if backoff_factor <= 0:
352
+ return 0.0
353
+ return backoff_factor * (2 ** attempt)
354
+
355
+
356
+ def _parse_retry_after(header_value: Optional[str]) -> Optional[float]:
357
+ if header_value is None:
358
+ return None
359
+ header_value = header_value.strip()
360
+ if not header_value:
361
+ return None
362
+ try:
363
+ return max(0.0, float(header_value))
364
+ except ValueError:
365
+ pass
366
+ try:
367
+ dt = parsedate_to_datetime(header_value)
368
+ if dt is None:
369
+ return None
370
+ if dt.tzinfo is None:
371
+ dt = dt.replace(tzinfo=timezone.utc)
372
+ seconds = (dt - datetime.now(timezone.utc)).total_seconds()
373
+ return max(0.0, seconds)
374
+ except (TypeError, ValueError, OverflowError):
375
+ return None
376
+
377
+
378
+ def _sleep_sync(delay: float) -> None:
379
+ if delay > 0:
380
+ time.sleep(delay)
381
+
382
+
383
+ async def _sleep_async(delay: float) -> None:
384
+ if delay > 0:
385
+ await asyncio.sleep(delay)
@@ -0,0 +1,49 @@
1
+ """SDK-specific exception hierarchy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, Optional
6
+
7
+ from .models import ChatAdsResponse
8
+
9
+
10
+ class ChatAdsSDKError(Exception):
11
+ """Base class for local SDK issues (serialization, transport, etc.)."""
12
+
13
+
14
+ class ChatAdsAPIError(ChatAdsSDKError):
15
+ """Raised for non-2xx responses returned by the ChatAds API."""
16
+
17
+ def __init__(
18
+ self,
19
+ status_code: int,
20
+ payload: Optional[Dict[str, Any]] = None,
21
+ response: Optional[ChatAdsResponse] = None,
22
+ headers: Optional[Dict[str, str]] = None,
23
+ request_body: Optional[Dict[str, Any]] = None,
24
+ url: Optional[str] = None,
25
+ ) -> None:
26
+ self.status_code = status_code
27
+ self.payload = payload or {}
28
+ self.response = response
29
+ self.headers = headers or {}
30
+ self.request_body = request_body or {}
31
+ self.url = url
32
+ message = self._build_message()
33
+ super().__init__(message)
34
+
35
+ def _build_message(self) -> str:
36
+ if self.response and self.response.error:
37
+ return (
38
+ f"ChatAds API error {self.status_code}: "
39
+ f"{self.response.error.code} - {self.response.error.message}"
40
+ )
41
+ return f"ChatAds API error {self.status_code}"
42
+
43
+ @property
44
+ def retry_after(self) -> Optional[str]:
45
+ """Expose Retry-After header when rate limits are hit."""
46
+ for key, value in self.headers.items():
47
+ if key.lower() == "retry-after":
48
+ return value
49
+ return None
@@ -0,0 +1,227 @@
1
+ """Dataclasses that mirror the ChatAds FastAPI request/response models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Dict, Optional
7
+
8
+ FUNCTION_ITEM_OPTIONAL_FIELDS = (
9
+ "page_url",
10
+ "page_title",
11
+ "referrer",
12
+ "address",
13
+ "email",
14
+ "type",
15
+ "domain",
16
+ "user_agent",
17
+ "ip",
18
+ "reason",
19
+ "company",
20
+ "name",
21
+ "country",
22
+ "language",
23
+ "website",
24
+ )
25
+
26
+ _CAMELCASE_ALIASES = {
27
+ "pageurl": "page_url",
28
+ "pagetitle": "page_title",
29
+ "useragent": "user_agent",
30
+ }
31
+
32
+ FUNCTION_ITEM_FIELD_ALIASES = {
33
+ **{field: field for field in FUNCTION_ITEM_OPTIONAL_FIELDS},
34
+ **_CAMELCASE_ALIASES,
35
+ }
36
+
37
+ _FIELD_TO_PAYLOAD_KEY = {
38
+ "page_url": "pageUrl",
39
+ "page_title": "pageTitle",
40
+ "referrer": "referrer",
41
+ "address": "address",
42
+ "email": "email",
43
+ "type": "type",
44
+ "domain": "domain",
45
+ "user_agent": "userAgent",
46
+ "ip": "ip",
47
+ "reason": "reason",
48
+ "company": "company",
49
+ "name": "name",
50
+ "country": "country",
51
+ "language": "language",
52
+ "website": "website",
53
+ }
54
+
55
+ RESERVED_PAYLOAD_KEYS = frozenset({"message", *(_FIELD_TO_PAYLOAD_KEY.values())})
56
+
57
+
58
+ @dataclass
59
+ class ChatAdsAd:
60
+ product: str
61
+ link: str
62
+ message: str
63
+ category: str
64
+
65
+ @classmethod
66
+ def from_dict(cls, data: Optional[Dict[str, Any]]) -> Optional["ChatAdsAd"]:
67
+ if not data:
68
+ return None
69
+ return cls(
70
+ product=data.get("product", ""),
71
+ link=data.get("link", ""),
72
+ message=data.get("message", ""),
73
+ category=data.get("category", ""),
74
+ )
75
+
76
+
77
+ @dataclass
78
+ class ChatAdsData:
79
+ matched: bool
80
+ ad: Optional[ChatAdsAd] = None
81
+ reason: Optional[str] = None
82
+
83
+ @classmethod
84
+ def from_dict(cls, data: Optional[Dict[str, Any]]) -> Optional["ChatAdsData"]:
85
+ if not data:
86
+ return None
87
+ return cls(
88
+ matched=bool(data.get("matched", False)),
89
+ ad=ChatAdsAd.from_dict(data.get("ad")),
90
+ reason=data.get("reason"),
91
+ )
92
+
93
+
94
+ @dataclass
95
+ class ChatAdsError:
96
+ code: str
97
+ message: str
98
+ details: Dict[str, Any] = field(default_factory=dict)
99
+
100
+ @classmethod
101
+ def from_dict(cls, data: Optional[Dict[str, Any]]) -> Optional["ChatAdsError"]:
102
+ if not data:
103
+ return None
104
+ return cls(
105
+ code=data.get("code", "UNKNOWN"),
106
+ message=data.get("message", ""),
107
+ details=data.get("details") or {},
108
+ )
109
+
110
+
111
+ @dataclass
112
+ class UsageInfo:
113
+ monthly_requests: int
114
+ free_tier_limit: int
115
+ free_tier_remaining: int
116
+ is_free_tier: bool
117
+ has_credit_card: bool
118
+ daily_requests: Optional[int] = None
119
+ daily_limit: Optional[int] = None
120
+ minute_requests: Optional[int] = None
121
+ minute_limit: Optional[int] = None
122
+
123
+ @classmethod
124
+ def from_dict(cls, data: Optional[Dict[str, Any]]) -> Optional["UsageInfo"]:
125
+ if not data:
126
+ return None
127
+ return cls(
128
+ monthly_requests=int(data.get("monthly_requests") or 0),
129
+ free_tier_limit=int(data.get("free_tier_limit") or 0),
130
+ free_tier_remaining=int(data.get("free_tier_remaining") or 0),
131
+ is_free_tier=bool(data.get("is_free_tier", False)),
132
+ has_credit_card=bool(data.get("has_credit_card", False)),
133
+ daily_requests=_maybe_int(data.get("daily_requests")),
134
+ daily_limit=_maybe_int(data.get("daily_limit")),
135
+ minute_requests=_maybe_int(data.get("minute_requests")),
136
+ minute_limit=_maybe_int(data.get("minute_limit")),
137
+ )
138
+
139
+
140
+ @dataclass
141
+ class ChatAdsMeta:
142
+ request_id: str
143
+ user_id: Optional[str] = None
144
+ country: Optional[str] = None
145
+ language: Optional[str] = None
146
+ processing_time_ms: Optional[float] = None
147
+ usage: Optional[UsageInfo] = None
148
+ raw: Dict[str, Any] = field(default_factory=dict)
149
+
150
+ @classmethod
151
+ def from_dict(cls, data: Optional[Dict[str, Any]]) -> "ChatAdsMeta":
152
+ data = data or {}
153
+ return cls(
154
+ request_id=data.get("request_id", ""),
155
+ user_id=data.get("user_id"),
156
+ country=data.get("country"),
157
+ language=data.get("language"),
158
+ processing_time_ms=data.get("processing_time_ms"),
159
+ usage=UsageInfo.from_dict(data.get("usage")),
160
+ raw=data,
161
+ )
162
+
163
+
164
+ @dataclass
165
+ class ChatAdsResponse:
166
+ success: bool
167
+ meta: ChatAdsMeta
168
+ data: Optional[ChatAdsData] = None
169
+ error: Optional[ChatAdsError] = None
170
+ raw: Dict[str, Any] = field(default_factory=dict)
171
+
172
+ @classmethod
173
+ def from_dict(cls, data: Dict[str, Any]) -> "ChatAdsResponse":
174
+ data = data or {}
175
+ return cls(
176
+ success=bool(data.get("success", False)),
177
+ data=ChatAdsData.from_dict(data.get("data")),
178
+ error=ChatAdsError.from_dict(data.get("error")),
179
+ meta=ChatAdsMeta.from_dict(data.get("meta")),
180
+ raw=data,
181
+ )
182
+
183
+
184
+ @dataclass
185
+ class FunctionItemPayload:
186
+ """Subset of the server's FunctionItem pydantic model."""
187
+
188
+ message: str
189
+ page_url: Optional[str] = None
190
+ page_title: Optional[str] = None
191
+ referrer: Optional[str] = None
192
+ address: Optional[str] = None
193
+ email: Optional[str] = None
194
+ type: Optional[str] = None
195
+ domain: Optional[str] = None
196
+ user_agent: Optional[str] = None
197
+ ip: Optional[str] = None
198
+ reason: Optional[str] = None
199
+ company: Optional[str] = None
200
+ name: Optional[str] = None
201
+ country: Optional[str] = None
202
+ language: Optional[str] = None
203
+ website: Optional[str] = None
204
+ extra_fields: Dict[str, Any] = field(default_factory=dict)
205
+
206
+ def to_payload(self) -> Dict[str, Any]:
207
+ payload = {"message": self.message}
208
+ for field_name, payload_key in _FIELD_TO_PAYLOAD_KEY.items():
209
+ value = getattr(self, field_name)
210
+ if value is not None:
211
+ payload[payload_key] = value
212
+
213
+ conflicts = RESERVED_PAYLOAD_KEYS.intersection(self.extra_fields.keys())
214
+ if conflicts:
215
+ conflict_list = ", ".join(sorted(conflicts))
216
+ raise ValueError(
217
+ f"extra_fields contains reserved keys that would override core payload data: {conflict_list}"
218
+ )
219
+ payload.update(self.extra_fields)
220
+ return payload
221
+
222
+
223
+ def _maybe_int(value: Any) -> Optional[int]:
224
+ try:
225
+ return int(value)
226
+ except (TypeError, ValueError):
227
+ return None
@@ -0,0 +1,74 @@
1
+ Metadata-Version: 2.4
2
+ Name: chatads-sdk
3
+ Version: 0.1.0
4
+ Summary: Lightweight Python client for the ChatAds affiliate scoring API
5
+ Author-email: ChatAds <support@getchatads.com>
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: httpx<1.0,>=0.27
9
+ Provides-Extra: async
10
+ Requires-Dist: httpx[http2]<1.0,>=0.27; extra == "async"
11
+
12
+ # ChatAds Python SDK
13
+
14
+ A tiny, dependency-light wrapper around the ChatAds `/v1/chatads-script` endpoint. It mirrors the response payloads returned by the FastAPI service so you can drop it into CLIs, serverless functions, or orchestration tools.
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ pip install .
20
+ ```
21
+
22
+ (Or build/upload to your internal index as needed.)
23
+
24
+ ## Quickstart
25
+
26
+ ```python
27
+ from chatads_sdk import ChatAdsClient, FunctionItemPayload
28
+
29
+ client = ChatAdsClient(
30
+ api_key="YOUR_X_API_KEY",
31
+ base_url="https://<your-chatads-domain>",
32
+ raise_on_failure=True, # Treat success=False payloads as exceptions
33
+ max_retries=2, # Optional automatic retries for 429/5xx responses
34
+ retry_backoff_factor=0.75, # Exponential backoff multiplier
35
+ )
36
+
37
+ payload = FunctionItemPayload(
38
+ message="Looking for a CRM to close more deals",
39
+ ip="1.2.3.4",
40
+ user_agent="Mozilla/5.0",
41
+ )
42
+
43
+ result = client.analyze(payload)
44
+
45
+ if result.success:
46
+ print(result.data.ad)
47
+ else:
48
+ print(result.error.code, result.error.message)
49
+ ```
50
+
51
+ ## Error Handling
52
+
53
+ Non-2xx responses raise `ChatAdsAPIError` and include the parsed error payload plus the original HTTP status code so you can branch on quota/validation failures. Set `raise_on_failure=True` if you want 200 responses with `success=false` to raise the same exception class.
54
+
55
+ ## Notes
56
+
57
+ - Retries are opt-in. Provide `max_retries>0` to automatically retry transport errors and retryable status codes. The client honors `Retry-After` headers and falls back to exponential backoff.
58
+ - `FunctionItemPayload` matches the server-side `FunctionItem` pydantic model. Keyword arguments passed to `ChatAdsClient.analyze_message()` accept either snake_case (`user_agent`) or camelCase (`userAgent`) keys.
59
+ - Reserved payload keys (e.g., `message`, `pageUrl`, `userAgent`) cannot be overridden through `extra_fields`; doing so raises `ValueError` to prevent silent mutations.
60
+
61
+ ## CLI Smoke Test
62
+
63
+ For a super-quick check, edit the config block at the top of `python_sdk/run_sdk_smoke.py` (or set the
64
+ `CHATADS_*` env vars) and run:
65
+
66
+ ```bash
67
+ PYTHONPATH=python_sdk python python_sdk/run_sdk_smoke.py
68
+ ```
69
+
70
+ It prints the raw JSON response or surfaces a `ChatAdsAPIError` with status/error fields so you can see
71
+ exactly what the API returned.
72
+
73
+ - `API_KEY` and `MESSAGE` are the only required values. Leave `CALLER_IP`, `USER_AGENT`, or `CHATADS_EXTRA_FIELDS`
74
+ blank to omit them from the request.
@@ -0,0 +1,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ chatads_sdk/__init__.py
4
+ chatads_sdk/client.py
5
+ chatads_sdk/exceptions.py
6
+ chatads_sdk/models.py
7
+ chatads_sdk.egg-info/PKG-INFO
8
+ chatads_sdk.egg-info/SOURCES.txt
9
+ chatads_sdk.egg-info/dependency_links.txt
10
+ chatads_sdk.egg-info/requires.txt
11
+ chatads_sdk.egg-info/top_level.txt
@@ -0,0 +1,4 @@
1
+ httpx<1.0,>=0.27
2
+
3
+ [async]
4
+ httpx[http2]<1.0,>=0.27
@@ -0,0 +1 @@
1
+ chatads_sdk
@@ -0,0 +1,17 @@
1
+ [project]
2
+ name = "chatads-sdk"
3
+ version = "0.1.0"
4
+ description = "Lightweight Python client for the ChatAds affiliate scoring API"
5
+ authors = [{name = "ChatAds", email = "support@getchatads.com"}]
6
+ readme = "README.md"
7
+ requires-python = ">=3.9"
8
+ dependencies = [
9
+ "httpx>=0.27,<1.0",
10
+ ]
11
+
12
+ [project.optional-dependencies]
13
+ async = ["httpx[http2]>=0.27,<1.0"]
14
+
15
+ [build-system]
16
+ requires = ["setuptools>=68"]
17
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+