layercall 1.2.4__tar.gz → 1.2.6__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: layercall
3
- Version: 1.2.4
3
+ Version: 1.2.6
4
4
  Summary: Official LayerCall client — score an IP, email, phone, domain or a whole signup for fraud in one call. Zero dependencies.
5
5
  Author: LayerCall
6
6
  License: MIT
@@ -72,13 +72,68 @@ lc.verify_email("someone@mailinator.com")
72
72
  lc.lookup_phone("+14155552671")
73
73
  lc.lookup_phone("4155552671", country="US")
74
74
  lc.score_domain("example.com")
75
+ lc.score_device(device_id, ip=ip, signals=signals) # fingerprint from /fp.js
75
76
  lc.score_user(ip=ip, email=email, phone=phone, device_id=device_id)
76
- lc.batch("email", ["a@x.com", "b@y.com"]) # up to 500
77
+ lc.batch("email", ["a@x.com", "b@y.com"]) # up to 500
78
+
79
+ # AI agents — proof of identity, then your policy applied to it
80
+ lc.verify_agent(url=url, headers=headers) # who is this?
81
+ lc.authorize_agent(method=method, url=url, headers=headers) # may they, here?
82
+ lc.get_agent_policy()
83
+ lc.set_agent_policy([{"trigger": "crawler", "path": "/api", "action": "deny"}])
84
+
85
+ # Tell us whether a score was right. Free, and the only thing that improves it.
86
+ lc.report_outcome(request_id=r["request_id"], outcome="fraud")
87
+
88
+ # Report confirmed fraud to the shared reputation network. Live keys only.
89
+ lc.report("ip", "185.220.101.1", reason="carding")
90
+ ```
91
+
92
+ ### Custom rules - your lists always win
93
+
94
+ A rule overrides the computed score for that value. The kind (`ip`, `cidr`,
95
+ `email`, `domain`, `phone`, `asn`) is detected from the value unless you force
96
+ it.
97
+
98
+ ```python
99
+ lc.add_rules("block", ["185.220.101.1", "mailinator.com"])
100
+ lc.add_rules("allow", "10.0.0.0/8")
101
+ lc.add_rules("block", ["AS14061"], kind="asn")
102
+
103
+ listed = lc.list_rules()
104
+ lc.delete_rule(listed["rules"][0]["id"])
105
+
106
+ # Paste a list - one value per line, or CSV.
107
+ lc.import_rules("block", "1.2.3.4\n5.6.7.8\nspam.example")
108
+
109
+ # Removes EVERY rule on the account. confirm is required, deliberately.
110
+ lc.clear_rules(confirm=True)
77
111
  ```
78
112
 
79
- Every method takes `strictness` (`0` lenient → `3` paranoid). It moves the
80
- verdict thresholds only; the risk score never changes, so you can re-tune
81
- without re-scoring anything.
113
+ ### A pending mailbox
114
+
115
+ `verify_email` returns `mailbox_status` `"pending"` when the SMTP probe has not
116
+ finished - it is queued and the answer is there on the next lookup. If you
117
+ would rather wait for it, say so. It costs 2-10 seconds on a cache miss, which
118
+ is why it is opt-in:
119
+
120
+ ```python
121
+ r = lc.verify_email("someone@example.com", wait_for_mailbox=True)
122
+ ```
123
+
124
+ That is all thirteen. This block used to list seven, with no hint there were
125
+ more — so a Python developer reasonably concluded that device scoring, agent
126
+ authorization and outcome feedback were Node-only features. They were not;
127
+ they were shipped, working and undocumented.
128
+
129
+ Every **scoring** method takes `strictness` (`0` lenient → `3` paranoid) —
130
+ `score_ip`, `verify_email`, `lookup_phone`, `score_domain`, `score_device`,
131
+ `score_user` and `batch`. It moves the verdict thresholds only; the risk score
132
+ never changes, so you can re-tune without re-scoring anything.
133
+
134
+ The other methods report or configure rather than score, and take no
135
+ strictness: `report`, `report_outcome`, `verify_agent`, `authorize_agent`,
136
+ `get_agent_policy`, `set_agent_policy`.
82
137
 
83
138
  ## None means unknown, never "no"
84
139
 
@@ -53,13 +53,68 @@ lc.verify_email("someone@mailinator.com")
53
53
  lc.lookup_phone("+14155552671")
54
54
  lc.lookup_phone("4155552671", country="US")
55
55
  lc.score_domain("example.com")
56
+ lc.score_device(device_id, ip=ip, signals=signals) # fingerprint from /fp.js
56
57
  lc.score_user(ip=ip, email=email, phone=phone, device_id=device_id)
57
- lc.batch("email", ["a@x.com", "b@y.com"]) # up to 500
58
+ lc.batch("email", ["a@x.com", "b@y.com"]) # up to 500
59
+
60
+ # AI agents — proof of identity, then your policy applied to it
61
+ lc.verify_agent(url=url, headers=headers) # who is this?
62
+ lc.authorize_agent(method=method, url=url, headers=headers) # may they, here?
63
+ lc.get_agent_policy()
64
+ lc.set_agent_policy([{"trigger": "crawler", "path": "/api", "action": "deny"}])
65
+
66
+ # Tell us whether a score was right. Free, and the only thing that improves it.
67
+ lc.report_outcome(request_id=r["request_id"], outcome="fraud")
68
+
69
+ # Report confirmed fraud to the shared reputation network. Live keys only.
70
+ lc.report("ip", "185.220.101.1", reason="carding")
71
+ ```
72
+
73
+ ### Custom rules - your lists always win
74
+
75
+ A rule overrides the computed score for that value. The kind (`ip`, `cidr`,
76
+ `email`, `domain`, `phone`, `asn`) is detected from the value unless you force
77
+ it.
78
+
79
+ ```python
80
+ lc.add_rules("block", ["185.220.101.1", "mailinator.com"])
81
+ lc.add_rules("allow", "10.0.0.0/8")
82
+ lc.add_rules("block", ["AS14061"], kind="asn")
83
+
84
+ listed = lc.list_rules()
85
+ lc.delete_rule(listed["rules"][0]["id"])
86
+
87
+ # Paste a list - one value per line, or CSV.
88
+ lc.import_rules("block", "1.2.3.4\n5.6.7.8\nspam.example")
89
+
90
+ # Removes EVERY rule on the account. confirm is required, deliberately.
91
+ lc.clear_rules(confirm=True)
58
92
  ```
