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/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"]