precleared 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.
- precleared/__init__.py +65 -0
- precleared/_schemas.py +36 -0
- precleared/_tools.py +234 -0
- precleared/_version.py +1 -0
- precleared/client.py +561 -0
- precleared/crewai.py +98 -0
- precleared/errors.py +115 -0
- precleared/langchain.py +84 -0
- precleared/llamaindex.py +74 -0
- precleared/models.py +426 -0
- precleared/openai_agents.py +122 -0
- precleared/py.typed +0 -0
- precleared-0.1.0.dist-info/METADATA +240 -0
- precleared-0.1.0.dist-info/RECORD +16 -0
- precleared-0.1.0.dist-info/WHEEL +4 -0
- precleared-0.1.0.dist-info/licenses/LICENSE +21 -0
precleared/client.py
ADDED
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
"""Sync and async clients for the Precleared REST API.
|
|
2
|
+
|
|
3
|
+
Both clients expose the same three calls:
|
|
4
|
+
|
|
5
|
+
* ``check(...)`` runs one cold-email legality check and returns a
|
|
6
|
+
:class:`~precleared.models.CheckResult`.
|
|
7
|
+
* ``verify_receipt(id)`` fetches a signed receipt by id and returns a
|
|
8
|
+
:class:`~precleared.models.Receipt`.
|
|
9
|
+
* ``health()`` returns the latest service pulse as a
|
|
10
|
+
:class:`~precleared.models.Health`.
|
|
11
|
+
|
|
12
|
+
The API key is sent as ``Authorization: Bearer ...`` and is never logged,
|
|
13
|
+
printed or included in an exception.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import email.utils
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from typing import Any, Dict, Optional, Union
|
|
23
|
+
|
|
24
|
+
import httpx
|
|
25
|
+
|
|
26
|
+
from ._version import __version__
|
|
27
|
+
from .errors import (
|
|
28
|
+
DailyLimit,
|
|
29
|
+
InvalidKey,
|
|
30
|
+
InvalidRequest,
|
|
31
|
+
NetworkError,
|
|
32
|
+
PaymentRequired,
|
|
33
|
+
PreclearedError,
|
|
34
|
+
RateLimited,
|
|
35
|
+
ReceiptNotFound,
|
|
36
|
+
ServerError,
|
|
37
|
+
)
|
|
38
|
+
from .models import ADDRESS_SOURCES, DEFAULT_BASE_URL, CheckResult, Health, Receipt
|
|
39
|
+
|
|
40
|
+
DEFAULT_USER_AGENT = f"precleared-python/{__version__}"
|
|
41
|
+
DEFAULT_TIMEOUT = 30.0
|
|
42
|
+
ENV_API_KEY = "PRECLEARED_API_KEY"
|
|
43
|
+
|
|
44
|
+
_COUNTRY_RE = re.compile(r"^[A-Z]{2}$")
|
|
45
|
+
_RECEIPT_ID_RE = re.compile(r"cc_rcpt_[0-9A-Za-z]+")
|
|
46
|
+
|
|
47
|
+
Timeout = Union[float, int, httpx.Timeout, None]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
# Request building and validation (shared by both clients)
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _resolve_api_key(api_key: Optional[str]) -> Optional[str]:
|
|
56
|
+
if api_key is None:
|
|
57
|
+
api_key = os.environ.get(ENV_API_KEY)
|
|
58
|
+
if api_key is None:
|
|
59
|
+
return None
|
|
60
|
+
if not isinstance(api_key, str):
|
|
61
|
+
raise TypeError("api_key must be a string or None")
|
|
62
|
+
api_key = api_key.strip()
|
|
63
|
+
return api_key or None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _normalise_country(value: Any, name: str) -> str:
|
|
67
|
+
if not isinstance(value, str):
|
|
68
|
+
raise ValueError(f"{name} must be a two-letter country code such as 'GB'")
|
|
69
|
+
code = value.strip().upper()
|
|
70
|
+
if not _COUNTRY_RE.match(code):
|
|
71
|
+
raise ValueError(f"{name} must be a two-letter country code such as 'GB', got {value!r}")
|
|
72
|
+
return code
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _require_bool(value: Any, name: str) -> bool:
|
|
76
|
+
if not isinstance(value, bool):
|
|
77
|
+
raise ValueError(f"{name} must be True or False, got {value!r}")
|
|
78
|
+
return value
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def build_check_body(
|
|
82
|
+
recipient_email: Optional[str] = None,
|
|
83
|
+
*,
|
|
84
|
+
recipient_domain: Optional[str] = None,
|
|
85
|
+
pitch: Optional[str] = None,
|
|
86
|
+
address_source: str = "unknown",
|
|
87
|
+
sender_country: str = "GB",
|
|
88
|
+
existing_customer: bool = False,
|
|
89
|
+
pitch_relevant: Optional[bool] = None,
|
|
90
|
+
company_number: Optional[Union[str, int]] = None,
|
|
91
|
+
recipient_country: Optional[str] = None,
|
|
92
|
+
default_recipient_country: Optional[str] = None,
|
|
93
|
+
suppressed: Optional[bool] = None,
|
|
94
|
+
**extra: Any,
|
|
95
|
+
) -> Dict[str, Any]:
|
|
96
|
+
"""Validate the check fields and build the JSON body.
|
|
97
|
+
|
|
98
|
+
Raises :class:`ValueError` before anything is sent when a field is
|
|
99
|
+
malformed. ``address_source`` is always sent (``unknown`` unless you say
|
|
100
|
+
otherwise), so the caller's choice is explicit in every receipt.
|
|
101
|
+
"""
|
|
102
|
+
recipient_email = recipient_email.strip() if isinstance(recipient_email, str) else recipient_email
|
|
103
|
+
recipient_domain = recipient_domain.strip() if isinstance(recipient_domain, str) else recipient_domain
|
|
104
|
+
if not recipient_email and not recipient_domain:
|
|
105
|
+
raise ValueError("recipient_email or recipient_domain is required")
|
|
106
|
+
if recipient_email is not None and not isinstance(recipient_email, str):
|
|
107
|
+
raise ValueError("recipient_email must be a string")
|
|
108
|
+
if recipient_domain is not None and not isinstance(recipient_domain, str):
|
|
109
|
+
raise ValueError("recipient_domain must be a string")
|
|
110
|
+
|
|
111
|
+
if not isinstance(address_source, str):
|
|
112
|
+
raise ValueError(f"address_source must be one of {', '.join(ADDRESS_SOURCES)}, got {address_source!r}")
|
|
113
|
+
source = address_source.strip().lower()
|
|
114
|
+
if source not in ADDRESS_SOURCES:
|
|
115
|
+
raise ValueError(f"address_source must be one of {', '.join(ADDRESS_SOURCES)}, got {address_source!r}")
|
|
116
|
+
|
|
117
|
+
body: Dict[str, Any] = {}
|
|
118
|
+
if recipient_email:
|
|
119
|
+
body["recipient_email"] = recipient_email
|
|
120
|
+
if recipient_domain:
|
|
121
|
+
body["recipient_domain"] = recipient_domain
|
|
122
|
+
body["sender_country"] = _normalise_country(sender_country, "sender_country")
|
|
123
|
+
body["address_source"] = source
|
|
124
|
+
body["existing_customer"] = _require_bool(existing_customer, "existing_customer")
|
|
125
|
+
|
|
126
|
+
if pitch is not None:
|
|
127
|
+
if not isinstance(pitch, str):
|
|
128
|
+
raise ValueError("pitch must be a string")
|
|
129
|
+
pitch = pitch.strip()
|
|
130
|
+
if pitch:
|
|
131
|
+
body["pitch"] = pitch
|
|
132
|
+
if pitch_relevant is not None:
|
|
133
|
+
body["pitch_relevant"] = _require_bool(pitch_relevant, "pitch_relevant")
|
|
134
|
+
if company_number is not None:
|
|
135
|
+
number = str(company_number).strip()
|
|
136
|
+
if number:
|
|
137
|
+
body["company_number"] = number
|
|
138
|
+
if recipient_country is not None:
|
|
139
|
+
body["recipient_country"] = _normalise_country(recipient_country, "recipient_country")
|
|
140
|
+
if default_recipient_country is not None:
|
|
141
|
+
body["default_recipient_country"] = _normalise_country(
|
|
142
|
+
default_recipient_country, "default_recipient_country"
|
|
143
|
+
)
|
|
144
|
+
if suppressed is not None:
|
|
145
|
+
body["suppressed"] = _require_bool(suppressed, "suppressed")
|
|
146
|
+
|
|
147
|
+
for key, value in extra.items():
|
|
148
|
+
if value is not None and key not in body:
|
|
149
|
+
body[key] = value
|
|
150
|
+
return body
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def normalise_receipt_id(receipt_id: Any) -> str:
|
|
154
|
+
"""Accept a bare id or a receipt URL and return the ``cc_rcpt_...`` id."""
|
|
155
|
+
if not isinstance(receipt_id, str) or not receipt_id.strip():
|
|
156
|
+
raise ValueError("receipt id is required, for example 'cc_rcpt_...'")
|
|
157
|
+
value = receipt_id.strip()
|
|
158
|
+
match = _RECEIPT_ID_RE.search(value)
|
|
159
|
+
if match:
|
|
160
|
+
return match.group(0)
|
|
161
|
+
if "/" in value or "?" in value or "=" in value or " " in value:
|
|
162
|
+
raise ValueError(f"not a receipt id: {receipt_id!r}")
|
|
163
|
+
return value
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
# Response handling (shared by both clients)
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def parse_retry_after(body_value: Any = None, header_value: Optional[str] = None) -> Optional[float]:
|
|
172
|
+
"""Turn ``retry_after`` (body) or ``Retry-After`` (header) into seconds.
|
|
173
|
+
|
|
174
|
+
The header may be a number of seconds or an HTTP-date. Returns ``None``
|
|
175
|
+
when neither is usable.
|
|
176
|
+
"""
|
|
177
|
+
if body_value is not None and not isinstance(body_value, bool):
|
|
178
|
+
if isinstance(body_value, (int, float)):
|
|
179
|
+
return max(0.0, float(body_value))
|
|
180
|
+
try:
|
|
181
|
+
return max(0.0, float(str(body_value).strip()))
|
|
182
|
+
except ValueError:
|
|
183
|
+
pass
|
|
184
|
+
if header_value is None:
|
|
185
|
+
return None
|
|
186
|
+
text = header_value.strip()
|
|
187
|
+
if not text:
|
|
188
|
+
return None
|
|
189
|
+
try:
|
|
190
|
+
return max(0.0, float(text))
|
|
191
|
+
except ValueError:
|
|
192
|
+
pass
|
|
193
|
+
try:
|
|
194
|
+
when = email.utils.parsedate_to_datetime(text)
|
|
195
|
+
except (TypeError, ValueError, IndexError):
|
|
196
|
+
return None
|
|
197
|
+
if when is None:
|
|
198
|
+
return None
|
|
199
|
+
if when.tzinfo is None:
|
|
200
|
+
when = when.replace(tzinfo=timezone.utc)
|
|
201
|
+
return max(0.0, (when - datetime.now(timezone.utc)).total_seconds())
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _safe_json_object(response: httpx.Response) -> Dict[str, Any]:
|
|
205
|
+
try:
|
|
206
|
+
data = response.json()
|
|
207
|
+
except ValueError:
|
|
208
|
+
return {}
|
|
209
|
+
return data if isinstance(data, dict) else {}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _string(value: Any) -> Optional[str]:
|
|
213
|
+
return value if isinstance(value, str) and value else None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def error_from_response(response: httpx.Response, *, receipt: bool = False) -> PreclearedError:
|
|
217
|
+
"""Map an error response to the matching exception (does not raise)."""
|
|
218
|
+
status = response.status_code
|
|
219
|
+
body = _safe_json_object(response)
|
|
220
|
+
code = _string(body.get("error"))
|
|
221
|
+
message = _string(body.get("message"))
|
|
222
|
+
common: Dict[str, Any] = {"status_code": status, "body": body}
|
|
223
|
+
|
|
224
|
+
if status == 400:
|
|
225
|
+
return InvalidRequest(message or "Precleared rejected the request body.", code=code or "invalid_request", **common)
|
|
226
|
+
if status == 401:
|
|
227
|
+
return InvalidKey(message or "The API key is invalid or has been revoked.", code=code or "invalid_key", **common)
|
|
228
|
+
if status == 402:
|
|
229
|
+
return PaymentRequired(
|
|
230
|
+
message or "Free daily checks are used and this key has no pack credits.",
|
|
231
|
+
buy_url=_string(body.get("buy_url")),
|
|
232
|
+
code=code or "payment_required",
|
|
233
|
+
**common,
|
|
234
|
+
)
|
|
235
|
+
if status == 404 and receipt:
|
|
236
|
+
return ReceiptNotFound(message or "No receipt has that id.", code=code or "not_found", **common)
|
|
237
|
+
if status == 429:
|
|
238
|
+
retry_after = parse_retry_after(body.get("retry_after"), response.headers.get("Retry-After"))
|
|
239
|
+
if code == "daily_limit" or (code is None and "get_key_url" in body):
|
|
240
|
+
return DailyLimit(
|
|
241
|
+
message or "The free daily allowance is used up.",
|
|
242
|
+
get_key_url=_string(body.get("get_key_url")),
|
|
243
|
+
code="daily_limit",
|
|
244
|
+
**common,
|
|
245
|
+
)
|
|
246
|
+
return RateLimited(
|
|
247
|
+
message or "Too many checks: the limit is 60 a minute per key.",
|
|
248
|
+
retry_after=retry_after,
|
|
249
|
+
code=code or "rate_limited",
|
|
250
|
+
**common,
|
|
251
|
+
)
|
|
252
|
+
if status >= 500:
|
|
253
|
+
return ServerError(message or f"Precleared returned HTTP {status}.", code=code or "server_error", **common)
|
|
254
|
+
return PreclearedError(message or f"Unexpected HTTP {status} from Precleared.", code=code, **common)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def parse_json_object(response: httpx.Response) -> Dict[str, Any]:
|
|
258
|
+
try:
|
|
259
|
+
data = response.json()
|
|
260
|
+
except ValueError as exc:
|
|
261
|
+
raise PreclearedError(
|
|
262
|
+
"Precleared returned a response that is not JSON.",
|
|
263
|
+
status_code=response.status_code,
|
|
264
|
+
code="invalid_response",
|
|
265
|
+
) from exc
|
|
266
|
+
if not isinstance(data, dict):
|
|
267
|
+
raise PreclearedError(
|
|
268
|
+
"Precleared returned JSON that is not an object.",
|
|
269
|
+
status_code=response.status_code,
|
|
270
|
+
code="invalid_response",
|
|
271
|
+
)
|
|
272
|
+
return data
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def receipt_from_payload(data: Dict[str, Any]) -> Receipt:
|
|
276
|
+
"""Build a Receipt from the receipt endpoint's JSON, flat or wrapped."""
|
|
277
|
+
inner = data.get("receipt")
|
|
278
|
+
if isinstance(inner, dict) and (inner.get("id") or inner.get("receipt_id")):
|
|
279
|
+
merged = dict(inner)
|
|
280
|
+
for key in ("public_key", "key_version", "algorithm", "verify_url"):
|
|
281
|
+
if key not in merged and key in data:
|
|
282
|
+
merged[key] = data[key]
|
|
283
|
+
receipt = Receipt.from_dict(merged)
|
|
284
|
+
receipt.raw = dict(data)
|
|
285
|
+
return receipt
|
|
286
|
+
return Receipt.from_dict(data)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _wrap_network_error(exc: httpx.HTTPError) -> NetworkError:
|
|
290
|
+
name = type(exc).__name__
|
|
291
|
+
detail = str(exc).strip()
|
|
292
|
+
message = f"Could not reach Precleared ({name})" + (f": {detail}" if detail else ".")
|
|
293
|
+
return NetworkError(message, code="network_error")
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# ---------------------------------------------------------------------------
|
|
297
|
+
# Clients
|
|
298
|
+
# ---------------------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
class _Base:
|
|
302
|
+
def __init__(
|
|
303
|
+
self,
|
|
304
|
+
api_key: Optional[str] = None,
|
|
305
|
+
*,
|
|
306
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
307
|
+
timeout: Timeout = DEFAULT_TIMEOUT,
|
|
308
|
+
user_agent: str = DEFAULT_USER_AGENT,
|
|
309
|
+
) -> None:
|
|
310
|
+
if not isinstance(base_url, str) or not base_url.startswith(("http://", "https://")):
|
|
311
|
+
raise ValueError("base_url must start with http:// or https://")
|
|
312
|
+
self._api_key = _resolve_api_key(api_key)
|
|
313
|
+
self.base_url = base_url.rstrip("/")
|
|
314
|
+
self.timeout = timeout
|
|
315
|
+
self.user_agent = user_agent
|
|
316
|
+
|
|
317
|
+
@property
|
|
318
|
+
def has_api_key(self) -> bool:
|
|
319
|
+
"""Whether requests carry a key. The key itself is never exposed."""
|
|
320
|
+
return self._api_key is not None
|
|
321
|
+
|
|
322
|
+
def _headers(self) -> Dict[str, str]:
|
|
323
|
+
headers = {"User-Agent": self.user_agent, "Accept": "application/json"}
|
|
324
|
+
if self._api_key:
|
|
325
|
+
headers["Authorization"] = f"Bearer {self._api_key}"
|
|
326
|
+
return headers
|
|
327
|
+
|
|
328
|
+
def __repr__(self) -> str:
|
|
329
|
+
key = "<set>" if self._api_key else "None"
|
|
330
|
+
return f"{type(self).__name__}(base_url={self.base_url!r}, api_key={key})"
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
class Precleared(_Base):
|
|
334
|
+
"""Synchronous client.
|
|
335
|
+
|
|
336
|
+
Args:
|
|
337
|
+
api_key: ``pc_live_...`` key. Optional: without one the keyless
|
|
338
|
+
allowance applies (10 checks a day per network). Falls back to
|
|
339
|
+
the ``PRECLEARED_API_KEY`` environment variable.
|
|
340
|
+
base_url: API origin. Only change this for testing.
|
|
341
|
+
timeout: Seconds to wait for a response. A check can take up to 25
|
|
342
|
+
seconds when a register or website is slow.
|
|
343
|
+
user_agent: Sent on every request. Precleared's hosting refuses
|
|
344
|
+
requests without a User-Agent.
|
|
345
|
+
transport: An ``httpx`` transport, for tests.
|
|
346
|
+
"""
|
|
347
|
+
|
|
348
|
+
def __init__(
|
|
349
|
+
self,
|
|
350
|
+
api_key: Optional[str] = None,
|
|
351
|
+
*,
|
|
352
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
353
|
+
timeout: Timeout = DEFAULT_TIMEOUT,
|
|
354
|
+
user_agent: str = DEFAULT_USER_AGENT,
|
|
355
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
356
|
+
) -> None:
|
|
357
|
+
super().__init__(api_key, base_url=base_url, timeout=timeout, user_agent=user_agent)
|
|
358
|
+
self._http = httpx.Client(
|
|
359
|
+
base_url=self.base_url,
|
|
360
|
+
timeout=timeout,
|
|
361
|
+
headers=self._headers(),
|
|
362
|
+
transport=transport,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
def __enter__(self) -> Precleared:
|
|
366
|
+
return self
|
|
367
|
+
|
|
368
|
+
def __exit__(self, *exc_info: Any) -> None:
|
|
369
|
+
self.close()
|
|
370
|
+
|
|
371
|
+
def close(self) -> None:
|
|
372
|
+
self._http.close()
|
|
373
|
+
|
|
374
|
+
def _request(self, method: str, path: str, *, receipt: bool = False, **kwargs: Any) -> httpx.Response:
|
|
375
|
+
try:
|
|
376
|
+
response = self._http.request(method, path, **kwargs)
|
|
377
|
+
except httpx.HTTPError as exc:
|
|
378
|
+
raise _wrap_network_error(exc) from exc
|
|
379
|
+
if response.status_code >= 400:
|
|
380
|
+
raise error_from_response(response, receipt=receipt)
|
|
381
|
+
return response
|
|
382
|
+
|
|
383
|
+
def check(
|
|
384
|
+
self,
|
|
385
|
+
recipient_email: Optional[str] = None,
|
|
386
|
+
*,
|
|
387
|
+
recipient_domain: Optional[str] = None,
|
|
388
|
+
pitch: Optional[str] = None,
|
|
389
|
+
address_source: str = "unknown",
|
|
390
|
+
sender_country: str = "GB",
|
|
391
|
+
existing_customer: bool = False,
|
|
392
|
+
pitch_relevant: Optional[bool] = None,
|
|
393
|
+
company_number: Optional[Union[str, int]] = None,
|
|
394
|
+
recipient_country: Optional[str] = None,
|
|
395
|
+
default_recipient_country: Optional[str] = None,
|
|
396
|
+
suppressed: Optional[bool] = None,
|
|
397
|
+
**extra: Any,
|
|
398
|
+
) -> CheckResult:
|
|
399
|
+
"""Check whether a cold email may lawfully be sent to one address.
|
|
400
|
+
|
|
401
|
+
Args:
|
|
402
|
+
recipient_email: The address you want to email. One of this or
|
|
403
|
+
``recipient_domain`` is required.
|
|
404
|
+
recipient_domain: Check a domain when you do not have an address yet.
|
|
405
|
+
pitch: What you are offering, in a sentence.
|
|
406
|
+
address_source: How you got the address: ``published``, ``bought``,
|
|
407
|
+
``scraped``, ``given`` or ``unknown``. Defaults to ``unknown``.
|
|
408
|
+
Only say ``published`` when the recipient put the address on
|
|
409
|
+
their own website or a public listing for people to contact them.
|
|
410
|
+
sender_country: Two-letter code for where you send from. Default ``GB``.
|
|
411
|
+
existing_customer: True if the recipient has bought from you.
|
|
412
|
+
pitch_relevant: Whether the pitch relates to the recipient's role.
|
|
413
|
+
company_number: UK Companies House number, when you know it.
|
|
414
|
+
recipient_country: Two-letter code. Overrides what Precleared infers.
|
|
415
|
+
default_recipient_country: Used only when the country cannot be
|
|
416
|
+
worked out from the address, register or website.
|
|
417
|
+
suppressed: True if the address is on your own suppression list.
|
|
418
|
+
**extra: Forwarded as is, for request fields newer than this client.
|
|
419
|
+
|
|
420
|
+
Raises:
|
|
421
|
+
ValueError: for a malformed field, before anything is sent.
|
|
422
|
+
PreclearedError: a subclass per API error, see :mod:`precleared.errors`.
|
|
423
|
+
"""
|
|
424
|
+
body = build_check_body(
|
|
425
|
+
recipient_email,
|
|
426
|
+
recipient_domain=recipient_domain,
|
|
427
|
+
pitch=pitch,
|
|
428
|
+
address_source=address_source,
|
|
429
|
+
sender_country=sender_country,
|
|
430
|
+
existing_customer=existing_customer,
|
|
431
|
+
pitch_relevant=pitch_relevant,
|
|
432
|
+
company_number=company_number,
|
|
433
|
+
recipient_country=recipient_country,
|
|
434
|
+
default_recipient_country=default_recipient_country,
|
|
435
|
+
suppressed=suppressed,
|
|
436
|
+
**extra,
|
|
437
|
+
)
|
|
438
|
+
response = self._request("POST", "/api/v1/check", json=body)
|
|
439
|
+
return CheckResult.from_dict(parse_json_object(response), base_url=self.base_url)
|
|
440
|
+
|
|
441
|
+
def verify_receipt(self, receipt_id: str) -> Receipt:
|
|
442
|
+
"""Fetch a signed receipt by id (``cc_rcpt_...``) or receipt URL.
|
|
443
|
+
|
|
444
|
+
Raises :class:`~precleared.errors.ReceiptNotFound` when no receipt
|
|
445
|
+
has that id.
|
|
446
|
+
"""
|
|
447
|
+
rid = normalise_receipt_id(receipt_id)
|
|
448
|
+
response = self._request("GET", "/api/v1/receipt", params={"id": rid}, receipt=True)
|
|
449
|
+
return receipt_from_payload(parse_json_object(response))
|
|
450
|
+
|
|
451
|
+
def health(self) -> Health:
|
|
452
|
+
"""Return the latest five-minute health pulse."""
|
|
453
|
+
response = self._request("GET", "/api/v1/health")
|
|
454
|
+
return Health.from_dict(parse_json_object(response))
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
class AsyncPrecleared(_Base):
|
|
458
|
+
"""Asynchronous client with the same surface as :class:`Precleared`."""
|
|
459
|
+
|
|
460
|
+
def __init__(
|
|
461
|
+
self,
|
|
462
|
+
api_key: Optional[str] = None,
|
|
463
|
+
*,
|
|
464
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
465
|
+
timeout: Timeout = DEFAULT_TIMEOUT,
|
|
466
|
+
user_agent: str = DEFAULT_USER_AGENT,
|
|
467
|
+
transport: Optional[httpx.AsyncBaseTransport] = None,
|
|
468
|
+
) -> None:
|
|
469
|
+
super().__init__(api_key, base_url=base_url, timeout=timeout, user_agent=user_agent)
|
|
470
|
+
self._http = httpx.AsyncClient(
|
|
471
|
+
base_url=self.base_url,
|
|
472
|
+
timeout=timeout,
|
|
473
|
+
headers=self._headers(),
|
|
474
|
+
transport=transport,
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
async def __aenter__(self) -> AsyncPrecleared:
|
|
478
|
+
return self
|
|
479
|
+
|
|
480
|
+
async def __aexit__(self, *exc_info: Any) -> None:
|
|
481
|
+
await self.aclose()
|
|
482
|
+
|
|
483
|
+
async def aclose(self) -> None:
|
|
484
|
+
await self._http.aclose()
|
|
485
|
+
|
|
486
|
+
async def _request(self, method: str, path: str, *, receipt: bool = False, **kwargs: Any) -> httpx.Response:
|
|
487
|
+
try:
|
|
488
|
+
response = await self._http.request(method, path, **kwargs)
|
|
489
|
+
except httpx.HTTPError as exc:
|
|
490
|
+
raise _wrap_network_error(exc) from exc
|
|
491
|
+
if response.status_code >= 400:
|
|
492
|
+
raise error_from_response(response, receipt=receipt)
|
|
493
|
+
return response
|
|
494
|
+
|
|
495
|
+
async def check(
|
|
496
|
+
self,
|
|
497
|
+
recipient_email: Optional[str] = None,
|
|
498
|
+
*,
|
|
499
|
+
recipient_domain: Optional[str] = None,
|
|
500
|
+
pitch: Optional[str] = None,
|
|
501
|
+
address_source: str = "unknown",
|
|
502
|
+
sender_country: str = "GB",
|
|
503
|
+
existing_customer: bool = False,
|
|
504
|
+
pitch_relevant: Optional[bool] = None,
|
|
505
|
+
company_number: Optional[Union[str, int]] = None,
|
|
506
|
+
recipient_country: Optional[str] = None,
|
|
507
|
+
default_recipient_country: Optional[str] = None,
|
|
508
|
+
suppressed: Optional[bool] = None,
|
|
509
|
+
**extra: Any,
|
|
510
|
+
) -> CheckResult:
|
|
511
|
+
"""Async version of :meth:`Precleared.check`."""
|
|
512
|
+
body = build_check_body(
|
|
513
|
+
recipient_email,
|
|
514
|
+
recipient_domain=recipient_domain,
|
|
515
|
+
pitch=pitch,
|
|
516
|
+
address_source=address_source,
|
|
517
|
+
sender_country=sender_country,
|
|
518
|
+
existing_customer=existing_customer,
|
|
519
|
+
pitch_relevant=pitch_relevant,
|
|
520
|
+
company_number=company_number,
|
|
521
|
+
recipient_country=recipient_country,
|
|
522
|
+
default_recipient_country=default_recipient_country,
|
|
523
|
+
suppressed=suppressed,
|
|
524
|
+
**extra,
|
|
525
|
+
)
|
|
526
|
+
response = await self._request("POST", "/api/v1/check", json=body)
|
|
527
|
+
return CheckResult.from_dict(parse_json_object(response), base_url=self.base_url)
|
|
528
|
+
|
|
529
|
+
async def verify_receipt(self, receipt_id: str) -> Receipt:
|
|
530
|
+
"""Async version of :meth:`Precleared.verify_receipt`."""
|
|
531
|
+
rid = normalise_receipt_id(receipt_id)
|
|
532
|
+
response = await self._request("GET", "/api/v1/receipt", params={"id": rid}, receipt=True)
|
|
533
|
+
return receipt_from_payload(parse_json_object(response))
|
|
534
|
+
|
|
535
|
+
async def health(self) -> Health:
|
|
536
|
+
"""Async version of :meth:`Precleared.health`."""
|
|
537
|
+
response = await self._request("GET", "/api/v1/health")
|
|
538
|
+
return Health.from_dict(parse_json_object(response))
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
__all__ = [
|
|
542
|
+
"AsyncPrecleared",
|
|
543
|
+
"DEFAULT_TIMEOUT",
|
|
544
|
+
"DEFAULT_USER_AGENT",
|
|
545
|
+
"ENV_API_KEY",
|
|
546
|
+
"Precleared",
|
|
547
|
+
"build_check_body",
|
|
548
|
+
"error_from_response",
|
|
549
|
+
"normalise_receipt_id",
|
|
550
|
+
"parse_retry_after",
|
|
551
|
+
# Re-exported so ``from precleared.client import InvalidKey`` works too.
|
|
552
|
+
"PreclearedError",
|
|
553
|
+
"InvalidRequest",
|
|
554
|
+
"InvalidKey",
|
|
555
|
+
"PaymentRequired",
|
|
556
|
+
"DailyLimit",
|
|
557
|
+
"RateLimited",
|
|
558
|
+
"ReceiptNotFound",
|
|
559
|
+
"ServerError",
|
|
560
|
+
"NetworkError",
|
|
561
|
+
]
|
precleared/crewai.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""CrewAI tools for Precleared.
|
|
2
|
+
|
|
3
|
+
Requires the ``crewai`` extra::
|
|
4
|
+
|
|
5
|
+
pip install "precleared[crewai]"
|
|
6
|
+
|
|
7
|
+
Usage::
|
|
8
|
+
|
|
9
|
+
from precleared.crewai import PreclearedCheckTool, PreclearedVerifyReceiptTool
|
|
10
|
+
|
|
11
|
+
agent = Agent(role="SDR", goal="...", backstory="...",
|
|
12
|
+
tools=[PreclearedCheckTool(), PreclearedVerifyReceiptTool()])
|
|
13
|
+
|
|
14
|
+
The API key is held privately and does not appear in the tool's repr or
|
|
15
|
+
``model_dump()``.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import Any, Dict, List, Optional, Type, Union
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
from crewai.tools import BaseTool
|
|
24
|
+
except ImportError: # pragma: no cover - depends on the installed CrewAI version
|
|
25
|
+
try:
|
|
26
|
+
from crewai_tools import BaseTool # type: ignore[no-redef]
|
|
27
|
+
except ImportError as exc:
|
|
28
|
+
raise ImportError(
|
|
29
|
+
"precleared.crewai needs crewai. Install it with: pip install 'precleared[crewai]'"
|
|
30
|
+
) from exc
|
|
31
|
+
|
|
32
|
+
from pydantic import BaseModel, PrivateAttr
|
|
33
|
+
|
|
34
|
+
from ._schemas import PreclearedCheckArgs, PreclearedVerifyReceiptArgs
|
|
35
|
+
from ._tools import (
|
|
36
|
+
CHECK_DESCRIPTION,
|
|
37
|
+
CHECK_TOOL_NAME,
|
|
38
|
+
VERIFY_DESCRIPTION,
|
|
39
|
+
VERIFY_TOOL_NAME,
|
|
40
|
+
PreclearedTools,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_CLIENT_KWARGS = ("base_url", "timeout", "user_agent", "transport")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class _PreclearedBaseTool(BaseTool):
|
|
48
|
+
"""Shared plumbing. ``api_key`` is passed to ``__init__`` but is not a
|
|
49
|
+
pydantic field, so it never shows up in ``repr()`` or ``model_dump()``."""
|
|
50
|
+
|
|
51
|
+
to_dict: bool = False
|
|
52
|
+
_runner: PreclearedTools = PrivateAttr()
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
api_key: Optional[str] = None,
|
|
57
|
+
*,
|
|
58
|
+
to_dict: bool = False,
|
|
59
|
+
client: Any = None,
|
|
60
|
+
**data: Any,
|
|
61
|
+
) -> None:
|
|
62
|
+
client_kwargs = {k: data.pop(k) for k in _CLIENT_KWARGS if k in data}
|
|
63
|
+
super().__init__(to_dict=to_dict, **data)
|
|
64
|
+
self._runner = PreclearedTools(api_key, to_dict=to_dict, client=client, **client_kwargs)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class PreclearedCheckTool(_PreclearedBaseTool):
|
|
68
|
+
"""``precleared_check``: is it legal to cold-email this recipient?"""
|
|
69
|
+
|
|
70
|
+
name: str = CHECK_TOOL_NAME
|
|
71
|
+
description: str = CHECK_DESCRIPTION
|
|
72
|
+
args_schema: Type[BaseModel] = PreclearedCheckArgs
|
|
73
|
+
|
|
74
|
+
def _run(self, **kwargs: Any) -> Union[str, Dict[str, Any]]:
|
|
75
|
+
return self._runner.check(**kwargs)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class PreclearedVerifyReceiptTool(_PreclearedBaseTool):
|
|
79
|
+
"""``precleared_verify_receipt``: fetch a signed receipt by id."""
|
|
80
|
+
|
|
81
|
+
name: str = VERIFY_TOOL_NAME
|
|
82
|
+
description: str = VERIFY_DESCRIPTION
|
|
83
|
+
args_schema: Type[BaseModel] = PreclearedVerifyReceiptArgs
|
|
84
|
+
|
|
85
|
+
def _run(self, receipt_id: str = "", **kwargs: Any) -> Union[str, Dict[str, Any]]:
|
|
86
|
+
return self._runner.verify_receipt(receipt_id)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def precleared_tools(api_key: Optional[str] = None, *, to_dict: bool = False, **client_kwargs: Any) -> List[BaseTool]:
|
|
90
|
+
"""Both tools, sharing one client."""
|
|
91
|
+
runner = PreclearedTools(api_key, to_dict=to_dict, **client_kwargs)
|
|
92
|
+
return [
|
|
93
|
+
PreclearedCheckTool(client=runner.client, to_dict=to_dict),
|
|
94
|
+
PreclearedVerifyReceiptTool(client=runner.client, to_dict=to_dict),
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
__all__ = ["PreclearedCheckTool", "PreclearedVerifyReceiptTool", "precleared_tools"]
|