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 ADDED
@@ -0,0 +1,65 @@
1
+ """Precleared: a cold-email legality check for AI agents.
2
+
3
+ One check per recipient, before the first cold email::
4
+
5
+ from precleared import Precleared
6
+
7
+ result = Precleared().check("jane@acme.co.uk", pitch="Bookkeeping software", address_source="unknown")
8
+ if result.ok:
9
+ ... # send, with a footer that meets result.footer_text
10
+
11
+ Decision support with sources, not legal advice.
12
+ """
13
+
14
+ from ._version import __version__
15
+ from .client import DEFAULT_TIMEOUT, DEFAULT_USER_AGENT, AsyncPrecleared, Precleared
16
+ from .errors import (
17
+ DailyLimit,
18
+ InvalidKey,
19
+ InvalidRequest,
20
+ NetworkError,
21
+ PaymentRequired,
22
+ PreclearedError,
23
+ RateLimited,
24
+ ReceiptNotFound,
25
+ ServerError,
26
+ )
27
+ from .models import (
28
+ ADDRESS_SOURCES,
29
+ DEFAULT_BASE_URL,
30
+ VERDICTS,
31
+ CheckResult,
32
+ Health,
33
+ Reason,
34
+ Receipt,
35
+ Usage,
36
+ )
37
+
38
+ __all__ = [
39
+ "__version__",
40
+ # Clients
41
+ "Precleared",
42
+ "AsyncPrecleared",
43
+ # Models
44
+ "CheckResult",
45
+ "Receipt",
46
+ "Reason",
47
+ "Usage",
48
+ "Health",
49
+ # Errors
50
+ "PreclearedError",
51
+ "InvalidRequest",
52
+ "InvalidKey",
53
+ "PaymentRequired",
54
+ "DailyLimit",
55
+ "RateLimited",
56
+ "ReceiptNotFound",
57
+ "ServerError",
58
+ "NetworkError",
59
+ # Constants
60
+ "ADDRESS_SOURCES",
61
+ "VERDICTS",
62
+ "DEFAULT_BASE_URL",
63
+ "DEFAULT_TIMEOUT",
64
+ "DEFAULT_USER_AGENT",
65
+ ]
precleared/_schemas.py ADDED
@@ -0,0 +1,36 @@
1
+ """Pydantic argument models shared by the LangChain, CrewAI and LlamaIndex
2
+ adapters. Imported only by those adapters, so the core package does not
3
+ need pydantic.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Optional
9
+
10
+ from pydantic import BaseModel, Field
11
+
12
+ from ._tools import ARG_DESCRIPTIONS as _D
13
+
14
+
15
+ class PreclearedCheckArgs(BaseModel):
16
+ """Arguments for ``precleared_check``."""
17
+
18
+ recipient_email: Optional[str] = Field(default=None, description=_D["recipient_email"])
19
+ recipient_domain: Optional[str] = Field(default=None, description=_D["recipient_domain"])
20
+ pitch: Optional[str] = Field(default=None, description=_D["pitch"])
21
+ address_source: str = Field(default="unknown", description=_D["address_source"])
22
+ existing_customer: bool = Field(default=False, description=_D["existing_customer"])
23
+ pitch_relevant: Optional[bool] = Field(default=None, description=_D["pitch_relevant"])
24
+ sender_country: str = Field(default="GB", description=_D["sender_country"])
25
+ recipient_country: Optional[str] = Field(default=None, description=_D["recipient_country"])
26
+ company_number: Optional[str] = Field(default=None, description=_D["company_number"])
27
+ suppressed: Optional[bool] = Field(default=None, description=_D["suppressed"])
28
+
29
+
30
+ class PreclearedVerifyReceiptArgs(BaseModel):
31
+ """Arguments for ``precleared_verify_receipt``."""
32
+
33
+ receipt_id: str = Field(description=_D["receipt_id"])
34
+
35
+
36
+ __all__ = ["PreclearedCheckArgs", "PreclearedVerifyReceiptArgs"]
precleared/_tools.py ADDED
@@ -0,0 +1,234 @@
1
+ """Framework-neutral logic behind the LangChain, CrewAI, LlamaIndex and
2
+ OpenAI Agents adapters: tool names, descriptions, argument descriptions and
3
+ the two functions the tools call.
4
+
5
+ This module depends only on the core client, so it imports without any
6
+ framework installed.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Dict, Optional, Union
12
+
13
+ import httpx
14
+
15
+ from .client import DEFAULT_TIMEOUT, DEFAULT_USER_AGENT, Precleared, Timeout
16
+ from .errors import DailyLimit, PaymentRequired, PreclearedError, RateLimited
17
+ from .models import ADDRESS_SOURCES, DEFAULT_BASE_URL
18
+
19
+ CHECK_TOOL_NAME = "precleared_check"
20
+ VERIFY_TOOL_NAME = "precleared_verify_receipt"
21
+
22
+ # Kept under 1024 characters: the OpenAI limit for a function description,
23
+ # which LlamaIndex and the Agents SDK enforce.
24
+ CHECK_DESCRIPTION = (
25
+ "Check whether a cold or marketing email to one recipient is legal. "
26
+ "Call this once per address before the first cold email to anyone who is not already a customer, "
27
+ "in the UK, US, Canada, Germany, France, Australia, Ireland or the Netherlands, including every "
28
+ "address in a lead list. Not needed for transactional email such as invoices or password resets. "
29
+ "It applies the local rules (UK PECR, US CAN-SPAM, Canadian CASL and others) using live lookups "
30
+ "such as UK Companies House. Returns a verdict, the rule that decided it with its source, "
31
+ "footer_text, next_step and a signed receipt id. ok_to_email means send, with a footer that meets "
32
+ "footer_text (a requirement, not wording to paste). consent_needed and blocked mean do not send; "
33
+ "follow next_step. unknown means a fact could not be established: treat it as consent_needed unless "
34
+ "you can supply the missing fact (such as recipient_country or company_number) and check again. "
35
+ "Nothing is sent to the recipient. Decision support with sources, not legal advice."
36
+ )
37
+
38
+ VERIFY_DESCRIPTION = (
39
+ "Fetch a Precleared receipt by its id (cc_rcpt_...) to show that a cold-email check happened and what "
40
+ "it said. Use it when a client, auditor or regulator asks for evidence of a check, or to re-read a past "
41
+ "verdict without spending another check. Returns the receipt id, time, request and response hashes, "
42
+ "Ed25519 signature and verification URL. Does not run a new check and sends nothing to anyone."
43
+ )
44
+
45
+ ARG_DESCRIPTIONS: Dict[str, str] = {
46
+ "recipient_email": "The address you want to email. Give this or recipient_domain.",
47
+ "recipient_domain": "The recipient's domain when you have no address yet, for example acme.co.uk.",
48
+ "pitch": "What you are offering, in one sentence.",
49
+ "address_source": (
50
+ "How the address was obtained: published (the recipient put it on their own website or a public "
51
+ "listing so people can contact them), bought (from a data vendor or list), scraped (collected from "
52
+ "the web by a tool), given (the recipient gave it to you directly) or unknown. "
53
+ "Use unknown when you are not sure. Never guess published."
54
+ ),
55
+ "existing_customer": "True only if the recipient has bought from the sender before.",
56
+ "pitch_relevant": (
57
+ "True if the pitch relates to the recipient's role or business, false if it does not. "
58
+ "Leave unset when unsure."
59
+ ),
60
+ "sender_country": "Two-letter code for where the sender is based, for example GB or US. Default GB.",
61
+ "recipient_country": (
62
+ "Two-letter country code for the recipient when you know it. Otherwise Precleared works it out "
63
+ "from the domain, the register and the website."
64
+ ),
65
+ "company_number": (
66
+ "UK Companies House number when known. Raises confidence for UK recipients and can resolve an "
67
+ "unknown verdict."
68
+ ),
69
+ "suppressed": "True if the address is on the sender's own suppression or unsubscribe list.",
70
+ "receipt_id": "Receipt id from a previous check, starting cc_rcpt_. A receipt URL also works.",
71
+ }
72
+
73
+ _TRUE = {"true", "yes", "y", "1", "on"}
74
+ _FALSE = {"false", "no", "n", "0", "off"}
75
+
76
+
77
+ def coerce_bool(value: Any, name: str) -> Optional[bool]:
78
+ """Models sometimes send booleans as strings. Accept the obvious forms."""
79
+ if value is None or isinstance(value, bool):
80
+ return value
81
+ if isinstance(value, (int, float)) and value in (0, 1):
82
+ return bool(value)
83
+ if isinstance(value, str):
84
+ text = value.strip().lower()
85
+ if text == "":
86
+ return None
87
+ if text in _TRUE:
88
+ return True
89
+ if text in _FALSE:
90
+ return False
91
+ raise ValueError(f"{name} must be true or false, got {value!r}")
92
+
93
+
94
+ def _blank_to_none(value: Any) -> Optional[str]:
95
+ if value is None:
96
+ return None
97
+ text = str(value).strip()
98
+ return text or None
99
+
100
+
101
+ def error_payload(exc: Exception) -> Dict[str, Any]:
102
+ """Structured description of a failure, safe to hand to a model."""
103
+ if isinstance(exc, PreclearedError):
104
+ payload: Dict[str, Any] = {
105
+ "error": exc.code or type(exc).__name__,
106
+ "message": exc.message,
107
+ }
108
+ if exc.status_code is not None:
109
+ payload["status_code"] = exc.status_code
110
+ if isinstance(exc, RateLimited) and exc.retry_after is not None:
111
+ payload["retry_after"] = exc.retry_after
112
+ if isinstance(exc, PaymentRequired) and exc.buy_url:
113
+ payload["buy_url"] = exc.buy_url
114
+ if isinstance(exc, DailyLimit) and exc.get_key_url:
115
+ payload["get_key_url"] = exc.get_key_url
116
+ return payload
117
+ return {"error": "invalid_arguments", "message": str(exc)}
118
+
119
+
120
+ def format_error(payload: Dict[str, Any]) -> str:
121
+ lines = [f"error: {payload.get('error')}", f"message: {payload.get('message')}"]
122
+ for key in ("retry_after", "buy_url", "get_key_url"):
123
+ if key in payload:
124
+ lines.append(f"{key}: {payload[key]}")
125
+ lines.append("Do not send until a check succeeds.")
126
+ return "\n".join(lines)
127
+
128
+
129
+ class PreclearedTools:
130
+ """The two tool functions, bound to one client.
131
+
132
+ Every framework adapter builds its tool objects around an instance of
133
+ this class. ``check`` and ``verify_receipt`` never raise for API
134
+ failures: they return an error description so the model can react
135
+ (wait, get a key, fix an argument). Bad arguments are reported the same
136
+ way.
137
+
138
+ Args:
139
+ api_key: Optional ``pc_live_...`` key, or ``None`` for keyless use.
140
+ to_dict: Return compact dictionaries instead of compact text.
141
+ client: An existing :class:`~precleared.Precleared` to reuse.
142
+ """
143
+
144
+ def __init__(
145
+ self,
146
+ api_key: Optional[str] = None,
147
+ *,
148
+ to_dict: bool = False,
149
+ client: Optional[Precleared] = None,
150
+ base_url: str = DEFAULT_BASE_URL,
151
+ timeout: Timeout = DEFAULT_TIMEOUT,
152
+ user_agent: str = DEFAULT_USER_AGENT,
153
+ transport: Optional[httpx.BaseTransport] = None,
154
+ ) -> None:
155
+ self.client = (
156
+ client
157
+ if client is not None
158
+ else Precleared(
159
+ api_key, base_url=base_url, timeout=timeout, user_agent=user_agent, transport=transport
160
+ )
161
+ )
162
+ self.to_dict = bool(to_dict)
163
+
164
+ def _failure(self, exc: Exception) -> Union[str, Dict[str, Any]]:
165
+ payload = error_payload(exc)
166
+ return payload if self.to_dict else format_error(payload)
167
+
168
+ def check(
169
+ self,
170
+ recipient_email: Optional[str] = None,
171
+ recipient_domain: Optional[str] = None,
172
+ pitch: Optional[str] = None,
173
+ address_source: Optional[str] = "unknown",
174
+ existing_customer: Optional[bool] = False,
175
+ pitch_relevant: Optional[bool] = None,
176
+ sender_country: Optional[str] = "GB",
177
+ recipient_country: Optional[str] = None,
178
+ company_number: Optional[str] = None,
179
+ suppressed: Optional[bool] = None,
180
+ ) -> Union[str, Dict[str, Any]]:
181
+ """Run one check and return compact text (or a dict when ``to_dict``)."""
182
+ try:
183
+ source = _blank_to_none(address_source) or "unknown"
184
+ result = self.client.check(
185
+ _blank_to_none(recipient_email),
186
+ recipient_domain=_blank_to_none(recipient_domain),
187
+ pitch=_blank_to_none(pitch),
188
+ address_source=source,
189
+ sender_country=_blank_to_none(sender_country) or "GB",
190
+ existing_customer=coerce_bool(existing_customer, "existing_customer") or False,
191
+ pitch_relevant=coerce_bool(pitch_relevant, "pitch_relevant"),
192
+ company_number=_blank_to_none(company_number),
193
+ recipient_country=_blank_to_none(recipient_country),
194
+ suppressed=coerce_bool(suppressed, "suppressed"),
195
+ )
196
+ except (PreclearedError, ValueError) as exc:
197
+ return self._failure(exc)
198
+ return result.summary() if self.to_dict else result.summary_text()
199
+
200
+ def verify_receipt(self, receipt_id: str) -> Union[str, Dict[str, Any]]:
201
+ """Fetch one receipt and return compact text (or a dict when ``to_dict``)."""
202
+ try:
203
+ receipt = self.client.verify_receipt(receipt_id)
204
+ except (PreclearedError, ValueError) as exc:
205
+ return self._failure(exc)
206
+ if self.to_dict:
207
+ out = receipt.summary()
208
+ out["receipt_url"] = receipt.page_url(self.client.base_url)
209
+ return out
210
+ return receipt.summary_text() + f"\nreceipt_url: {receipt.page_url(self.client.base_url)}"
211
+
212
+
213
+ def google_docstring(summary: str, params: Dict[str, str]) -> str:
214
+ """Build a Google-style docstring so frameworks that read docstrings
215
+ (OpenAI Agents SDK) get the same argument descriptions as the others."""
216
+ lines = [summary, "", "Args:"]
217
+ for name, text in params.items():
218
+ lines.append(f" {name}: {text}")
219
+ return "\n".join(lines)
220
+
221
+
222
+ __all__ = [
223
+ "ADDRESS_SOURCES",
224
+ "ARG_DESCRIPTIONS",
225
+ "CHECK_DESCRIPTION",
226
+ "CHECK_TOOL_NAME",
227
+ "VERIFY_DESCRIPTION",
228
+ "VERIFY_TOOL_NAME",
229
+ "PreclearedTools",
230
+ "coerce_bool",
231
+ "error_payload",
232
+ "format_error",
233
+ "google_docstring",
234
+ ]
precleared/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"