59
93
 
60
- Every method takes `strictness` (`0` lenient → `3` paranoid). It moves the
61
- verdict thresholds only; the risk score never changes, so you can re-tune
62
- without re-scoring anything.
94
+ ### A pending mailbox
95
+
96
+ `verify_email` returns `mailbox_status` `"pending"` when the SMTP probe has not
97
+ finished - it is queued and the answer is there on the next lookup. If you
98
+ would rather wait for it, say so. It costs 2-10 seconds on a cache miss, which
99
+ is why it is opt-in:
100
+
101
+ ```python
102
+ r = lc.verify_email("someone@example.com", wait_for_mailbox=True)
103
+ ```
104
+
105
+ That is all thirteen. This block used to list seven, with no hint there were
106
+ more — so a Python developer reasonably concluded that device scoring, agent
107
+ authorization and outcome feedback were Node-only features. They were not;
108
+ they were shipped, working and undocumented.
109
+
110
+ Every **scoring** method takes `strictness` (`0` lenient → `3` paranoid) —
111
+ `score_ip`, `verify_email`, `lookup_phone`, `score_domain`, `score_device`,
112
+ `score_user` and `batch`. It moves the verdict thresholds only; the risk score
113
+ never changes, so you can re-tune without re-scoring anything.
114
+
115
+ The other methods report or configure rather than score, and take no
116
+ strictness: `report`, `report_outcome`, `verify_agent`, `authorize_agent`,
117
+ `get_agent_policy`, `set_agent_policy`.
63
118
 
64
119
  ## None means unknown, never "no"
65
120
 
@@ -32,7 +32,7 @@ import urllib.parse
32
32
  import urllib.request
33
33
  from typing import Any, Literal, Mapping, Sequence
34
34
 
35
- __version__ = "1.2.2"
35
+ __version__ = "1.2.6"
36
36
  __all__ = ["LayerCall", "LayerCallError"]
37
37
 
38
38
  Verdict = Literal["allow", "review", "block"]
@@ -87,7 +87,7 @@ class LayerCall:
87
87
  api_key: str,
88
88
  *,
89
89
  base_url: str = _DEFAULT_BASE,
