quorum-api 0.1.0__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.
quorum_api/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""quorum-api. The typed Python client for the hosted API.
|
|
2
|
+
|
|
3
|
+
WRITTEN AGAINST spec/openapi.yaml, WHICH IS THE CONTRACT. Every method here is
|
|
4
|
+
one operationId from that file, and the spec wins when they disagree.
|
|
5
|
+
|
|
6
|
+
THE SAME TWO DECISIONS THE JAVASCRIPT SDK MADE, for the same reasons.
|
|
7
|
+
|
|
8
|
+
ERRORS ARE VALUES, NEVER RAISED. The house rule everywhere a vendor can be
|
|
9
|
+
down, and an SDK IS the place a vendor can be down. A caller gets a Result
|
|
10
|
+
whose error carries the server's type, message and requestId rather than an
|
|
11
|
+
exception, because the interesting failures here are a 429, a 503 with a
|
|
12
|
+
Retry-After, and a report that is simply not finished, and none of those are
|
|
13
|
+
exceptional.
|
|
14
|
+
|
|
15
|
+
IT HONOURS RETRY-AFTER. The server sheds under load with a 503 and rate
|
|
16
|
+
limits with a 429, both carrying the wait. wait_for_report implements the
|
|
17
|
+
polling loop correctly once, so every caller does not implement it wrongly.
|
|
18
|
+
|
|
19
|
+
ZERO DEPENDENCIES. The standard library's urllib is the transport, and the
|
|
20
|
+
transport is injectable so the tests run with no network at all.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from .client import (
|
|
24
|
+
ApiError,
|
|
25
|
+
QuorumClient,
|
|
26
|
+
Result,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = ["ApiError", "QuorumClient", "Result"]
|
|
30
|
+
__version__ = "0.1.0"
|
quorum_api/client.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""The client. See the package docstring for the two decisions that shape it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.request
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple
|
|
11
|
+
from urllib.parse import quote
|
|
12
|
+
|
|
13
|
+
# What a transport returns: status, lowercased headers, body bytes.
|
|
14
|
+
TransportResponse = Tuple[int, Dict[str, str], bytes]
|
|
15
|
+
# (method, url, headers, body bytes or None, timeout seconds) -> response.
|
|
16
|
+
Transport = Callable[[str, str, Dict[str, str], Optional[bytes], float], TransportResponse]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ApiError:
|
|
21
|
+
"""The server's machine readable failure, or the absence of a server.
|
|
22
|
+
|
|
23
|
+
type is the server's class: rate_limited, not_found, queue_saturated,
|
|
24
|
+
unauthorized, bad_request, conflict; or this client's own two, network
|
|
25
|
+
and timeout, when the request never got an answer. status is the HTTP
|
|
26
|
+
status, or 0 when nothing answered at all, which is a different problem
|
|
27
|
+
from any status code and a caller should be able to tell them apart.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
type: str
|
|
31
|
+
message: str
|
|
32
|
+
request_id: Optional[str] = None
|
|
33
|
+
# Present on 429 and 503. Seconds. Honour it.
|
|
34
|
+
retry_after_seconds: Optional[float] = None
|
|
35
|
+
status: int = 0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Result:
|
|
40
|
+
"""ok with data, or not ok with error. Never both, never neither."""
|
|
41
|
+
|
|
42
|
+
ok: bool
|
|
43
|
+
data: Any = None
|
|
44
|
+
error: Optional[ApiError] = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _failure(status: int, kind: str, message: str,
|
|
48
|
+
request_id: Optional[str] = None,
|
|
49
|
+
retry_after: Optional[float] = None) -> Result:
|
|
50
|
+
return Result(ok=False, error=ApiError(
|
|
51
|
+
type=kind, message=message, request_id=request_id,
|
|
52
|
+
retry_after_seconds=retry_after, status=status,
|
|
53
|
+
))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _default_transport(method: str, url: str, headers: Dict[str, str],
|
|
57
|
+
body: Optional[bytes], timeout: float) -> TransportResponse:
|
|
58
|
+
request = urllib.request.Request(url, data=body, method=method)
|
|
59
|
+
for name, value in headers.items():
|
|
60
|
+
request.add_header(name, value)
|
|
61
|
+
try:
|
|
62
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
63
|
+
return (response.status,
|
|
64
|
+
{k.lower(): v for k, v in response.headers.items()},
|
|
65
|
+
response.read())
|
|
66
|
+
except urllib.error.HTTPError as err:
|
|
67
|
+
# urllib raises on any non-2xx. The status and body are still the
|
|
68
|
+
# server speaking, so they travel back as a response, not an error.
|
|
69
|
+
return (err.code,
|
|
70
|
+
{k.lower(): v for k, v in (err.headers or {}).items()},
|
|
71
|
+
err.read())
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class QuorumClient:
|
|
75
|
+
"""One instance per base URL and key.
|
|
76
|
+
|
|
77
|
+
The base URL is supplied by the caller and never hardcoded. That is what
|
|
78
|
+
makes this a client for YOUR instance rather than a vendor client: the
|
|
79
|
+
address is configuration, hosted or self hosted alike.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(self, base_url: str, api_key: Optional[str] = None,
|
|
83
|
+
timeout_seconds: float = 30.0,
|
|
84
|
+
transport: Optional[Transport] = None) -> None:
|
|
85
|
+
self._base = base_url.rstrip("/") + "/v1"
|
|
86
|
+
# Trimmed for the reason learned three times on 2026-08-24: a pasted
|
|
87
|
+
# key carries a newline, and an untrimmed one dies at the header.
|
|
88
|
+
self._api_key = api_key.strip() if api_key else None
|
|
89
|
+
self._timeout = timeout_seconds
|
|
90
|
+
self._transport = transport or _default_transport
|
|
91
|
+
|
|
92
|
+
def _headers(self, has_body: bool) -> Dict[str, str]:
|
|
93
|
+
headers = {"accept": "application/json"}
|
|
94
|
+
if has_body:
|
|
95
|
+
headers["content-type"] = "application/json"
|
|
96
|
+
if self._api_key:
|
|
97
|
+
headers["authorization"] = "Bearer " + self._api_key
|
|
98
|
+
return headers
|
|
99
|
+
|
|
100
|
+
def _call(self, method: str, path: str, body: Any = None) -> Result:
|
|
101
|
+
payload = None if body is None else json.dumps(body).encode("utf-8")
|
|
102
|
+
try:
|
|
103
|
+
status, headers, raw = self._transport(
|
|
104
|
+
method, self._base + path, self._headers(payload is not None),
|
|
105
|
+
payload, self._timeout)
|
|
106
|
+
except Exception as cause: # noqa: BLE001 - errors are values here.
|
|
107
|
+
kind = "timeout" if "timed out" in str(cause).lower() else "network"
|
|
108
|
+
return _failure(0, kind, str(cause))
|
|
109
|
+
|
|
110
|
+
text = raw.decode("utf-8", errors="replace") if raw else ""
|
|
111
|
+
try:
|
|
112
|
+
parsed = json.loads(text) if text else None
|
|
113
|
+
except ValueError:
|
|
114
|
+
# A proxy returning HTML is the common case here, and a raw json
|
|
115
|
+
# parse error would send somebody debugging this client.
|
|
116
|
+
return _failure(status, "bad_response",
|
|
117
|
+
"the server returned %d with a body that is not json" % status)
|
|
118
|
+
|
|
119
|
+
if 200 <= status < 300:
|
|
120
|
+
return Result(ok=True, data=parsed)
|
|
121
|
+
|
|
122
|
+
err = (parsed or {}).get("error") if isinstance(parsed, dict) else None
|
|
123
|
+
err = err if isinstance(err, dict) else {}
|
|
124
|
+
# The header wins over the body: a proxy may add one the app did not.
|
|
125
|
+
header = headers.get("retry-after")
|
|
126
|
+
retry = None
|
|
127
|
+
if header is not None:
|
|
128
|
+
try:
|
|
129
|
+
retry = float(header)
|
|
130
|
+
except ValueError:
|
|
131
|
+
retry = None
|
|
132
|
+
if retry is None:
|
|
133
|
+
retry = err.get("retryAfterSeconds")
|
|
134
|
+
return _failure(status,
|
|
135
|
+
err.get("type") or "http_error",
|
|
136
|
+
err.get("message") or ("the server returned %d" % status),
|
|
137
|
+
err.get("requestId"),
|
|
138
|
+
retry)
|
|
139
|
+
|
|
140
|
+
# --- reports, the slow path ---
|
|
141
|
+
|
|
142
|
+
def create_report(self, subject: str, *, terms: Optional[List[str]] = None,
|
|
143
|
+
communities: Optional[List[str]] = None,
|
|
144
|
+
sources: Optional[List[str]] = None,
|
|
145
|
+
include_ads: Optional[bool] = None,
|
|
146
|
+
offline: Optional[bool] = None,
|
|
147
|
+
cap_usd: Optional[float] = None,
|
|
148
|
+
deadline_ms: Optional[int] = None,
|
|
149
|
+
webhook_url: Optional[str] = None) -> Result:
|
|
150
|
+
body: Dict[str, Any] = {"subject": subject}
|
|
151
|
+
for key, value in (("terms", terms), ("communities", communities),
|
|
152
|
+
("sources", sources), ("includeAds", include_ads),
|
|
153
|
+
("offline", offline), ("capUsd", cap_usd),
|
|
154
|
+
("deadlineMs", deadline_ms), ("webhookUrl", webhook_url)):
|
|
155
|
+
if value is not None:
|
|
156
|
+
body[key] = value
|
|
157
|
+
return self._call("POST", "/reports", body)
|
|
158
|
+
|
|
159
|
+
def get_report(self, report_id: str) -> Result:
|
|
160
|
+
return self._call("GET", "/reports/" + quote(report_id, safe=""))
|
|
161
|
+
|
|
162
|
+
def cancel_report(self, report_id: str) -> Result:
|
|
163
|
+
return self._call("DELETE", "/reports/" + quote(report_id, safe=""))
|
|
164
|
+
|
|
165
|
+
# --- evidence, the fast path ---
|
|
166
|
+
|
|
167
|
+
def get_evidence(self, receipt_id: str) -> Result:
|
|
168
|
+
return self._call("GET", "/evidence/" + quote(receipt_id, safe=""))
|
|
169
|
+
|
|
170
|
+
def get_evidence_batch(self, receipt_ids: List[str]) -> Result:
|
|
171
|
+
return self._call("POST", "/evidence/batch", {"receiptIds": receipt_ids})
|
|
172
|
+
|
|
173
|
+
def search_evidence(self, query: str, *, category: Optional[str] = None,
|
|
174
|
+
limit: Optional[int] = None) -> Result:
|
|
175
|
+
body: Dict[str, Any] = {"query": query}
|
|
176
|
+
if category is not None:
|
|
177
|
+
body["category"] = category
|
|
178
|
+
if limit is not None:
|
|
179
|
+
body["limit"] = limit
|
|
180
|
+
return self._call("POST", "/evidence/search", body)
|
|
181
|
+
|
|
182
|
+
def get_ad_evidence(self, ad_id: str) -> Result:
|
|
183
|
+
return self._call("GET", "/evidence/ads/" + quote(ad_id, safe=""))
|
|
184
|
+
|
|
185
|
+
def get_category(self, slug: str) -> Result:
|
|
186
|
+
return self._call("GET", "/categories/" + quote(slug, safe=""))
|
|
187
|
+
|
|
188
|
+
def list_categories(self) -> Result:
|
|
189
|
+
return self._call("GET", "/categories")
|
|
190
|
+
|
|
191
|
+
# --- verification and account ---
|
|
192
|
+
|
|
193
|
+
def verify_claims(self, claims: List[Dict[str, Any]]) -> Result:
|
|
194
|
+
"""Re-resolve every cited id against the corpus, ours or anybody's.
|
|
195
|
+
|
|
196
|
+
A claim citing an id that does not exist is reported rather than
|
|
197
|
+
quietly passed, which is the point of the whole product.
|
|
198
|
+
"""
|
|
199
|
+
return self._call("POST", "/verify", {"claims": claims})
|
|
200
|
+
|
|
201
|
+
def get_usage(self) -> Result:
|
|
202
|
+
return self._call("GET", "/usage")
|
|
203
|
+
|
|
204
|
+
def healthz(self) -> Result:
|
|
205
|
+
return self._call("GET", "/healthz")
|
|
206
|
+
|
|
207
|
+
# --- the loop every caller would otherwise write badly ---
|
|
208
|
+
|
|
209
|
+
def wait_for_report(self, report_id: str, *,
|
|
210
|
+
timeout_seconds: float = 900.0,
|
|
211
|
+
poll_seconds: float = 2.0,
|
|
212
|
+
on_poll: Optional[Callable[[Any], None]] = None,
|
|
213
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
214
|
+
clock: Callable[[], float] = time.monotonic) -> Result:
|
|
215
|
+
"""Poll a report to completion, honouring the server's own pacing.
|
|
216
|
+
|
|
217
|
+
Three things this gets right that a naive loop does not: a 503 is the
|
|
218
|
+
load shedder speaking, not a failure; the server sets the pace through
|
|
219
|
+
Retry-After rather than the client guessing; and on timeout it returns
|
|
220
|
+
the last status it saw rather than pretending the report failed.
|
|
221
|
+
"""
|
|
222
|
+
deadline = clock() + timeout_seconds
|
|
223
|
+
last_status: Optional[str] = None
|
|
224
|
+
|
|
225
|
+
while True:
|
|
226
|
+
result = self.get_report(report_id)
|
|
227
|
+
|
|
228
|
+
if result.ok:
|
|
229
|
+
data = result.data if isinstance(result.data, dict) else {}
|
|
230
|
+
last_status = data.get("status")
|
|
231
|
+
if on_poll is not None:
|
|
232
|
+
on_poll(result.data)
|
|
233
|
+
if last_status not in ("queued", "running"):
|
|
234
|
+
return result
|
|
235
|
+
suggested = None
|
|
236
|
+
elif result.error is not None and result.error.status in (429, 503):
|
|
237
|
+
suggested = result.error.retry_after_seconds
|
|
238
|
+
else:
|
|
239
|
+
# A real error. Anything else was the server asking us to wait.
|
|
240
|
+
return result
|
|
241
|
+
|
|
242
|
+
wait = max(poll_seconds, float(suggested or 0))
|
|
243
|
+
if clock() + wait > deadline:
|
|
244
|
+
message = ("gave up after the deadline with the report still %s" % last_status
|
|
245
|
+
if last_status else
|
|
246
|
+
"gave up after the deadline without reaching the server")
|
|
247
|
+
return _failure(0, "timeout", message)
|
|
248
|
+
sleep(wait)
|
|
249
|
+
|
|
250
|
+
def stream_report(self, report_id: str,
|
|
251
|
+
opener: Optional[Callable[[urllib.request.Request, float], Any]] = None,
|
|
252
|
+
) -> Iterator[Dict[str, Any]]:
|
|
253
|
+
"""Yield a running report's server sent events as dicts.
|
|
254
|
+
|
|
255
|
+
Parsed by hand for the same reason the JavaScript SDK parses by hand:
|
|
256
|
+
the frame is id, event and data lines terminated by a blank line, and
|
|
257
|
+
a chunk boundary can fall anywhere, so the buffer is drained on
|
|
258
|
+
complete frames only. A transport level failure ends the stream
|
|
259
|
+
rather than raising, because a dropped stream is a normal way for a
|
|
260
|
+
finished report to say goodbye.
|
|
261
|
+
"""
|
|
262
|
+
request = urllib.request.Request(
|
|
263
|
+
self._base + "/reports/" + quote(report_id, safe="") + "/stream")
|
|
264
|
+
for name, value in self._headers(False).items():
|
|
265
|
+
request.add_header(name, "text/event-stream" if name == "accept" else value)
|
|
266
|
+
|
|
267
|
+
open_stream = opener or (lambda req, timeout: urllib.request.urlopen(req, timeout=timeout))
|
|
268
|
+
try:
|
|
269
|
+
response = open_stream(request, self._timeout)
|
|
270
|
+
except Exception: # noqa: BLE001 - a stream that never opened is empty.
|
|
271
|
+
return
|
|
272
|
+
|
|
273
|
+
buffer = ""
|
|
274
|
+
try:
|
|
275
|
+
while True:
|
|
276
|
+
chunk = response.read(1024)
|
|
277
|
+
if not chunk:
|
|
278
|
+
break
|
|
279
|
+
buffer += chunk.decode("utf-8", errors="replace")
|
|
280
|
+
while "\n\n" in buffer:
|
|
281
|
+
frame, buffer = buffer.split("\n\n", 1)
|
|
282
|
+
event = _parse_frame(frame)
|
|
283
|
+
if event is not None:
|
|
284
|
+
yield event
|
|
285
|
+
except Exception: # noqa: BLE001
|
|
286
|
+
return
|
|
287
|
+
finally:
|
|
288
|
+
close = getattr(response, "close", None)
|
|
289
|
+
if close is not None:
|
|
290
|
+
close()
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _parse_frame(frame: str) -> Optional[Dict[str, Any]]:
|
|
294
|
+
event_id = 0
|
|
295
|
+
kind = "message"
|
|
296
|
+
data: List[str] = []
|
|
297
|
+
for line in frame.split("\n"):
|
|
298
|
+
if line.startswith("id:"):
|
|
299
|
+
try:
|
|
300
|
+
event_id = int(line[3:].strip())
|
|
301
|
+
except ValueError:
|
|
302
|
+
event_id = 0
|
|
303
|
+
elif line.startswith("event:"):
|
|
304
|
+
kind = line[6:].strip()
|
|
305
|
+
elif line.startswith("data:"):
|
|
306
|
+
# Multiple data lines in one frame concatenate, per the SSE spec.
|
|
307
|
+
data.append(line[5:].strip())
|
|
308
|
+
if not data:
|
|
309
|
+
return None
|
|
310
|
+
joined = "\n".join(data)
|
|
311
|
+
try:
|
|
312
|
+
return {"id": event_id, "type": kind, "data": json.loads(joined)}
|
|
313
|
+
except ValueError:
|
|
314
|
+
return {"id": event_id, "type": kind, "data": joined}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: quorum-api
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Typed Python client for the Quorum market evidence API. Errors are values, Retry-After is honoured, zero dependencies.
|
|
5
|
+
Author: Quorum
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/Godzilla-lab/Quorum-API
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# quorum-api (Python)
|
|
16
|
+
|
|
17
|
+
The typed Python client for the Quorum market evidence API. Written against
|
|
18
|
+
`spec/openapi.yaml`, which is the contract. Standard library only: no
|
|
19
|
+
dependencies, and the transport is injectable so the tests run with no
|
|
20
|
+
network at all.
|
|
21
|
+
|
|
22
|
+
Errors are values, never raised. Retry-After is honoured, and
|
|
23
|
+
`wait_for_report` implements the polling loop correctly once so every caller
|
|
24
|
+
does not implement it wrongly.
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from quorum_api import QuorumClient
|
|
28
|
+
|
|
29
|
+
client = QuorumClient("https://quorum-api-j15n.onrender.com", api_key="qk_...")
|
|
30
|
+
|
|
31
|
+
accepted = client.create_report("running shoes", terms=["sizing"])
|
|
32
|
+
if accepted.ok:
|
|
33
|
+
report = client.wait_for_report(accepted.data["id"])
|
|
34
|
+
if report.ok:
|
|
35
|
+
for finding in report.data["findings"]:
|
|
36
|
+
print(finding["term"], finding["records"], "records")
|
|
37
|
+
|
|
38
|
+
# Every receipt id resolves back to the real record behind it.
|
|
39
|
+
record = client.get_evidence("rc_4d6d444821b0044f")
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Not yet published to PyPI. Install from the repo:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install packages/sdk-py
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Tests: `python3 -m unittest discover -s packages/sdk-py/tests`
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
quorum_api/__init__.py,sha256=yBVYsGudGylXZhXFGAUQ0GUIXZdvVcY8om6FuLjTJM4,1179
|
|
2
|
+
quorum_api/client.py,sha256=A-sBP_naVd4mhm9sfUWTzz6tCnk6nTSdYqvBI6pTn50,13159
|
|
3
|
+
quorum_api-0.1.0.dist-info/METADATA,sha256=Ioj4-iyyuUG_-rGSxIncl4TJ7Mq6_xGQCdrpuIjKXgU,1650
|
|
4
|
+
quorum_api-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
5
|
+
quorum_api-0.1.0.dist-info/top_level.txt,sha256=P2CWC5CssCUAZYBRwFR11WSv8uuKeeqLZtxDBHEpZFo,11
|
|
6
|
+
quorum_api-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
quorum_api
|