easydata-api 1.0.1__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.
@@ -0,0 +1,79 @@
1
+ """EasyData - LinkedIn data enrichment.
2
+
3
+ from easydata_api import EasyData
4
+
5
+ ed = EasyData() # reads EASYDATA_API_KEY
6
+
7
+ # One record, in this call, at twice the credits.
8
+ r = ed.profiles_enrich.sync("https://linkedin.com/in/satyanadella")
9
+ print(r.result.data["full_name"])
10
+
11
+ # A batch of any size, streamed as it drains.
12
+ batch = ed.profiles_enrich(urls, external_id="crm-sync")
13
+ for entry in ed.results(batch.batch_id):
14
+ if entry.ok:
15
+ save(entry.data)
16
+
17
+ Everything is a batch, including a batch of one, and you are billed per record
18
+ that resolves: a failure costs nothing.
19
+ """
20
+
21
+ from .client import (
22
+ DEFAULT_BASE_URL,
23
+ OPERATIONS,
24
+ Batch,
25
+ EasyData,
26
+ RateLimits,
27
+ ResultEntry,
28
+ ResultsPage,
29
+ SyncResult,
30
+ )
31
+ from .errors import (
32
+ CapacityUnavailable,
33
+ Conflict,
34
+ EasyDataError,
35
+ EmailUnverified,
36
+ InternalError,
37
+ InvalidAPIKey,
38
+ InvalidRequest,
39
+ NotFound,
40
+ NotImplementedYet,
41
+ QuotaExhausted,
42
+ RateLimited,
43
+ TransportError,
44
+ UnprocessableTarget,
45
+ UpstreamTimeout,
46
+ )
47
+ from .webhooks import Delivery, VerificationError, verify, verify_ed25519
48
+
49
+ __version__ = "1.0.1"
50
+
51
+ __all__ = [
52
+ "EasyData",
53
+ "Batch",
54
+ "ResultEntry",
55
+ "ResultsPage",
56
+ "SyncResult",
57
+ "RateLimits",
58
+ "OPERATIONS",
59
+ "DEFAULT_BASE_URL",
60
+ "EasyDataError",
61
+ "InvalidRequest",
62
+ "InvalidAPIKey",
63
+ "QuotaExhausted",
64
+ "EmailUnverified",
65
+ "NotFound",
66
+ "Conflict",
67
+ "UnprocessableTarget",
68
+ "RateLimited",
69
+ "NotImplementedYet",
70
+ "InternalError",
71
+ "UpstreamTimeout",
72
+ "CapacityUnavailable",
73
+ "TransportError",
74
+ "verify",
75
+ "verify_ed25519",
76
+ "Delivery",
77
+ "VerificationError",
78
+ "__version__",
79
+ ]
easydata_api/client.py ADDED
@@ -0,0 +1,642 @@
1
+ """The EasyData client.
2
+
3
+ Zero dependencies, on purpose. It is one HTTP call shape against one envelope,
4
+ and a data-enrichment script is the last place anyone wants a transitive
5
+ dependency tree - so this is `urllib` and nothing else. Ed25519 webhook
6
+ verification is the single exception and is an optional extra; the HMAC scheme
7
+ is in the standard library and always available.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import random
15
+ import time
16
+ import urllib.error
17
+ import urllib.parse
18
+ import urllib.request
19
+ import uuid
20
+ from dataclasses import dataclass, field
21
+ from typing import Any, Iterator, Mapping, Sequence
22
+
23
+ from .errors import EasyDataError, RateLimited, TransportError, error_for
24
+
25
+ __all__ = [
26
+ "EasyData",
27
+ "Batch",
28
+ "ResultEntry",
29
+ "ResultsPage",
30
+ "SyncResult",
31
+ "RateLimits",
32
+ "OPERATIONS",
33
+ ]
34
+
35
+ DEFAULT_BASE_URL = "https://api.easydata.win/v1"
36
+
37
+ #: Every operation, as `attribute name -> path`. The client builds one method
38
+ #: per entry rather than defining nine near-identical methods, because they ARE
39
+ #: identical: one request shape, one response shape, and the operation is the
40
+ #: path. A new operation is a row here.
41
+ OPERATIONS: dict[str, str] = {
42
+ "profiles_enrich": "/profiles/enrich",
43
+ "profiles_activity": "/profiles/activity",
44
+ "profiles_posts": "/profiles/posts",
45
+ "profiles_comments": "/profiles/comments",
46
+ "profiles_reactions": "/profiles/reactions",
47
+ "companies_enrich": "/companies/enrich",
48
+ "posts_enrich": "/posts/enrich",
49
+ "sales_search_people": "/sales/search/people",
50
+ "sales_search_companies": "/sales/search/companies",
51
+ }
52
+
53
+ # Statuses a batch can hold that mean it is still going to produce something.
54
+ _LIVE = ("queued", "processing")
55
+
56
+
57
+ @dataclass
58
+ class RateLimits:
59
+ """The ceilings, read off the response headers of the last call.
60
+
61
+ Every field is None when the deployment publishes no ceiling for it, which
62
+ is what "no limit" looks like on the wire: an ABSENT header, never a zero.
63
+ Treating a missing header as 0 would make an unlimited account look
64
+ completely blocked.
65
+ """
66
+
67
+ limit: int | None = None
68
+ remaining: int | None = None
69
+ reset: int | None = None
70
+ concurrent_batch_limit: int | None = None
71
+ concurrent_batch_remaining: int | None = None
72
+ sync_limit: int | None = None
73
+ sync_remaining: int | None = None
74
+ sync_reset: int | None = None
75
+ sync_concurrent_limit: int | None = None
76
+ sync_concurrent_remaining: int | None = None
77
+
78
+ @classmethod
79
+ def from_headers(cls, headers: Mapping[str, str]) -> "RateLimits":
80
+ def num(name: str) -> int | None:
81
+ raw = headers.get(name)
82
+ if raw is None:
83
+ return None
84
+ try:
85
+ return int(raw)
86
+ except (TypeError, ValueError):
87
+ return None
88
+
89
+ return cls(
90
+ limit=num("X-RateLimit-Limit"),
91
+ remaining=num("X-RateLimit-Remaining"),
92
+ reset=num("X-RateLimit-Reset"),
93
+ concurrent_batch_limit=num("X-Concurrent-Batch-Limit"),
94
+ concurrent_batch_remaining=num("X-Concurrent-Batch-Remaining"),
95
+ sync_limit=num("X-Sync-Limit"),
96
+ sync_remaining=num("X-Sync-Remaining"),
97
+ sync_reset=num("X-Sync-Reset"),
98
+ sync_concurrent_limit=num("X-Sync-Concurrent-Limit"),
99
+ sync_concurrent_remaining=num("X-Sync-Concurrent-Remaining"),
100
+ )
101
+
102
+
103
+ @dataclass
104
+ class ResultEntry:
105
+ """One row of a batch's results, as the cursor returns it.
106
+
107
+ `raw` is the whole entry as JSON. The named fields are the ones every
108
+ operation has; `data` is the record itself and its shape is the
109
+ operation's.
110
+ """
111
+
112
+ item_index: int
113
+ input: Any
114
+ status: str
115
+ credits_used: float
116
+ data: Any | None = None
117
+ error: Mapping[str, Any] | None = None
118
+ page: int | None = None
119
+ created_at: str = ""
120
+ raw: Mapping[str, Any] = field(default_factory=dict)
121
+
122
+ @property
123
+ def ok(self) -> bool:
124
+ return self.status == "succeeded"
125
+
126
+ @classmethod
127
+ def from_json(cls, d: Mapping[str, Any]) -> "ResultEntry":
128
+ return cls(
129
+ item_index=int(d.get("item_index", 0)),
130
+ input=d.get("input"),
131
+ status=str(d.get("status", "")),
132
+ credits_used=float(d.get("credits_used", 0) or 0),
133
+ data=d.get("data"),
134
+ error=d.get("error"),
135
+ page=d.get("page"),
136
+ created_at=str(d.get("created_at", "")),
137
+ raw=d,
138
+ )
139
+
140
+
141
+ @dataclass
142
+ class Batch:
143
+ """A submission and its progress."""
144
+
145
+ batch_id: str
146
+ operation: str
147
+ status: str
148
+ total: int = 0
149
+ succeeded: int = 0
150
+ failed: int = 0
151
+ pending: int = 0
152
+ results_available: int = 0
153
+ credits_used: float = 0.0
154
+ external_id: str = ""
155
+ created_at: str = ""
156
+ completed_at: str | None = None
157
+ recommended_poll_ms: int = 2000
158
+ raw: Mapping[str, Any] = field(default_factory=dict)
159
+
160
+ @property
161
+ def done(self) -> bool:
162
+ """True once the batch will produce nothing further."""
163
+ return self.status not in _LIVE
164
+
165
+ @classmethod
166
+ def from_json(cls, d: Mapping[str, Any]) -> "Batch":
167
+ return cls(
168
+ batch_id=str(d.get("batch_id", "")),
169
+ operation=str(d.get("operation", "")),
170
+ status=str(d.get("status", "")),
171
+ total=int(d.get("total", 0) or 0),
172
+ succeeded=int(d.get("succeeded", 0) or 0),
173
+ failed=int(d.get("failed", 0) or 0),
174
+ pending=int(d.get("pending", 0) or 0),
175
+ results_available=int(d.get("results_available", 0) or 0),
176
+ credits_used=float(d.get("credits_used", 0) or 0),
177
+ external_id=str(d.get("external_id", "") or ""),
178
+ created_at=str(d.get("created_at", "") or ""),
179
+ completed_at=d.get("completed_at"),
180
+ recommended_poll_ms=int(d.get("recommended_poll_ms", 2000) or 2000),
181
+ raw=d,
182
+ )
183
+
184
+
185
+ @dataclass
186
+ class SyncResult:
187
+ """What a `/sync` call answers with: one batch, one entry.
188
+
189
+ `complete` is the field to branch on. A synchronous request that ran out of
190
+ deadline hands back what landed and leaves the target queued at priority -
191
+ so `complete` is False, `result` may be None, and `batch_id` is still a real
192
+ batch you can read later. It is never a timeout you have to parse, and
193
+ nothing already paid for is lost.
194
+ """
195
+
196
+ batch_id: str
197
+ operation: str
198
+ status: str
199
+ complete: bool
200
+ result: ResultEntry | None
201
+ credits_used: float = 0.0
202
+ raw: Mapping[str, Any] = field(default_factory=dict)
203
+
204
+ @classmethod
205
+ def from_json(cls, d: Mapping[str, Any]) -> "SyncResult":
206
+ entry = d.get("result")
207
+ return cls(
208
+ batch_id=str(d.get("batch_id", "")),
209
+ operation=str(d.get("operation", "")),
210
+ status=str(d.get("status", "")),
211
+ complete=bool(d.get("complete", False)),
212
+ result=ResultEntry.from_json(entry) if isinstance(entry, Mapping) else None,
213
+ credits_used=float(d.get("credits_used", 0) or 0),
214
+ raw=d,
215
+ )
216
+
217
+
218
+ @dataclass
219
+ class ResultsPage:
220
+ """One read of the results cursor.
221
+
222
+ `recommended_poll_ms` is the server's own number and is 0 once the batch is
223
+ terminal, which is the signal that there is nothing left to wait for.
224
+ """
225
+
226
+ status: str
227
+ entries: list[ResultEntry]
228
+ next_cursor: str
229
+ has_more: bool
230
+ recommended_poll_ms: int = 0
231
+
232
+ @property
233
+ def still_running(self) -> bool:
234
+ return self.status in _LIVE
235
+
236
+
237
+ class _Operation:
238
+ """One operation, callable three ways.
239
+
240
+ `ed.profiles_enrich(targets)` submits a batch, `.sync(target)` does the
241
+ blocking single lookup, and `.collect(targets)` submits and drains. They are
242
+ the same operation, which is why they are one object rather than three
243
+ method names that have to be kept in step.
244
+ """
245
+
246
+ def __init__(self, client: "EasyData", path: str) -> None:
247
+ self._client = client
248
+ self._path = path
249
+
250
+ def __call__(self, targets: Sequence[Any], **kwargs: Any) -> Batch:
251
+ return self._client.submit(self._path, targets, **kwargs)
252
+
253
+ def sync(self, target: Any, **kwargs: Any) -> SyncResult:
254
+ return self._client.submit_sync(self._path, target, **kwargs)
255
+
256
+ def collect(self, targets: Sequence[Any], **kwargs: Any) -> list[ResultEntry]:
257
+ """Submit and return every entry, blocking until the batch is done."""
258
+ timeout = kwargs.pop("timeout", None)
259
+ batch = self(targets, **kwargs)
260
+ return list(self._client.results(batch.batch_id, timeout=timeout))
261
+
262
+
263
+ class EasyData:
264
+ """The client.
265
+
266
+ >>> ed = EasyData() # reads EASYDATA_API_KEY
267
+ >>> r = ed.profiles_enrich.sync("https://linkedin.com/in/satyanadella")
268
+ >>> r.result.data["full_name"]
269
+
270
+ Retries are on by default and cover exactly the failures that are safe to
271
+ repeat: 429, 5xx and a transport error. Submissions carry an
272
+ `Idempotency-Key` so that repeating one is free - a retried submit resolves
273
+ to the batch the first attempt created rather than creating a second one and
274
+ charging for it twice.
275
+ """
276
+
277
+ def __init__(
278
+ self,
279
+ api_key: str | None = None,
280
+ *,
281
+ base_url: str | None = None,
282
+ timeout: float = 120.0,
283
+ max_retries: int = 3,
284
+ user_agent: str = "easydata-python/1.0",
285
+ ) -> None:
286
+ key = api_key or os.environ.get("EASYDATA_API_KEY", "")
287
+ if not key:
288
+ raise ValueError(
289
+ "no API key: pass api_key= or set EASYDATA_API_KEY in the environment"
290
+ )
291
+ self.api_key = key
292
+ self.base_url = (base_url or os.environ.get("EASYDATA_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
293
+ self.timeout = timeout
294
+ self.max_retries = max_retries
295
+ self.user_agent = user_agent
296
+
297
+ #: The ceilings from the most recent response. Updated on every call,
298
+ #: including a failed one - a 429 is where they matter most.
299
+ self.rate_limits = RateLimits()
300
+
301
+ for attr, path in OPERATIONS.items():
302
+ setattr(self, attr, _Operation(self, path))
303
+
304
+ # ---------------------------------------------------------------- submit
305
+
306
+ def submit(
307
+ self,
308
+ path: str,
309
+ targets: Sequence[Any],
310
+ *,
311
+ external_id: str | None = None,
312
+ callback_url: str | None = None,
313
+ webhook_tag: str | None = None,
314
+ max_results: int | None = None,
315
+ enrich: bool | None = None,
316
+ find_emails: bool | None = None,
317
+ include_results: bool | None = None,
318
+ idempotency_key: str | None = None,
319
+ ) -> Batch:
320
+ """Submit a batch. Answers 202 with the batch; nothing has run yet.
321
+
322
+ A batch of one is a batch. There is no separate single-target path and
323
+ no ceiling to discover between one target and fifty thousand.
324
+ """
325
+ if not targets:
326
+ raise ValueError("targets is empty: a batch needs at least one target")
327
+
328
+ body: dict[str, Any] = {"targets": list(targets)}
329
+ _put(body, external_id=external_id, callback_url=callback_url,
330
+ webhook_tag=webhook_tag, max_results=max_results, enrich=enrich,
331
+ find_emails=find_emails, include_results=include_results)
332
+
333
+ data = self._request(
334
+ "POST", path, body=body,
335
+ # Minted here rather than left to the caller, because the retry
336
+ # below is ours: without a key, our own retry of a submission that
337
+ # actually succeeded creates a second batch and bills it.
338
+ idempotency_key=idempotency_key or str(uuid.uuid4()),
339
+ )
340
+ return Batch.from_json(data)
341
+
342
+ def submit_sync(
343
+ self,
344
+ path: str,
345
+ target: Any,
346
+ *,
347
+ external_id: str | None = None,
348
+ max_results: int | None = None,
349
+ idempotency_key: str | None = None,
350
+ ) -> SyncResult:
351
+ """One entity, answered in this response, at twice the credits.
352
+
353
+ `target`, singular - not a list of one. The bounds are refusals rather
354
+ than downgrades: no `enrich`, no webhooks, and one upstream page for a
355
+ paged operation.
356
+ """
357
+ if isinstance(target, (list, tuple, set)):
358
+ raise ValueError(
359
+ "sync takes ONE target, not a sequence - submit a batch for several"
360
+ )
361
+
362
+ body: dict[str, Any] = {"target": target}
363
+ _put(body, external_id=external_id, max_results=max_results)
364
+
365
+ data = self._request(
366
+ "POST", path + "/sync", body=body,
367
+ idempotency_key=idempotency_key or str(uuid.uuid4()),
368
+ )
369
+ return SyncResult.from_json(data)
370
+
371
+ # --------------------------------------------------------------- batches
372
+
373
+ def batch(self, batch_id: str) -> Batch:
374
+ """One batch's current state."""
375
+ return Batch.from_json(self._request("GET", f"/batches/{batch_id}"))
376
+
377
+ def batches(
378
+ self,
379
+ *,
380
+ status: str | None = None,
381
+ operation: str | None = None,
382
+ external_id: str | None = None,
383
+ limit: int = 50,
384
+ offset: int = 0,
385
+ ) -> list[Batch]:
386
+ """List your batches, newest first."""
387
+ query = {"limit": limit, "offset": offset}
388
+ _put(query, status=status, operation=operation, external_id=external_id)
389
+ # `data` is the array itself, not an object wrapping one: the listing's
390
+ # own pagination lives in `meta` (total, hasMore) like every other
391
+ # response's does.
392
+ data = self._request("GET", "/batches", query=query)
393
+ return [Batch.from_json(b) for b in (data or [])]
394
+
395
+ def cancel(self, batch_id: str) -> Batch:
396
+ """Stop a batch. Entries already delivered stay delivered and charged."""
397
+ return Batch.from_json(self._request("POST", f"/batches/{batch_id}/cancel"))
398
+
399
+ def results_page(
400
+ self,
401
+ batch_id: str,
402
+ *,
403
+ cursor: str = "",
404
+ page_size: int = 100,
405
+ ) -> "ResultsPage":
406
+ """One page of the cursor, and the cursor to continue from.
407
+
408
+ `results()` is the loop you usually want. This is the layer under it,
409
+ for a caller that owns its own paging - a worker that stores the cursor
410
+ between runs, or a request handler that must return rather than block.
411
+ The cursor is opaque: hand back exactly what you were given.
412
+ """
413
+ query: dict[str, Any] = {"limit": page_size}
414
+ if cursor:
415
+ query["cursor"] = cursor
416
+
417
+ data, meta = self._request(
418
+ "GET", f"/batches/{batch_id}/results", query=query, with_meta=True
419
+ )
420
+ return ResultsPage(
421
+ status=str(data.get("status", "")),
422
+ entries=[ResultEntry.from_json(e) for e in (data.get("entries") or [])],
423
+ next_cursor=str(meta.get("nextCursor") or ""),
424
+ has_more=bool(meta.get("hasMore")),
425
+ recommended_poll_ms=int(meta.get("recommendedPollMs") or 0),
426
+ )
427
+
428
+ def results(
429
+ self,
430
+ batch_id: str,
431
+ *,
432
+ page_size: int = 100,
433
+ wait: bool = True,
434
+ timeout: float | None = None,
435
+ ) -> Iterator[ResultEntry]:
436
+ """Stream a batch's entries, yielding each one as it becomes readable.
437
+
438
+ This is the whole point of the cursor: results are readable WHILE the
439
+ batch is still processing, so a long batch starts producing rows
440
+ immediately rather than after it finishes.
441
+
442
+ With `wait=True` (the default) the iterator keeps the cursor open until
443
+ the batch reaches a terminal state, sleeping for as long as the API's
444
+ own `recommended_poll_ms` says between empty reads. With `wait=False` it
445
+ yields what is readable right now and stops.
446
+
447
+ The cursor is monotonic and gapless, so resuming is exact: an entry is
448
+ yielded once, and a batch still filling in never re-delivers one you
449
+ have already seen.
450
+ """
451
+ cursor = ""
452
+ deadline = None if timeout is None else time.monotonic() + timeout
453
+
454
+ while True:
455
+ page = self.results_page(batch_id, cursor=cursor, page_size=page_size)
456
+ yield from page.entries
457
+
458
+ if page.next_cursor:
459
+ cursor = page.next_cursor
460
+
461
+ if page.has_more:
462
+ continue
463
+
464
+ if not wait or page.status not in _LIVE:
465
+ return
466
+
467
+ if deadline is not None and time.monotonic() >= deadline:
468
+ raise TimeoutError(
469
+ f"batch {batch_id} was still {page.status} after {timeout}s; "
470
+ "its results remain readable - call results() again with the "
471
+ "same batch id"
472
+ )
473
+
474
+ # The server's own number. Polling faster than this does not make
475
+ # the scrape finish sooner; it only spends the request budget the
476
+ # rate limiter is counting.
477
+ time.sleep((page.recommended_poll_ms or 2000) / 1000.0)
478
+
479
+ def wait(self, batch_id: str, *, timeout: float | None = None) -> Batch:
480
+ """Block until a batch reaches a terminal state, and return it.
481
+
482
+ Use `results()` instead when you want the rows: this polls the batch
483
+ row, which tells you the counters and not the records.
484
+ """
485
+ deadline = None if timeout is None else time.monotonic() + timeout
486
+ while True:
487
+ b = self.batch(batch_id)
488
+ if b.done:
489
+ return b
490
+ if deadline is not None and time.monotonic() >= deadline:
491
+ raise TimeoutError(f"batch {batch_id} was still {b.status} after {timeout}s")
492
+ time.sleep(b.recommended_poll_ms / 1000.0)
493
+
494
+ # --------------------------------------------------------------- account
495
+
496
+ def account(self) -> Mapping[str, Any]:
497
+ """The allowance, the ceilings, and the webhook signing secret."""
498
+ return self._request("GET", "/account")
499
+
500
+ def usage(
501
+ self,
502
+ *,
503
+ from_: str | None = None,
504
+ to: str | None = None,
505
+ external_id: str | None = None,
506
+ ) -> Mapping[str, Any]:
507
+ """Spend, by day and operation."""
508
+ query: dict[str, Any] = {}
509
+ _put(query, to=to, external_id=external_id)
510
+ if from_:
511
+ query["from"] = from_
512
+ return self._request("GET", "/usage", query=query)
513
+
514
+ # ------------------------------------------------------------- transport
515
+
516
+ def _request(
517
+ self,
518
+ method: str,
519
+ path: str,
520
+ *,
521
+ body: Mapping[str, Any] | None = None,
522
+ query: Mapping[str, Any] | None = None,
523
+ idempotency_key: str | None = None,
524
+ with_meta: bool = False,
525
+ ) -> Any:
526
+ url = self.base_url + path
527
+ if query:
528
+ clean = {k: v for k, v in query.items() if v is not None}
529
+ if clean:
530
+ url += "?" + urllib.parse.urlencode(clean)
531
+
532
+ payload = json.dumps(body).encode() if body is not None else None
533
+ headers = {
534
+ "X-API-Key": self.api_key,
535
+ "Accept": "application/json",
536
+ "User-Agent": self.user_agent,
537
+ }
538
+ if payload is not None:
539
+ headers["Content-Type"] = "application/json"
540
+ if idempotency_key:
541
+ headers["Idempotency-Key"] = idempotency_key
542
+
543
+ last: EasyDataError | None = None
544
+ for attempt in range(self.max_retries + 1):
545
+ try:
546
+ status, out, resp_headers = self._once(method, url, payload, headers)
547
+ except (urllib.error.URLError, TimeoutError, OSError) as exc:
548
+ # The request never produced a response. Safe to repeat: a
549
+ # submission carries an idempotency key, and everything else
550
+ # here is a read.
551
+ last = TransportError(f"could not reach {url}: {exc}")
552
+ if attempt >= self.max_retries:
553
+ raise last from exc
554
+ time.sleep(self._backoff(attempt, None))
555
+ continue
556
+
557
+ self.rate_limits = RateLimits.from_headers(resp_headers)
558
+
559
+ if 200 <= status < 300:
560
+ envelope = out if isinstance(out, Mapping) else {}
561
+ data = envelope.get("data", envelope)
562
+ return (data, envelope.get("meta") or {}) if with_meta else data
563
+
564
+ retry_after = _retry_after(resp_headers)
565
+ err = error_for(status, out if isinstance(out, Mapping) else {}, retry_after=retry_after)
566
+
567
+ # 429 and 5xx are the two the server is telling us to try again on.
568
+ # Everything else is a refusal that repeating cannot fix, and
569
+ # retrying it would only spend the rate budget on a certain no.
570
+ if status != 429 and status < 500:
571
+ raise err
572
+ if attempt >= self.max_retries:
573
+ raise err
574
+ last = err
575
+ time.sleep(self._backoff(attempt, retry_after))
576
+
577
+ raise last or EasyDataError("request failed") # pragma: no cover
578
+
579
+ def _once(
580
+ self, method: str, url: str, payload: bytes | None, headers: Mapping[str, str]
581
+ ) -> tuple[int, Any, dict[str, str]]:
582
+ req = urllib.request.Request(url, data=payload, method=method)
583
+ for k, v in headers.items():
584
+ req.add_header(k, v)
585
+
586
+ try:
587
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
588
+ return resp.status, _decode(resp.read()), dict(resp.headers)
589
+ except urllib.error.HTTPError as exc:
590
+ # An HTTP error IS a response - the API said no, in the envelope -
591
+ # so it is decoded here rather than raised as a transport failure.
592
+ return exc.code, _decode(exc.read()), dict(exc.headers or {})
593
+
594
+ def _backoff(self, attempt: int, retry_after: float | None) -> float:
595
+ """The server's own number when it sent one, otherwise exponential.
596
+
597
+ Jittered because the failure that produces a retry storm is the one
598
+ every client sees at the same instant, and an unjittered backoff
599
+ reconverges them on the same second.
600
+ """
601
+ if retry_after is not None:
602
+ return min(retry_after, 60.0)
603
+ return min(2.0**attempt, 30.0) * (0.5 + random.random() / 2)
604
+
605
+ def verify_webhook(self, secret: str, headers: Mapping[str, str], body: bytes, **kw: Any):
606
+ """Convenience for `easydata.webhooks.verify`. See that module."""
607
+ from . import webhooks
608
+
609
+ return webhooks.verify(secret, headers, body, **kw)
610
+
611
+
612
+ def _put(d: dict[str, Any], **kwargs: Any) -> None:
613
+ """Sets the keyword arguments that were actually given.
614
+
615
+ Absent is not the same as false here: `enrich=False` is a caller saying so,
616
+ and dropping it because it is falsy would silently send a different request
617
+ than the one they wrote.
618
+ """
619
+ for k, v in kwargs.items():
620
+ if v is not None:
621
+ d[k] = v
622
+
623
+
624
+ def _decode(raw: bytes) -> Any:
625
+ if not raw:
626
+ return {}
627
+ try:
628
+ return json.loads(raw)
629
+ except ValueError:
630
+ # A body that is not JSON is a proxy or a gateway answering, not this
631
+ # API. Keep it: it is the only evidence of what actually replied.
632
+ return {"error": {"type": "", "message": raw[:500].decode("utf-8", "replace")}}
633
+
634
+
635
+ def _retry_after(headers: Mapping[str, str]) -> float | None:
636
+ raw = headers.get("Retry-After") or headers.get("retry-after")
637
+ if raw is None:
638
+ return None
639
+ try:
640
+ return max(float(raw), 0.0)
641
+ except (TypeError, ValueError):
642
+ return None
easydata_api/errors.py ADDED
@@ -0,0 +1,199 @@
1
+ """The error vocabulary, as exceptions.
2
+
3
+ One class per `error.type` the API publishes, all under `EasyDataError`, so a
4
+ caller can catch the family or the one case they know how to handle. The type
5
+ string is the contract - the class is a convenience over it - which is why
6
+ `EasyDataError.type` is always the string the API sent, including for a type
7
+ this version of the SDK has never heard of.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Mapping
13
+
14
+
15
+ class EasyDataError(Exception):
16
+ """Any error the API reported, or any failure to reach it."""
17
+
18
+ #: The `error.type` string. Empty only for a transport failure.
19
+ type: str = ""
20
+
21
+ def __init__(
22
+ self,
23
+ message: str,
24
+ *,
25
+ type: str = "",
26
+ status: int = 0,
27
+ request_id: str = "",
28
+ field: str = "",
29
+ retry_after: float | None = None,
30
+ body: Mapping[str, Any] | None = None,
31
+ ) -> None:
32
+ super().__init__(message)
33
+ self.message = message
34
+ if type:
35
+ self.type = type
36
+ self.status = status
37
+ self.request_id = request_id
38
+ self.field = field
39
+ self.retry_after = retry_after
40
+ self.body = dict(body or {})
41
+
42
+ def __str__(self) -> str: # pragma: no cover - formatting only
43
+ parts = [self.message]
44
+ if self.type:
45
+ parts.append(f"type={self.type}")
46
+ if self.status:
47
+ parts.append(f"status={self.status}")
48
+ if self.request_id:
49
+ # Quote this at support and the request can be found in one query.
50
+ parts.append(f"request_id={self.request_id}")
51
+ if self.field:
52
+ parts.append(f"field={self.field}")
53
+ return " ".join(parts)
54
+
55
+
56
+ class InvalidRequest(EasyDataError):
57
+ """400. `field` names the offending key when the API could identify one.
58
+
59
+ An unknown key in the body is refused rather than ignored, so this is also
60
+ what a typo looks like - which is the point of refusing it.
61
+ """
62
+
63
+ type = "invalid_request"
64
+
65
+
66
+ class InvalidAPIKey(EasyDataError):
67
+ """401. Missing, malformed, revoked or expired. Never metered."""
68
+
69
+ type = "invalid_api_key"
70
+
71
+
72
+ class QuotaExhausted(EasyDataError):
73
+ """403. The monthly allowance is spent. Waiting is the fix."""
74
+
75
+ type = "quota_exhausted"
76
+
77
+
78
+ class EmailUnverified(EasyDataError):
79
+ """403, and NOT the same as a spent allowance.
80
+
81
+ The default allowance is gated on somebody on the organization having
82
+ confirmed their email address. Clicking the link fixes it; waiting for next
83
+ month does not.
84
+ """
85
+
86
+ type = "email_unverified"
87
+
88
+
89
+ class NotFound(EasyDataError):
90
+ """404. A batch id that is not yours, or is not a batch."""
91
+
92
+ type = "not_found"
93
+
94
+
95
+ class Conflict(EasyDataError):
96
+ """409. Most often an `Idempotency-Key` replayed with a different body."""
97
+
98
+ type = "conflict"
99
+
100
+
101
+ class UnprocessableTarget(EasyDataError):
102
+ """422. The request was well-formed and the target was not usable."""
103
+
104
+ type = "unprocessable_target"
105
+
106
+
107
+ class RateLimited(EasyDataError):
108
+ """429. `retry_after` carries the server's own number, in seconds.
109
+
110
+ The client retries this for you by default - see `EasyData(max_retries=)`.
111
+ Seeing it means the retries were exhausted or were switched off.
112
+ """
113
+
114
+ type = "rate_limited"
115
+
116
+
117
+ class NotImplementedYet(EasyDataError):
118
+ """501. The operation is published and its scraper is not live yet."""
119
+
120
+ type = "not_implemented"
121
+
122
+
123
+ class InternalError(EasyDataError):
124
+ """500. Ours. Retried by default."""
125
+
126
+ type = "internal_error"
127
+
128
+
129
+ class UpstreamTimeout(EasyDataError):
130
+ """504-shaped: the upstream did not answer in time."""
131
+
132
+ type = "upstream_timeout"
133
+
134
+
135
+ class CapacityUnavailable(EasyDataError):
136
+ """No worker in the pool can run this operation right now."""
137
+
138
+ type = "capacity_unavailable"
139
+
140
+
141
+ class TransportError(EasyDataError):
142
+ """The request never produced an API response: DNS, TLS, socket, timeout.
143
+
144
+ Distinct from every class above, which all mean "the API answered and said
145
+ no". A caller retrying on its own needs to know which of the two it got.
146
+ """
147
+
148
+
149
+ #: Built from the classes rather than typed out again - a table written twice
150
+ #: is a table that disagrees with itself after one rename.
151
+ _BY_TYPE = {
152
+ cls.type: cls
153
+ for cls in (
154
+ InvalidRequest,
155
+ InvalidAPIKey,
156
+ QuotaExhausted,
157
+ EmailUnverified,
158
+ NotFound,
159
+ Conflict,
160
+ UnprocessableTarget,
161
+ RateLimited,
162
+ NotImplementedYet,
163
+ InternalError,
164
+ UpstreamTimeout,
165
+ CapacityUnavailable,
166
+ )
167
+ }
168
+
169
+
170
+ def error_for(
171
+ status: int,
172
+ body: Mapping[str, Any],
173
+ *,
174
+ retry_after: float | None = None,
175
+ ) -> EasyDataError:
176
+ """Builds the exception for one error response.
177
+
178
+ An unrecognised `error.type` becomes a plain `EasyDataError` carrying that
179
+ string, never a crash: the vocabulary is allowed to grow, and an SDK a
180
+ version behind should still hand the caller something they can read and
181
+ report.
182
+ """
183
+ err = body.get("error") if isinstance(body, Mapping) else None
184
+ if not isinstance(err, Mapping):
185
+ err = {}
186
+
187
+ etype = str(err.get("type") or "")
188
+ message = str(err.get("message") or f"HTTP {status}")
189
+ cls = _BY_TYPE.get(etype, EasyDataError)
190
+
191
+ return cls(
192
+ message,
193
+ type=etype,
194
+ status=status,
195
+ request_id=str(err.get("requestId") or err.get("request_id") or ""),
196
+ field=str(err.get("field") or ""),
197
+ retry_after=retry_after,
198
+ body=body,
199
+ )
@@ -0,0 +1,234 @@
1
+ """Verifying a webhook delivery.
2
+
3
+ Two schemes ride on every delivery and you need only one of them.
4
+
5
+ `X-EasyData-Signature: t=<unix>,v1=<hex>` is an HMAC-SHA256 over
6
+ `<t>.<raw body>`, keyed with your webhook secret. It needs nothing but the
7
+ standard library.
8
+
9
+ `X-EasyData-Signature-Ed25519: t=<unix>,kid=<key id>,wid=<endpoint id>,v1b=<b64url>`
10
+ signs `<t>.<wid>.<raw body>` and is checked against the public key published at
11
+ `https://api.easydata.win/.well-known/webhook-keys.json`. Verifying it needs no
12
+ secret at all, so a partner, a queue consumer or an edge function can check a
13
+ delivery you forwarded to them. It needs `cryptography`, which is why it is an
14
+ optional extra rather than a dependency.
15
+
16
+ Two rules that are easy to get wrong and silent when you do:
17
+
18
+ * **Verify against the RAW body**, exactly the bytes that arrived. Re-serialising
19
+ the parsed JSON changes key order and whitespace, and the signature will not
20
+ match a body you rebuilt.
21
+ * **Never verify against `X-EasyData-Timestamp`.** It carries the same unix
22
+ seconds, but it sits OUTSIDE both signed messages - anyone replaying a captured
23
+ delivery can set it to whatever passes a freshness check. The `t` inside the
24
+ signature header is the only copy that cannot be edited without breaking the
25
+ signature, and it is the one both functions here read.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import hashlib
31
+ import hmac
32
+ import json
33
+ import time
34
+ from typing import Any, Mapping, NamedTuple
35
+
36
+ __all__ = ["Delivery", "verify", "verify_ed25519", "VerificationError"]
37
+
38
+ #: The default freshness window, in seconds. Generous enough for a retry and a
39
+ #: clock that is a little out, short enough that a captured delivery goes stale.
40
+ DEFAULT_TOLERANCE = 300
41
+
42
+
43
+ class VerificationError(Exception):
44
+ """The delivery did not verify. Answer 400 and do not process it."""
45
+
46
+
47
+ class Delivery(NamedTuple):
48
+ """A verified delivery, unpacked."""
49
+
50
+ id: str
51
+ event: str
52
+ created_at: str
53
+ data: Mapping[str, Any]
54
+ #: The whole parsed body, for anything this tuple does not name.
55
+ body: Mapping[str, Any]
56
+
57
+
58
+ def verify(
59
+ secret: str,
60
+ headers: Mapping[str, str],
61
+ body: bytes,
62
+ *,
63
+ tolerance: int = DEFAULT_TOLERANCE,
64
+ ) -> Delivery:
65
+ """Verifies the HMAC signature and returns the delivery.
66
+
67
+ Raises `VerificationError` on anything wrong - a missing header, a stale
68
+ timestamp, a bad digest - because there is nothing useful a caller can do
69
+ with a delivery that half-verified.
70
+
71
+ @app.post("/webhooks/easydata")
72
+ def hook():
73
+ try:
74
+ d = verify(SECRET, request.headers, request.get_data())
75
+ except VerificationError:
76
+ return "", 400
77
+ if seen(d.id): # X-EasyData-Delivery, stable across retries
78
+ return "", 200
79
+ handle(d.event, d.data)
80
+ return "", 200
81
+ """
82
+ if not secret:
83
+ raise VerificationError("no signing secret: read it from GET /v1/account")
84
+
85
+ raw = _header(headers, "X-EasyData-Signature")
86
+ if not raw:
87
+ raise VerificationError("no X-EasyData-Signature header")
88
+
89
+ parts = _parse(raw)
90
+ ts, sig = parts.get("t"), parts.get("v1")
91
+ if not ts or not sig:
92
+ raise VerificationError(f"malformed signature header: {raw!r}")
93
+
94
+ _check_age(ts, tolerance)
95
+
96
+ expected = hmac.new(secret.encode(), f"{ts}.".encode() + body, hashlib.sha256).hexdigest()
97
+ # Constant time: a byte-at-a-time comparison leaks where the first
98
+ # difference is, which is enough to forge a digest given enough attempts.
99
+ if not hmac.compare_digest(expected, sig):
100
+ raise VerificationError("signature does not match")
101
+
102
+ return _delivery(body)
103
+
104
+
105
+ def verify_ed25519(
106
+ public_key_b64: str,
107
+ headers: Mapping[str, str],
108
+ body: bytes,
109
+ *,
110
+ webhook_id: str,
111
+ tolerance: int = DEFAULT_TOLERANCE,
112
+ ) -> Delivery:
113
+ """Verifies the Ed25519 signature. Needs `pip install easydata-api[ed25519]`.
114
+
115
+ `webhook_id` is YOUR endpoint's id, and passing it is not optional.
116
+
117
+ One key signs for every customer on the deployment, so a delivery another
118
+ customer legitimately received is a genuinely signed message. Without
119
+ checking that `wid` is your endpoint, replaying theirs against your receiver
120
+ verifies. The HMAC scheme needs no equivalent check, because your secret is
121
+ only yours.
122
+ """
123
+ # Everything that can be decided without the optional dependency is decided
124
+ # first. The `wid` check below is the security-relevant one and it is pure
125
+ # string comparison; importing ahead of it turned "this delivery is not
126
+ # yours" into "you have not installed cryptography", which is the wrong
127
+ # answer to the more important question.
128
+ if not webhook_id:
129
+ raise VerificationError(
130
+ "webhook_id is required: one key signs for every customer, so a "
131
+ "delivery is only yours if wid matches your endpoint"
132
+ )
133
+
134
+ raw = _header(headers, "X-EasyData-Signature-Ed25519")
135
+ if not raw:
136
+ raise VerificationError("no X-EasyData-Signature-Ed25519 header")
137
+
138
+ parts = _parse(raw)
139
+ ts, wid, sig = parts.get("t"), parts.get("wid"), parts.get("v1b")
140
+ if not ts or not wid or not sig:
141
+ raise VerificationError(f"malformed signature header: {raw!r}")
142
+
143
+ if not hmac.compare_digest(wid, webhook_id):
144
+ raise VerificationError(
145
+ f"delivery was addressed to endpoint {wid}, not to {webhook_id}"
146
+ )
147
+
148
+ _check_age(ts, tolerance)
149
+
150
+ try:
151
+ from cryptography.exceptions import InvalidSignature
152
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
153
+ except ImportError as exc: # pragma: no cover - depends on the extra
154
+ raise VerificationError(
155
+ "Ed25519 verification needs the `cryptography` package: "
156
+ "pip install easydata-api[ed25519]. The HMAC scheme in verify() needs nothing."
157
+ ) from exc
158
+
159
+ key = Ed25519PublicKey.from_public_bytes(_b64(public_key_b64))
160
+ message = f"{ts}.{wid}.".encode() + body
161
+ try:
162
+ key.verify(_b64(sig), message)
163
+ except InvalidSignature as exc:
164
+ raise VerificationError("signature does not match") from exc
165
+
166
+ return _delivery(body)
167
+
168
+
169
+ def _delivery(body: bytes) -> Delivery:
170
+ try:
171
+ parsed = json.loads(body)
172
+ except ValueError as exc:
173
+ raise VerificationError("body is not JSON") from exc
174
+ if not isinstance(parsed, dict):
175
+ raise VerificationError("body is not an object")
176
+
177
+ # The event's own fields are in `data`, one level down. Reading the top
178
+ # level instead is the quietest bug this API can hand you: the signature
179
+ # still verifies, the handler still returns 2xx, and every field is None.
180
+ return Delivery(
181
+ id=str(parsed.get("id", "")),
182
+ event=str(parsed.get("event", "")),
183
+ created_at=str(parsed.get("createdAt", "")),
184
+ data=parsed.get("data") or {},
185
+ body=parsed,
186
+ )
187
+
188
+
189
+ def _check_age(ts: str, tolerance: int) -> None:
190
+ try:
191
+ sent = int(ts)
192
+ except ValueError as exc:
193
+ raise VerificationError(f"signature timestamp is not a number: {ts!r}") from exc
194
+ if tolerance > 0 and abs(time.time() - sent) > tolerance:
195
+ raise VerificationError(f"signature timestamp is outside {tolerance}s")
196
+
197
+
198
+ def _parse(header: str) -> dict[str, str]:
199
+ out: dict[str, str] = {}
200
+ for part in header.split(","):
201
+ k, _, v = part.strip().partition("=")
202
+ if k:
203
+ out[k] = v
204
+ return out
205
+
206
+
207
+ def _header(headers: Mapping[str, str], name: str) -> str:
208
+ """HTTP header names are case-insensitive and half the frameworks lowercase
209
+ them, so look the obvious ways before giving up."""
210
+ for key in (name, name.lower(), name.upper(), name.replace("-", "_").upper()):
211
+ if key in headers:
212
+ return str(headers[key])
213
+ for key, value in headers.items():
214
+ if key.lower() == name.lower():
215
+ return str(value)
216
+ return ""
217
+
218
+
219
+ def _b64(s: str) -> bytes:
220
+ """Decodes base64 in whichever of the four spellings arrived.
221
+
222
+ The signature is base64url without padding and a public key is pasted into
223
+ an env file by a human, so which alphabet and whether it is padded is not
224
+ worth failing a deployment over.
225
+ """
226
+ import base64
227
+
228
+ for decoder in (base64.urlsafe_b64decode, base64.b64decode):
229
+ for candidate in (s, s + "=" * (-len(s) % 4)):
230
+ try:
231
+ return decoder(candidate)
232
+ except Exception: # noqa: BLE001 - trying the next spelling
233
+ continue
234
+ raise VerificationError(f"could not decode base64: {s[:16]!r}...")
@@ -0,0 +1,185 @@
1
+ Metadata-Version: 2.4
2
+ Name: easydata-api
3
+ Version: 1.0.1
4
+ Summary: Official Python client for the EasyData LinkedIn enrichment API
5
+ Author-email: EasyData Development <dev@easydata.win>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://easydata.win
8
+ Project-URL: Documentation, https://easydata.win/docs/api
9
+ Project-URL: Reference, https://easydata.win/openapi.yaml
10
+ Keywords: linkedin,enrichment,data,api,scraping
11
+ Classifier: Development Status :: 5 - Production/Stable
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Provides-Extra: ed25519
19
+ Requires-Dist: cryptography>=42; extra == "ed25519"
20
+ Dynamic: license-file
21
+
22
+ # EasyData for Python
23
+
24
+ The official client for the [EasyData](https://easydata.win) LinkedIn
25
+ enrichment API.
26
+
27
+ ```bash
28
+ pip install easydata-api
29
+ ```
30
+
31
+ Zero dependencies. The client is `urllib` and the webhook HMAC is
32
+ `hmac`/`hashlib`, both standard library - an enrichment script should not drag a
33
+ dependency tree in behind it. Only the asymmetric webhook scheme needs an extra
34
+ (`pip install easydata-api[ed25519]`), and most receivers never use it.
35
+
36
+ ## One record
37
+
38
+ ```python
39
+ from easydata_api import EasyData
40
+
41
+ ed = EasyData() # reads EASYDATA_API_KEY
42
+
43
+ r = ed.profiles_enrich.sync("https://linkedin.com/in/satyanadella")
44
+ print(r.result.data["full_name"])
45
+ ```
46
+
47
+ `.sync()` takes **one** target and answers with **one** record, in the response.
48
+ It costs double, it accepts no webhooks and no `enrich`, and for a paged
49
+ operation it returns one upstream page. Those bounds are refusals, not quiet
50
+ downgrades.
51
+
52
+ If the deadline expires first you get `complete=False` and a real `batch_id`,
53
+ never a 504 - so you keep the handle to results you may already have been
54
+ charged for.
55
+
56
+ ## A batch
57
+
58
+ Everything is a batch, including a batch of one. There is no ceiling to discover
59
+ between one target and fifty thousand.
60
+
61
+ ```python
62
+ batch = ed.profiles_enrich(urls, external_id="crm-sync", find_emails=True)
63
+
64
+ for entry in ed.results(batch.batch_id):
65
+ if entry.ok:
66
+ save(entry.data)
67
+ else:
68
+ log(entry.input, entry.error["type"]) # and credits_used is 0
69
+ ```
70
+
71
+ `results()` is a generator over the cursor. Results **stream**: it yields rows
72
+ while the batch is still processing, so a long batch starts producing
73
+ immediately rather than after it finishes. It holds the cursor open until the
74
+ batch reaches a terminal state, sleeping for the server's own poll interval
75
+ between empty reads.
76
+
77
+ ```python
78
+ list(ed.results(batch_id, wait=False)) # what is readable right now
79
+ ed.wait(batch_id, timeout=600) # the counters, not the rows
80
+ ed.profiles_enrich.collect(urls) # submit and drain, in one call
81
+ ```
82
+
83
+ ## Retries and double-billing
84
+
85
+ Retries are on by default and cover exactly what is safe to repeat: `429`, `5xx`
86
+ and a transport failure. A `4xx` is raised immediately, because repeating a
87
+ refusal only spends the rate budget on a certain no.
88
+
89
+ Every submission carries an `Idempotency-Key` that the client mints, so the
90
+ retry is free: a retried submit resolves to the batch the first attempt created
91
+ rather than creating a second one and charging for it. Pass your own
92
+ `idempotency_key=` if your caller's retry needs the same guarantee.
93
+
94
+ ```python
95
+ ed = EasyData(max_retries=5, timeout=180)
96
+ ed.rate_limits.remaining # read off the last response, including a 429
97
+ ```
98
+
99
+ `rate_limits` fields are `None` when the deployment publishes no ceiling. That
100
+ is what "no limit" looks like on the wire: an absent header, never a zero.
101
+
102
+ ## Errors
103
+
104
+ ```python
105
+ from easydata_api import QuotaExhausted, RateLimited, InvalidRequest, EasyDataError
106
+
107
+ try:
108
+ ed.profiles_enrich(urls)
109
+ except InvalidRequest as e:
110
+ print(e.field, e.request_id) # quote request_id at support
111
+ except QuotaExhausted:
112
+ ... # waiting for the month is the fix
113
+ except EasyDataError as e:
114
+ print(e.type, e.status)
115
+ ```
116
+
117
+ One class per `error.type`, all under `EasyDataError`. An `error.type` this
118
+ version has never heard of becomes a plain `EasyDataError` carrying that string
119
+ rather than a crash.
120
+
121
+ `EmailUnverified` is a `403` and is **not** a spent allowance: the default
122
+ allowance is gated on somebody having confirmed their email address, and
123
+ clicking the link fixes it.
124
+
125
+ ## Webhooks
126
+
127
+ ```python
128
+ from easydata_api import verify, VerificationError
129
+
130
+ @app.post("/webhooks/easydata")
131
+ def hook():
132
+ try:
133
+ d = verify(SECRET, request.headers, request.get_data())
134
+ except VerificationError:
135
+ return "", 400
136
+
137
+ if seen(d.id): # stable across every retry of the same delivery
138
+ return "", 200
139
+
140
+ if d.event == "batch.result":
141
+ handle(d.data["result"])
142
+ elif d.event == "batch.completed":
143
+ finish(d.data["batch_id"])
144
+ return "", 200
145
+ ```
146
+
147
+ Four events: `batch.started`, `batch.result` (one per row), `batch.completed`
148
+ and `batch.failed`. The fields are in `d.data`, one level down.
149
+
150
+ Two things that are silent when you get them wrong:
151
+
152
+ - **Verify against the raw body.** Re-serialising parsed JSON changes key order
153
+ and whitespace, and the signature will not match a body you rebuilt.
154
+ - **Never verify against `X-EasyData-Timestamp`.** It sits outside both signed
155
+ messages, so a replayed delivery can set it to anything. The `t` inside the
156
+ signature header is the only copy that cannot be edited.
157
+
158
+ For the asymmetric scheme, `webhook_id` is required - one key signs for every
159
+ customer, so checking `wid` is what stops another customer's genuine delivery
160
+ verifying against your receiver.
161
+
162
+ ```python
163
+ from easydata_api import verify_ed25519
164
+ d = verify_ed25519(PUBLIC_KEY, request.headers, body, webhook_id=MY_ENDPOINT_ID)
165
+ ```
166
+
167
+ ## Everything else
168
+
169
+ ```python
170
+ ed.batch(batch_id) # one batch's state
171
+ ed.batches(status="completed", external_id="crm") # your batches
172
+ ed.cancel(batch_id) # delivered rows stay charged
173
+ ed.account() # allowance, ceilings, secret
174
+ ed.usage(from_="2026-09-01", to="2026-09-30") # spend by day and operation
175
+ ```
176
+
177
+ Operations are attributes: `profiles_enrich`, `profiles_activity`,
178
+ `profiles_posts`, `profiles_comments`, `profiles_reactions`, `companies_enrich`,
179
+ `posts_enrich`, `sales_search_people`, `sales_search_companies`.
180
+
181
+ ## Reference
182
+
183
+ - [API reference](https://easydata.win/docs/api)
184
+ - [The batch model](https://easydata.win/docs/batches) - what a credit is, and why
185
+ - [OpenAPI 3.1](https://easydata.win/openapi.yaml)
@@ -0,0 +1,9 @@
1
+ easydata_api/__init__.py,sha256=aIhtD0aHCZU0m_aXXlQLyRP7sbe00Gv8xU6Xf3r84G0,1735
2
+ easydata_api/client.py,sha256=PdIarJRXe1uBKpL58EZ1j-P-2Lm8BCrntXt1mIDYq4E,23542
3
+ easydata_api/errors.py,sha256=4uWrGe7H9wblaroGwvqFoZIiScSdangNOiIvTZdVlNA,5537
4
+ easydata_api/webhooks.py,sha256=EfAY-zq3wNRqhOG4RPeu4Vh79zHjuOrt7seGrN9aTuM,8632
5
+ easydata_api-1.0.1.dist-info/licenses/LICENSE,sha256=OyJwsf0avfY-R1xUYt378xny55YToozBgEFTcKeljsQ,1065
6
+ easydata_api-1.0.1.dist-info/METADATA,sha256=2FWnwo9CVVo2-rEhbmFbyqQ-as6BjJtjAdsUhSx9Na4,6639
7
+ easydata_api-1.0.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
8
+ easydata_api-1.0.1.dist-info/top_level.txt,sha256=80mJkf3GRHHzo38CaRw8dqQ62vXQILoNJiSJi5OOIUU,13
9
+ easydata_api-1.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 EasyData
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ easydata_api