90
- timeout: float = 5.0,
90
+ timeout: float = 30.0,
91
91
  retries: int = 2,
92
92
  ) -> None:
93
93
  if not api_key:
@@ -95,6 +95,9 @@ class LayerCall:
95
95
  self._key = api_key
96
96
  self._base = base_url.rstrip("/")
97
97
  self._timeout = timeout
98
+ # /v1/batch alone budgets 300s server-side, so the normal default would
99
+ # abandon a large batch mid-flight and bill every item in it.
100
+ self._batch_timeout = max(timeout, 300.0)
98
101
  self._retries = retries
99
102
 
100
103
  # -- transport --------------------------------------------------------
@@ -143,7 +146,10 @@ class LayerCall:
143
146
  },
144
147
  )
145
148
  try:
146
- with urllib.request.urlopen(req, timeout=self._timeout) as resp:
149
+ timeout = (
150
+ self._batch_timeout if path == "/v1/batch" else self._timeout
151
+ )
152
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
147
153
  return json.loads(resp.read().decode() or "{}")
148
154
  except urllib.error.HTTPError as exc:
149
155
  try:
@@ -162,6 +168,22 @@ class LayerCall:
162
168
  raise err from None
163
169
  last_exc = err
164
170
  except Exception as exc: # timeouts, DNS, connection resets
171
+ # A TIMEOUT IS NOT EVIDENCE THAT THE SERVER FAILED.
172
+ #
173
+ # It only says we stopped waiting. The request very likely
174
+ # arrived and is still being processed - and it will finish,
175
+ # meter a billable lookup, and return to nobody. Retrying does
176
+ # not recover that lookup, it buys a second one. With the old
177
+ # 5s default against a 25-second endpoint budget, one logical
178
+ # call became three billable ones and the caller still got an
179
+ # error.
180
+ #
181
+ # Refused, reset or DNS means the request never reached us, so
182
+ # a retry is free and worth making. Those still retry.
183
+ if isinstance(exc, TimeoutError) or isinstance(
184
+ getattr(exc, "reason", None), TimeoutError
185
+ ):
186
+ raise
165
187
  last_exc = exc
166
188
  if attempt == self._retries:
167
189
  break
@@ -176,9 +198,30 @@ class LayerCall:
176
198
  """VPN, proxy, Tor, datacenter, geolocation and ASN for an IP."""
177
199
  return self._request("/v1/score/ip", query={"ip": ip, "strictness": strictness})
178
200
 
179
- def verify_email(self, email: str, *, strictness: Strictness | None = None) -> dict[str, Any]:
180
- """Syntax, MX, disposable, role account, homograph and domain age."""
181
- return self._request("/v1/verify/email", query={"email": email, "strictness": strictness})
201
+ def verify_email(
202
+ self,
203
+ email: str,
204
+ *,
205
+ strictness: Strictness | None = None,
206
+ wait_for_mailbox: bool = False,
207
+ ) -> dict[str, Any]:
208
+ """Syntax, MX, disposable, role account, homograph and domain age.
209
+
210
+ wait_for_mailbox blocks until the SMTP mailbox probe finishes rather
211
+ than returning mailbox_status "pending". It is the documented remedy
212
+ for a pending mailbox, and it was reachable only by hand-writing the
213
+ HTTP call - the docs told you to pass it and the recommended client had
214
+ no way to. Costs 2-10 seconds on a cache miss, which is why it is
215
+ opt-in.
216
+ """
217
+ return self._request(
218
+ "/v1/verify/email",
219
+ query={
220
+ "email": email,
221
+ "strictness": strictness,
222
+ "wait_for_mailbox": "true" if wait_for_mailbox else None,
223
+ },
224
+ )
182
225
 
183
226
  def lookup_phone(
184
227
  self,
@@ -193,9 +236,12 @@ class LayerCall:
193
236
  query={"phone": phone, "country": country, "strictness": strictness},
194
237
  )
195
238
 
196
- def score_domain(self, domain: str) -> dict[str, Any]:
239
+ def score_domain(self, domain: str, *, strictness: Strictness | None = None) -> dict[str, Any]:
197
240
  """RDAP registration date, registrar, MX/SPF/DMARC, risky TLD."""
