easydata-api 1.0.2__tar.gz → 1.1.0__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.
- {easydata_api-1.0.2 → easydata_api-1.1.0}/PKG-INFO +18 -1
- {easydata_api-1.0.2 → easydata_api-1.1.0}/README.md +17 -0
- {easydata_api-1.0.2 → easydata_api-1.1.0}/easydata_api/__init__.py +3 -1
- {easydata_api-1.0.2 → easydata_api-1.1.0}/easydata_api/client.py +127 -0
- {easydata_api-1.0.2 → easydata_api-1.1.0}/easydata_api/errors.py +15 -0
- {easydata_api-1.0.2 → easydata_api-1.1.0}/easydata_api.egg-info/PKG-INFO +18 -1
- {easydata_api-1.0.2 → easydata_api-1.1.0}/pyproject.toml +1 -1
- {easydata_api-1.0.2 → easydata_api-1.1.0}/tests/test_client.py +78 -1
- {easydata_api-1.0.2 → easydata_api-1.1.0}/LICENSE +0 -0
- {easydata_api-1.0.2 → easydata_api-1.1.0}/easydata_api/webhooks.py +0 -0
- {easydata_api-1.0.2 → easydata_api-1.1.0}/easydata_api.egg-info/SOURCES.txt +0 -0
- {easydata_api-1.0.2 → easydata_api-1.1.0}/easydata_api.egg-info/dependency_links.txt +0 -0
- {easydata_api-1.0.2 → easydata_api-1.1.0}/easydata_api.egg-info/requires.txt +0 -0
- {easydata_api-1.0.2 → easydata_api-1.1.0}/easydata_api.egg-info/top_level.txt +0 -0
- {easydata_api-1.0.2 → easydata_api-1.1.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: easydata-api
|
|
3
|
-
Version: 1.0
|
|
3
|
+
Version: 1.1.0
|
|
4
4
|
Summary: Official Python client for the EasyData LinkedIn enrichment API
|
|
5
5
|
Author-email: EasyData Development <dev@easydata.win>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -76,10 +76,27 @@ between empty reads.
|
|
|
76
76
|
|
|
77
77
|
```python
|
|
78
78
|
list(ed.results(batch_id, wait=False)) # what is readable right now
|
|
79
|
+
ed.results_page(batch_id, cursor=c) # one page, and the next cursor
|
|
79
80
|
ed.wait(batch_id, timeout=600) # the counters, not the rows
|
|
80
81
|
ed.profiles_enrich.collect(urls) # submit and drain, in one call
|
|
81
82
|
```
|
|
82
83
|
|
|
84
|
+
## Streaming instead of polling
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
for entry in ed.stream(batch.batch_id):
|
|
88
|
+
if entry.ok:
|
|
89
|
+
save(entry.data)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Same cursor, same entries, same order - the server pushes over a held
|
|
93
|
+
connection, so a long batch costs one request instead of hundreds against your
|
|
94
|
+
rate limit. Reconnects are handled and are exact: the event id IS the cursor, so
|
|
95
|
+
a dropped connection resumes with no duplicates and no gaps.
|
|
96
|
+
|
|
97
|
+
Use webhooks instead if you run a server with a public URL. This is for a client
|
|
98
|
+
with nowhere to deliver to: an agent on a laptop, a CLI, an edge function.
|
|
99
|
+
|
|
83
100
|
## Retries and double-billing
|
|
84
101
|
|
|
85
102
|
Retries are on by default and cover exactly what is safe to repeat: `429`, `5xx`
|
|
@@ -55,10 +55,27 @@ between empty reads.
|
|
|
55
55
|
|
|
56
56
|
```python
|
|
57
57
|
list(ed.results(batch_id, wait=False)) # what is readable right now
|
|
58
|
+
ed.results_page(batch_id, cursor=c) # one page, and the next cursor
|
|
58
59
|
ed.wait(batch_id, timeout=600) # the counters, not the rows
|
|
59
60
|
ed.profiles_enrich.collect(urls) # submit and drain, in one call
|
|
60
61
|
```
|
|
61
62
|
|
|
63
|
+
## Streaming instead of polling
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
for entry in ed.stream(batch.batch_id):
|
|
67
|
+
if entry.ok:
|
|
68
|
+
save(entry.data)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Same cursor, same entries, same order - the server pushes over a held
|
|
72
|
+
connection, so a long batch costs one request instead of hundreds against your
|
|
73
|
+
rate limit. Reconnects are handled and are exact: the event id IS the cursor, so
|
|
74
|
+
a dropped connection resumes with no duplicates and no gaps.
|
|
75
|
+
|
|
76
|
+
Use webhooks instead if you run a server with a public URL. This is for a client
|
|
77
|
+
with nowhere to deliver to: an agent on a laptop, a CLI, an edge function.
|
|
78
|
+
|
|
62
79
|
## Retries and double-billing
|
|
63
80
|
|
|
64
81
|
Retries are on by default and cover exactly what is safe to repeat: `429`, `5xx`
|
|
@@ -33,6 +33,7 @@ from .errors import (
|
|
|
33
33
|
Conflict,
|
|
34
34
|
EasyDataError,
|
|
35
35
|
EmailUnverified,
|
|
36
|
+
InsufficientScope,
|
|
36
37
|
InternalError,
|
|
37
38
|
InvalidAPIKey,
|
|
38
39
|
InvalidRequest,
|
|
@@ -46,7 +47,7 @@ from .errors import (
|
|
|
46
47
|
)
|
|
47
48
|
from .webhooks import Delivery, VerificationError, verify, verify_ed25519
|
|
48
49
|
|
|
49
|
-
__version__ = "1.0
|
|
50
|
+
__version__ = "1.1.0"
|
|
50
51
|
|
|
51
52
|
__all__ = [
|
|
52
53
|
"EasyData",
|
|
@@ -62,6 +63,7 @@ __all__ = [
|
|
|
62
63
|
"InvalidAPIKey",
|
|
63
64
|
"QuotaExhausted",
|
|
64
65
|
"EmailUnverified",
|
|
66
|
+
"InsufficientScope",
|
|
65
67
|
"NotFound",
|
|
66
68
|
"Conflict",
|
|
67
69
|
"UnprocessableTarget",
|
|
@@ -476,6 +476,94 @@ class EasyData:
|
|
|
476
476
|
# rate limiter is counting.
|
|
477
477
|
time.sleep((page.recommended_poll_ms or 2000) / 1000.0)
|
|
478
478
|
|
|
479
|
+
def stream(
|
|
480
|
+
self,
|
|
481
|
+
batch_id: str,
|
|
482
|
+
*,
|
|
483
|
+
cursor: str = "",
|
|
484
|
+
max_reconnects: int = 10,
|
|
485
|
+
) -> Iterator[ResultEntry]:
|
|
486
|
+
"""Stream a batch's entries over a held connection instead of polling.
|
|
487
|
+
|
|
488
|
+
Same cursor, same entries, same order as `results()` - the difference
|
|
489
|
+
is that the server pushes rather than the client asking, so a long
|
|
490
|
+
batch costs one request instead of hundreds against your rate limit.
|
|
491
|
+
|
|
492
|
+
Use webhooks instead if you run a server with a public URL. This is for
|
|
493
|
+
a client with nowhere to deliver to: an agent on a laptop, a CLI, an
|
|
494
|
+
edge function.
|
|
495
|
+
|
|
496
|
+
Reconnects are handled here and are exact: the event id IS the cursor,
|
|
497
|
+
so a dropped connection resumes where it stopped with no duplicates and
|
|
498
|
+
no gaps. The iterator ends when the batch reaches a terminal state.
|
|
499
|
+
|
|
500
|
+
for entry in ed.stream(batch.batch_id):
|
|
501
|
+
if entry.ok:
|
|
502
|
+
save(entry.data)
|
|
503
|
+
"""
|
|
504
|
+
reconnects = 0
|
|
505
|
+
|
|
506
|
+
while True:
|
|
507
|
+
url = f"{self.base_url}/batches/{batch_id}/results"
|
|
508
|
+
if cursor:
|
|
509
|
+
url += "?" + urllib.parse.urlencode({"cursor": cursor})
|
|
510
|
+
|
|
511
|
+
headers = {
|
|
512
|
+
"X-API-Key": self.api_key,
|
|
513
|
+
"Accept": "text/event-stream",
|
|
514
|
+
"User-Agent": self.user_agent,
|
|
515
|
+
}
|
|
516
|
+
if cursor:
|
|
517
|
+
# Where to resume. The server prefers this over ?cursor= for
|
|
518
|
+
# the same reason a browser sends it: it is the more recent of
|
|
519
|
+
# the two.
|
|
520
|
+
headers["Last-Event-ID"] = cursor
|
|
521
|
+
|
|
522
|
+
req = urllib.request.Request(url, headers=headers, method="GET")
|
|
523
|
+
complete = False
|
|
524
|
+
|
|
525
|
+
try:
|
|
526
|
+
# No read timeout: a stream that stays open is the point. The
|
|
527
|
+
# server ends it with `timeout` after its own limit.
|
|
528
|
+
with urllib.request.urlopen(req) as resp:
|
|
529
|
+
for event, data, event_id in _parse_sse(resp):
|
|
530
|
+
if event_id:
|
|
531
|
+
cursor = event_id
|
|
532
|
+
|
|
533
|
+
if event == "result":
|
|
534
|
+
yield ResultEntry.from_json(json.loads(data))
|
|
535
|
+
elif event == "complete":
|
|
536
|
+
complete = True
|
|
537
|
+
break
|
|
538
|
+
elif event == "error":
|
|
539
|
+
raise EasyDataError(
|
|
540
|
+
f"stream failed: {data}. Resume from cursor {cursor}."
|
|
541
|
+
)
|
|
542
|
+
# `timeout` is the server ending a long stream on
|
|
543
|
+
# purpose. Falling through re-opens from the cursor,
|
|
544
|
+
# which is what it asked for - a caller never sees it.
|
|
545
|
+
except urllib.error.HTTPError as exc:
|
|
546
|
+
raise error_for(exc.code, _decode(exc.read())) from exc
|
|
547
|
+
except (urllib.error.URLError, OSError) as exc:
|
|
548
|
+
# A dropped connection is the case reconnecting exists for.
|
|
549
|
+
if reconnects >= max_reconnects:
|
|
550
|
+
raise TransportError(
|
|
551
|
+
f"stream dropped and could not be resumed from {cursor}: {exc}"
|
|
552
|
+
) from exc
|
|
553
|
+
reconnects += 1
|
|
554
|
+
time.sleep(self._backoff(reconnects, None))
|
|
555
|
+
continue
|
|
556
|
+
|
|
557
|
+
if complete:
|
|
558
|
+
return
|
|
559
|
+
|
|
560
|
+
# Ended without a `complete`. Re-open from the cursor.
|
|
561
|
+
reconnects += 1
|
|
562
|
+
if reconnects > max_reconnects:
|
|
563
|
+
raise TransportError(
|
|
564
|
+
f"stream dropped {reconnects} times without completing; last cursor {cursor}"
|
|
565
|
+
)
|
|
566
|
+
|
|
479
567
|
def wait(self, batch_id: str, *, timeout: float | None = None) -> Batch:
|
|
480
568
|
"""Block until a batch reaches a terminal state, and return it.
|
|
481
569
|
|
|
@@ -609,6 +697,45 @@ class EasyData:
|
|
|
609
697
|
return webhooks.verify(secret, headers, body, **kw)
|
|
610
698
|
|
|
611
699
|
|
|
700
|
+
def _parse_sse(stream: Any) -> Iterator[tuple[str, str, str]]:
|
|
701
|
+
"""The wire format of Server-Sent Events, as (event, data, id) triples.
|
|
702
|
+
|
|
703
|
+
Hand-rolled rather than a dependency, because this client has none and the
|
|
704
|
+
format is three rules: events are separated by a blank line, fields are
|
|
705
|
+
`name: value`, and a line starting with `:` is a comment.
|
|
706
|
+
|
|
707
|
+
Fields accumulate until a blank line, so a `data:` split across two reads is
|
|
708
|
+
reassembled rather than parsed as half an entry.
|
|
709
|
+
"""
|
|
710
|
+
event, data, event_id = "message", [], ""
|
|
711
|
+
|
|
712
|
+
for raw in stream:
|
|
713
|
+
line = raw.decode("utf-8", "replace").rstrip("\n").rstrip("\r")
|
|
714
|
+
|
|
715
|
+
if not line:
|
|
716
|
+
if data or event != "message":
|
|
717
|
+
yield event, "\n".join(data), event_id
|
|
718
|
+
event, data, event_id = "message", [], ""
|
|
719
|
+
continue
|
|
720
|
+
|
|
721
|
+
if line.startswith(":"):
|
|
722
|
+
continue # keepalive comment
|
|
723
|
+
|
|
724
|
+
field, _, value = line.partition(":")
|
|
725
|
+
# One optional space after the colon is part of the framing.
|
|
726
|
+
value = value[1:] if value.startswith(" ") else value
|
|
727
|
+
|
|
728
|
+
if field == "event":
|
|
729
|
+
event = value
|
|
730
|
+
elif field == "data":
|
|
731
|
+
data.append(value)
|
|
732
|
+
elif field == "id":
|
|
733
|
+
event_id = value
|
|
734
|
+
|
|
735
|
+
if data or event != "message":
|
|
736
|
+
yield event, "\n".join(data), event_id
|
|
737
|
+
|
|
738
|
+
|
|
612
739
|
def _put(d: dict[str, Any], **kwargs: Any) -> None:
|
|
613
740
|
"""Sets the keyword arguments that were actually given.
|
|
614
741
|
|
|
@@ -86,6 +86,20 @@ class EmailUnverified(EasyDataError):
|
|
|
86
86
|
type = "email_unverified"
|
|
87
87
|
|
|
88
88
|
|
|
89
|
+
class InsufficientScope(EasyDataError):
|
|
90
|
+
"""403. The key is valid and is the WRONG one.
|
|
91
|
+
|
|
92
|
+
It does not carry the scope this route needs - `read` to page results,
|
|
93
|
+
`write` to submit, cancel or change the account. The message names which.
|
|
94
|
+
|
|
95
|
+
Never retried, by this client or by you: no amount of waiting gives a
|
|
96
|
+
credential a scope it was not minted with. Mint a key that carries it.
|
|
97
|
+
`EasyData.account()["scopes"]` reports what the current key holds.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
type = "insufficient_scope"
|
|
101
|
+
|
|
102
|
+
|
|
89
103
|
class NotFound(EasyDataError):
|
|
90
104
|
"""404. A batch id that is not yours, or is not a batch."""
|
|
91
105
|
|
|
@@ -155,6 +169,7 @@ _BY_TYPE = {
|
|
|
155
169
|
InvalidAPIKey,
|
|
156
170
|
QuotaExhausted,
|
|
157
171
|
EmailUnverified,
|
|
172
|
+
InsufficientScope,
|
|
158
173
|
NotFound,
|
|
159
174
|
Conflict,
|
|
160
175
|
UnprocessableTarget,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: easydata-api
|
|
3
|
-
Version: 1.0
|
|
3
|
+
Version: 1.1.0
|
|
4
4
|
Summary: Official Python client for the EasyData LinkedIn enrichment API
|
|
5
5
|
Author-email: EasyData Development <dev@easydata.win>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -76,10 +76,27 @@ between empty reads.
|
|
|
76
76
|
|
|
77
77
|
```python
|
|
78
78
|
list(ed.results(batch_id, wait=False)) # what is readable right now
|
|
79
|
+
ed.results_page(batch_id, cursor=c) # one page, and the next cursor
|
|
79
80
|
ed.wait(batch_id, timeout=600) # the counters, not the rows
|
|
80
81
|
ed.profiles_enrich.collect(urls) # submit and drain, in one call
|
|
81
82
|
```
|
|
82
83
|
|
|
84
|
+
## Streaming instead of polling
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
for entry in ed.stream(batch.batch_id):
|
|
88
|
+
if entry.ok:
|
|
89
|
+
save(entry.data)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Same cursor, same entries, same order - the server pushes over a held
|
|
93
|
+
connection, so a long batch costs one request instead of hundreds against your
|
|
94
|
+
rate limit. Reconnects are handled and are exact: the event id IS the cursor, so
|
|
95
|
+
a dropped connection resumes with no duplicates and no gaps.
|
|
96
|
+
|
|
97
|
+
Use webhooks instead if you run a server with a public URL. This is for a client
|
|
98
|
+
with nowhere to deliver to: an agent on a laptop, a CLI, an edge function.
|
|
99
|
+
|
|
83
100
|
## Retries and double-billing
|
|
84
101
|
|
|
85
102
|
Retries are on by default and cover exactly what is safe to repeat: `429`, `5xx`
|
|
@@ -18,7 +18,7 @@ from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
|
18
18
|
|
|
19
19
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
20
20
|
|
|
21
|
-
from easydata_api import EasyData, InvalidRequest, RateLimited, webhooks # noqa: E402
|
|
21
|
+
from easydata_api import EasyData, EasyDataError, InvalidRequest, RateLimited, webhooks # noqa: E402
|
|
22
22
|
from easydata_api.errors import TransportError # noqa: E402
|
|
23
23
|
|
|
24
24
|
|
|
@@ -265,6 +265,83 @@ class TestResultsCursor(ServerCase):
|
|
|
265
265
|
self.assertIn("b-1", str(ctx.exception))
|
|
266
266
|
|
|
267
267
|
|
|
268
|
+
class _SSEHandler(BaseHTTPRequestHandler):
|
|
269
|
+
"""Serves a scripted SSE body, one small chunk at a time."""
|
|
270
|
+
|
|
271
|
+
def log_message(self, *args): # noqa: D102
|
|
272
|
+
pass
|
|
273
|
+
|
|
274
|
+
def do_GET(self):
|
|
275
|
+
self.server.seen.append({k.lower(): v for k, v in self.headers.items()})
|
|
276
|
+
body = self.server.bodies.pop(0) if self.server.bodies else "event: complete\ndata: {}\n\n"
|
|
277
|
+
|
|
278
|
+
self.send_response(200)
|
|
279
|
+
self.send_header("Content-Type", "text/event-stream")
|
|
280
|
+
self.end_headers()
|
|
281
|
+
# Deliberately tiny writes: a `data:` line split across reads is the
|
|
282
|
+
# bug every naive SSE parser has.
|
|
283
|
+
raw = body.encode()
|
|
284
|
+
for i in range(0, len(raw), 5):
|
|
285
|
+
self.wfile.write(raw[i : i + 5])
|
|
286
|
+
self.wfile.flush()
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class TestStreaming(unittest.TestCase):
|
|
290
|
+
def serve(self, bodies):
|
|
291
|
+
server = HTTPServer(("127.0.0.1", 0), _SSEHandler)
|
|
292
|
+
server.bodies = list(bodies)
|
|
293
|
+
server.seen = []
|
|
294
|
+
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
295
|
+
self.addCleanup(server.server_close)
|
|
296
|
+
self.addCleanup(server.shutdown)
|
|
297
|
+
host, port = server.server_address
|
|
298
|
+
return EasyData("pk_x", base_url=f"http://{host}:{port}"), server
|
|
299
|
+
|
|
300
|
+
def test_it_yields_entries_and_stops_on_complete(self):
|
|
301
|
+
client, server = self.serve([
|
|
302
|
+
'id: c1\nevent: result\ndata: {"item_index":0,"status":"succeeded","credits_used":1}\n\n'
|
|
303
|
+
": keepalive\n\n"
|
|
304
|
+
'id: c2\nevent: result\ndata: {"item_index":1,"status":"succeeded","credits_used":1}\n\n'
|
|
305
|
+
'id: c3\nevent: complete\ndata: {"status":"completed"}\n\n'
|
|
306
|
+
])
|
|
307
|
+
|
|
308
|
+
got = list(client.stream("b-1"))
|
|
309
|
+
|
|
310
|
+
self.assertEqual([e.item_index for e in got], [0, 1],
|
|
311
|
+
"a keepalive comment was parsed as an entry")
|
|
312
|
+
self.assertEqual(server.seen[0]["accept"], "text/event-stream")
|
|
313
|
+
|
|
314
|
+
def test_a_chunk_boundary_does_not_split_an_entry(self):
|
|
315
|
+
client, _ = self.serve([
|
|
316
|
+
'id: c1\nevent: result\ndata: {"item_index":7,"status":"succeeded","credits_used":1}\n\n'
|
|
317
|
+
'event: complete\ndata: {}\n\n'
|
|
318
|
+
])
|
|
319
|
+
(got,) = list(client.stream("b-1"))
|
|
320
|
+
self.assertEqual(got.item_index, 7)
|
|
321
|
+
|
|
322
|
+
def test_a_dropped_stream_resumes_from_the_last_event_id(self):
|
|
323
|
+
"""The whole reason the event id is the cursor: resuming must be exact."""
|
|
324
|
+
client, server = self.serve([
|
|
325
|
+
'id: c1\nevent: result\ndata: {"item_index":0,"status":"succeeded","credits_used":1}\n\n',
|
|
326
|
+
'id: c2\nevent: result\ndata: {"item_index":1,"status":"succeeded","credits_used":1}\n\n'
|
|
327
|
+
'event: complete\ndata: {}\n\n',
|
|
328
|
+
])
|
|
329
|
+
|
|
330
|
+
got = list(client.stream("b-1"))
|
|
331
|
+
|
|
332
|
+
self.assertEqual([e.item_index for e in got], [0, 1])
|
|
333
|
+
self.assertEqual(len(server.seen), 2, "the dropped connection was not re-opened")
|
|
334
|
+
self.assertEqual(server.seen[1]["last-event-id"], "c1", "resumed from the wrong position")
|
|
335
|
+
|
|
336
|
+
def test_an_error_event_names_the_cursor(self):
|
|
337
|
+
client, _ = self.serve([
|
|
338
|
+
'id: c9\nevent: error\ndata: {"type":"internal_error","cursor":"c9"}\n\n'
|
|
339
|
+
])
|
|
340
|
+
with self.assertRaises(EasyDataError) as ctx:
|
|
341
|
+
list(client.stream("b-1"))
|
|
342
|
+
self.assertIn("c9", str(ctx.exception))
|
|
343
|
+
|
|
344
|
+
|
|
268
345
|
class TestWebhookVerification(unittest.TestCase):
|
|
269
346
|
SECRET = "whsec_test"
|
|
270
347
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|