easydata-api 1.0.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: easydata-api
3
- Version: 1.0.1
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.1"
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.1
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`
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "easydata-api"
7
- version = "1.0.1"
7
+ version = "1.1.0"
8
8
  description = "Official Python client for the EasyData LinkedIn enrichment API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -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
 
@@ -310,7 +387,7 @@ class TestWebhookVerification(unittest.TestCase):
310
387
  self.assertEqual(d.data["result"]["status"], "succeeded")
311
388
 
312
389
  def test_the_go_signers_own_output_verifies(self):
313
- """A vector produced by backend/internal/webhooks.Sign itself.
390
+ """A vector produced by the server's own webhook signer.
314
391
 
315
392
  Every other test here signs with this file's own HMAC, which would keep
316
393
  passing if both sides were wrong in the same way. This one is the output
@@ -318,8 +395,8 @@ class TestWebhookVerification(unittest.TestCase):
318
395
  change to either implementation that breaks the other fails here rather
319
396
  than in a customer's receiver.
320
397
 
321
- Regenerate by calling Sign("whsec_cross_check", time.Unix(1789000000, 0),
322
- body) in a Go test.
398
+ Regenerate from the server's signing function with the secret
399
+ "whsec_cross_check" and the unix timestamp 1789000000.
323
400
  """
324
401
  body = (
325
402
  b'{"id":"d-1","event":"batch.result","createdAt":"2026-09-01T10:00:00Z",'
File without changes
File without changes