198
- return self._request("/v1/score/domain", query={"domain": domain})
241
+ return self._request(
242
+ "/v1/score/domain",
243
+ query={"domain": domain, "strictness": strictness},
244
+ )
199
245
 
200
246
  def score_device(
201
247
  self,
@@ -204,11 +250,15 @@ class LayerCall:
204
250
  ip: str | None = None,
205
251
  signals: dict[str, Any] | None = None,
206
252
  automation: dict[str, Any] | None = None,
253
+ strictness: Strictness | None = None,
207
254
  ) -> dict[str, Any]:
208
255
  """Score a device fingerprint from /fp.js.
209
256
 
210
257
  POST, never GET: a device id in a URL lands in access logs and Referer
211
258
  headers, and that is a tracking identifier.
259
+
260
+ ``strictness`` rides in the query string, not the body — the route
261
+ reads it from the URL for every endpoint, POST ones included.
212
262
  """
213
263
  body: dict[str, Any] = {"device_id": device_id}
214
264
  if ip is not None:
@@ -217,7 +267,12 @@ class LayerCall:
217
267
  body["signals"] = signals
218
268
  if automation is not None:
219
269
  body["automation"] = automation
220
- return self._request("/v1/score/device", method="POST", body=body)
270
+ return self._request(
271
+ "/v1/score/device",
272
+ method="POST",
273
+ body=body,
274
+ query={"strictness": strictness},
275
+ )
221
276
 
