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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ quorum_api