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.
- easydata_api/__init__.py +79 -0
- easydata_api/client.py +642 -0
- easydata_api/errors.py +199 -0
- easydata_api/webhooks.py +234 -0
- easydata_api-1.0.1.dist-info/METADATA +185 -0
- easydata_api-1.0.1.dist-info/RECORD +9 -0
- easydata_api-1.0.1.dist-info/WHEEL +5 -0
- easydata_api-1.0.1.dist-info/licenses/LICENSE +21 -0
- easydata_api-1.0.1.dist-info/top_level.txt +1 -0
easydata_api/__init__.py
ADDED
|
@@ -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
|
+
)
|
easydata_api/webhooks.py
ADDED
|
@@ -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,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
|