222
277
  def verify_agent(
223
278
  self,
@@ -372,6 +427,66 @@ class LayerCall:
372
427
  raise ValueError("score_user: provide at least one of ip, email or phone.")
373
428
  return self._request("/v1/score/user", body=payload)
374
429
 
430
+ # -- custom rules -----------------------------------------------------
431
+ #
432
+ # One of the nine products on the homepage, three endpoints, and until now
433
+ # no method in either SDK for any of them. A customer following our own
434
+ # advice to use the SDK found a promoted feature reachable only by
435
+ # hand-writing HTTP, and would reasonably conclude it was unfinished.
436
+ #
437
+ # A rule's kind (ip, cidr, email, domain, phone, asn) is detected from the
438
+ # value unless you force it.
439
+
440
+ def list_rules(self) -> dict[str, Any]:
441
+ """Every rule on the account."""
442
+ return self._request("/v1/rules")
443
+
444
+ def add_rules(
445
+ self,
446
+ action: str,
447
+ values: str | list[str],
448
+ *,
449
+ kind: str | None = None,
450
+ ) -> dict[str, Any]:
451
+ """Add one rule or many. Re-adding an existing rule is idempotent."""
452
+ body: dict[str, Any] = {
453
+ "action": action,
454
+ "values": [values] if isinstance(values, str) else list(values),
455
+ }
456
+ if kind is not None:
457
+ body["kind"] = kind
458
+ return self._request("/v1/rules", body=body)
459
+
460
+ def delete_rule(self, rule_id: str) -> dict[str, Any]:
461
+ """Remove one rule by id."""
462
+ return self._request(f"/v1/rules/{urllib.parse.quote(rule_id, safe='')}", method="DELETE")
463
+
464
+ def clear_rules(self, *, confirm: bool = False) -> dict[str, Any]:
465
+ """Remove EVERY rule on the account.
466
+
467
+ confirm=True is required by the API and deliberately not defaulted
468
+ here - the whole point of the flag is that it cannot happen by
469
+ accident, and an SDK that fills it in for you removes the guard.
470
+ """
471
+ if not confirm:
472
+ raise ValueError(
473
+ "clear_rules deletes every rule on the account - pass confirm=True."
474
+ )
475
+ return self._request("/v1/rules", method="DELETE", query={"confirm": "true"})
476
+
477
+ def import_rules(
478
+ self,
479
+ action: str,
480
+ text: str,
481
+ *,
482
+ kind: str | None = None,
483
+ ) -> dict[str, Any]:
484
+ """Import rules from pasted text - one value per line, or CSV."""
485
+ body: dict[str, Any] = {"action": action, "text": text}
486
+ if kind is not None:
487
+ body["kind"] = kind
488
+ return self._request("/v1/rules/import", body=body)
489
+
375
490
  def batch(
376
491
  self,
377
492
  type: Literal["ip", "email", "phone", "domain"],
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: layercall
3
- Version: 1.2.4
3
+ Version: 1.2.6
4
4
  Summary: Official LayerCall client — score an IP, email, phone, domain or a whole signup for fraud in one call. Zero dependencies.
5
5
  Author: LayerCall
6
6
  License: MIT
@@ -72,13 +72,68 @@ lc.verify_email("someone@mailinator.com")
72
72
  lc.lookup_phone("+14155552671")
73
73
  lc.lookup_phone("4155552671", country="US")
74
74
  lc.score_domain("example.com")
75
+ lc.score_device(device_id, ip=ip, signals=signals) # fingerprint from /fp.js
75
76
  lc.score_user(ip=ip, email=email, phone=phone, device_id=device_id)
76
- lc.batch("email", ["a@x.com", "b@y.com"]) # up to 500
77
+ lc.batch("email", ["a@x.com", "b@y.com"]) # up to 500
78
+
79
+ # AI agents — proof of identity, then your policy applied to it
80
+ lc.verify_agent(url=url, headers=headers) # who is this?
81
+ lc.authorize_agent(method=method, url=url, headers=headers) # may they, here?
82
+ lc.get_agent_policy()
83
+ lc.set_agent_policy([{"trigger": "crawler", "path": "/api", "action": "deny"}])
84
+
85
+ # Tell us whether a score was right. Free, and the only thing that improves it.
86
+ lc.report_outcome(request_id=r["request_id"], outcome="fraud")
87
+
88
+ # Report confirmed fraud to the shared reputation network. Live keys only.
89
+ lc.report("ip", "185.220.101.1", reason="carding")
90
+ ```
91
+
92
+ ### Custom rules - your lists always win
93
+
94
+ A rule overrides the computed score for that value. The kind (`ip`, `cidr`,
95
+ `email`, `domain`, `phone`, `asn`) is detected from the value unless you force
96
+ it.
97
+
98
+ ```python
99
+ lc.add_rules("block", ["185.220.101.1", "mailinator.com"])
100
+ lc.add_rules("allow", "10.0.0.0/8")
101
+ lc.add_rules("block", ["AS14061"], kind="asn")
102
+
103
+ listed = lc.list_rules()
104
+ lc.delete_rule(listed["rules"][0]["id"])
105
+
106
+ # Paste a list - one value per line, or CSV.
107
+ lc.import_rules("block", "1.2.3.4\n5.6.7.8\nspam.example")
108
+
109
+ # Removes EVERY rule on the account. confirm is required, deliberately.
110
+ lc.clear_rules(confirm=True)
77
111
  ```
78
112
 
79
- Every method takes `strictness` (`0` lenient → `3` paranoid). It moves the
80
- verdict thresholds only; the risk score never changes, so you can re-tune
81
- without re-scoring anything.
113
+ ### A pending mailbox
114
+
115
+ `verify_email` returns `mailbox_status` `"pending"` when the SMTP probe has not
116
+ finished - it is queued and the answer is there on the next lookup. If you
117
+ would rather wait for it, say so. It costs 2-10 seconds on a cache miss, which
118
+ is why it is opt-in:
119
+
120
+ ```python
121
+ r = lc.verify_email("someone@example.com", wait_for_mailbox=True)
122
+ ```
123
+
124
+ That is all thirteen. This block used to list seven, with no hint there were
125
+ more — so a Python developer reasonably concluded that device scoring, agent
126
+ authorization and outcome feedback were Node-only features. They were not;
127
+ they were shipped, working and undocumented.
128
+
129
+ Every **scoring** method takes `strictness` (`0` lenient → `3` paranoid) —
130
+ `score_ip`, `verify_email`, `lookup_phone`, `score_domain`, `score_device`,
131
+ `score_user` and `batch`. It moves the verdict thresholds only; the risk score
132
+ never changes, so you can re-tune without re-scoring anything.
133
+
134
+ The other methods report or configure rather than score, and take no
135
+ strictness: `report`, `report_outcome`, `verify_agent`, `authorize_agent`,
136
+ `get_agent_policy`, `set_agent_policy`.
82
137
 
83
138
  ## None means unknown, never "no"
84
139
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "layercall"
7
- version = "1.2.4"
7
+ version = "1.2.6"
8
8
  description = "Official LayerCall client — score an IP, email, phone, domain or a whole signup for fraud in one call. Zero dependencies."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
File without changes