easydata-api 1.0.2__tar.gz → 1.2.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.2
3
+ Version: 1.2.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`
@@ -176,7 +193,8 @@ ed.usage(from_="2026-09-01", to="2026-09-30") # spend by day and operation
176
193
 
177
194
  Operations are attributes: `profiles_enrich`, `profiles_activity`,
178
195
  `profiles_posts`, `profiles_comments`, `profiles_reactions`, `companies_enrich`,
179
- `posts_enrich`, `sales_search_people`, `sales_search_companies`.
196
+ `posts_enrich`, `sales_search_people`, `sales_search_employees`,
197
+ `sales_search_companies`.
180
198
 
181
199
  ## Reference
182
200
 
@@ -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`
@@ -155,7 +172,8 @@ ed.usage(from_="2026-09-01", to="2026-09-30") # spend by day and operation
155
172
 
156
173
  Operations are attributes: `profiles_enrich`, `profiles_activity`,
157
174
  `profiles_posts`, `profiles_comments`, `profiles_reactions`, `companies_enrich`,
158
- `posts_enrich`, `sales_search_people`, `sales_search_companies`.
175
+ `posts_enrich`, `sales_search_people`, `sales_search_employees`,
176
+ `sales_search_companies`.
159
177
 
160
178
  ## Reference
161
179
 
@@ -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.2"
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",
@@ -35,7 +35,7 @@ __all__ = [
35
35
  DEFAULT_BASE_URL = "https://api.easydata.win/v1"
36
36
 
37
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
38
+ #: per entry rather than defining ten near-identical methods, because they ARE
39
39
  #: identical: one request shape, one response shape, and the operation is the
40
40
  #: path. A new operation is a row here.
41
41
  OPERATIONS: dict[str, str] = {
@@ -47,6 +47,7 @@ OPERATIONS: dict[str, str] = {
47
47
  "companies_enrich": "/companies/enrich",
48
48
  "posts_enrich": "/posts/enrich",
49
49
  "sales_search_people": "/sales/search/people",
50
+ "sales_search_employees": "/sales/search/employees",
50
51
  "sales_search_companies": "/sales/search/companies",
51
52
  }
52
53
 
@@ -476,6 +477,94 @@ class EasyData:
476
477
  # rate limiter is counting.
477
478
  time.sleep((page.recommended_poll_ms or 2000) / 1000.0)
478
479
 
480
+ def stream(
481
+ self,
482
+ batch_id: str,
483
+ *,
484
+ cursor: str = "",
485
+ max_reconnects: int = 10,
486
+ ) -> Iterator[ResultEntry]:
487
+ """Stream a batch's entries over a held connection instead of polling.
488
+
489
+ Same cursor, same entries, same order as `results()` - the difference
490
+ is that the server pushes rather than the client asking, so a long
491
+ batch costs one request instead of hundreds against your rate limit.
492
+
493
+ Use webhooks instead if you run a server with a public URL. This is for
494
+ a client with nowhere to deliver to: an agent on a laptop, a CLI, an
495
+ edge function.
496
+
497
+ Reconnects are handled here and are exact: the event id IS the cursor,
498
+ so a dropped connection resumes where it stopped with no duplicates and
499
+ no gaps. The iterator ends when the batch reaches a terminal state.
500
+
501
+ for entry in ed.stream(batch.batch_id):
502
+ if entry.ok:
503
+ save(entry.data)
504
+ """
505
+ reconnects = 0
506
+
507
+ while True:
508
+ url = f"{self.base_url}/batches/{batch_id}/results"
509
+ if cursor:
510
+ url += "?" + urllib.parse.urlencode({"cursor": cursor})
511
+
512
+ headers = {
513
+ "X-API-Key": self.api_key,
514
+ "Accept": "text/event-stream",
515
+ "User-Agent": self.user_agent,
516
+ }
517
+ if cursor:
518
+ # Where to resume. The server prefers this over ?cursor= for
519
+ # the same reason a browser sends it: it is the more recent of
520
+ # the two.
521
+ headers["Last-Event-ID"] = cursor
522
+
523
+ req = urllib.request.Request(url, headers=headers, method="GET")
524
+ complete = False
525
+
526
+ try:
527
+ # No read timeout: a stream that stays open is the point. The
528
+ # server ends it with `timeout` after its own limit.
529
+ with urllib.request.urlopen(req) as resp:
530
+ for event, data, event_id in _parse_sse(resp):
531
+ if event_id:
532
+ cursor = event_id
533
+
534
+ if event == "result":
535
+ yield ResultEntry.from_json(json.loads(data))
536
+ elif event == "complete":
537
+ complete = True
538
+ break
539
+ elif event == "error":
540
+ raise EasyDataError(
541
+ f"stream failed: {data}. Resume from cursor {cursor}."
542
+ )
543
+ # `timeout` is the server ending a long stream on
544
+ # purpose. Falling through re-opens from the cursor,
545
+ # which is what it asked for - a caller never sees it.
546
+ except urllib.error.HTTPError as exc:
547
+ raise error_for(exc.code, _decode(exc.read())) from exc
548
+ except (urllib.error.URLError, OSError) as exc:
549
+ # A dropped connection is the case reconnecting exists for.
550
+ if reconnects >= max_reconnects:
551
+ raise TransportError(
552
+ f"stream dropped and could not be resumed from {cursor}: {exc}"
553
+ ) from exc
554
+ reconnects += 1
555
+ time.sleep(self._backoff(reconnects, None))
556
+ continue
557
+
558
+ if complete:
559
+ return
560
+
561
+ # Ended without a `complete`. Re-open from the cursor.
562
+ reconnects += 1
563
+ if reconnects > max_reconnects:
564
+ raise TransportError(
565
+ f"stream dropped {reconnects} times without completing; last cursor {cursor}"
566
+ )
567
+
479
568
  def wait(self, batch_id: str, *, timeout: float | None = None) -> Batch:
480
569
  """Block until a batch reaches a terminal state, and return it.
481
570
 
@@ -609,6 +698,45 @@ class EasyData:
609
698
  return webhooks.verify(secret, headers, body, **kw)
610
699
 
611
700
 
701
+ def _parse_sse(stream: Any) -> Iterator[tuple[str, str, str]]:
702
+ """The wire format of Server-Sent Events, as (event, data, id) triples.
703
+
704
+ Hand-rolled rather than a dependency, because this client has none and the
705
+ format is three rules: events are separated by a blank line, fields are
706
+ `name: value`, and a line starting with `:` is a comment.
707
+
708
+ Fields accumulate until a blank line, so a `data:` split across two reads is
709
+ reassembled rather than parsed as half an entry.
710
+ """
711
+ event, data, event_id = "message", [], ""
712
+
713
+ for raw in stream:
714
+ line = raw.decode("utf-8", "replace").rstrip("\n").rstrip("\r")
715
+
716
+ if not line:
717
+ if data or event != "message":
718
+ yield event, "\n".join(data), event_id
719
+ event, data, event_id = "message", [], ""
720
+ continue
721
+
722
+ if line.startswith(":"):
723
+ continue # keepalive comment
724
+
725
+ field, _, value = line.partition(":")
726
+ # One optional space after the colon is part of the framing.
727
+ value = value[1:] if value.startswith(" ") else value
728
+
729
+ if field == "event":
730
+ event = value
731
+ elif field == "data":
732
+ data.append(value)
733
+ elif field == "id":
734
+ event_id = value
735
+
736
+ if data or event != "message":
737
+ yield event, "\n".join(data), event_id
738
+
739
+
612
740
  def _put(d: dict[str, Any], **kwargs: Any) -> None:
613
741
  """Sets the keyword arguments that were actually given.
614
742
 
@@ -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.2
3
+ Version: 1.2.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`
@@ -176,7 +193,8 @@ ed.usage(from_="2026-09-01", to="2026-09-30") # spend by day and operation
176
193
 
177
194
  Operations are attributes: `profiles_enrich`, `profiles_activity`,
178
195
  `profiles_posts`, `profiles_comments`, `profiles_reactions`, `companies_enrich`,
179
- `posts_enrich`, `sales_search_people`, `sales_search_companies`.
196
+ `posts_enrich`, `sales_search_people`, `sales_search_employees`,
197
+ `sales_search_companies`.
180
198
 
181
199
  ## Reference
182
200
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "easydata-api"
7
- version = "1.0.2"
7
+ version = "1.2.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
 
File without changes
File without changes