quorum-api 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quorum_api-0.1.0/PKG-INFO +48 -0
- quorum_api-0.1.0/README.md +34 -0
- quorum_api-0.1.0/pyproject.toml +24 -0
- quorum_api-0.1.0/quorum_api/__init__.py +30 -0
- quorum_api-0.1.0/quorum_api/client.py +314 -0
- quorum_api-0.1.0/quorum_api.egg-info/PKG-INFO +48 -0
- quorum_api-0.1.0/quorum_api.egg-info/SOURCES.txt +9 -0
- quorum_api-0.1.0/quorum_api.egg-info/dependency_links.txt +1 -0
- quorum_api-0.1.0/quorum_api.egg-info/top_level.txt +1 -0
- quorum_api-0.1.0/setup.cfg +4 -0
- quorum_api-0.1.0/tests/test_client.py +179 -0
|
@@ -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,34 @@
|
|
|
1
|
+
# quorum-api (Python)
|
|
2
|
+
|
|
3
|
+
The typed Python client for the Quorum market evidence API. Written against
|
|
4
|
+
`spec/openapi.yaml`, which is the contract. Standard library only: no
|
|
5
|
+
dependencies, and the transport is injectable so the tests run with no
|
|
6
|
+
network at all.
|
|
7
|
+
|
|
8
|
+
Errors are values, never raised. Retry-After is honoured, and
|
|
9
|
+
`wait_for_report` implements the polling loop correctly once so every caller
|
|
10
|
+
does not implement it wrongly.
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
from quorum_api import QuorumClient
|
|
14
|
+
|
|
15
|
+
client = QuorumClient("https://quorum-api-j15n.onrender.com", api_key="qk_...")
|
|
16
|
+
|
|
17
|
+
accepted = client.create_report("running shoes", terms=["sizing"])
|
|
18
|
+
if accepted.ok:
|
|
19
|
+
report = client.wait_for_report(accepted.data["id"])
|
|
20
|
+
if report.ok:
|
|
21
|
+
for finding in report.data["findings"]:
|
|
22
|
+
print(finding["term"], finding["records"], "records")
|
|
23
|
+
|
|
24
|
+
# Every receipt id resolves back to the real record behind it.
|
|
25
|
+
record = client.get_evidence("rc_4d6d444821b0044f")
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Not yet published to PyPI. Install from the repo:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install packages/sdk-py
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Tests: `python3 -m unittest discover -s packages/sdk-py/tests`
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "quorum-api"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Typed Python client for the Quorum market evidence API. Errors are values, Retry-After is honoured, zero dependencies."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
|
+
authors = [{ name = "Quorum" }]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: Apache Software License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://github.com/Godzilla-lab/Quorum-API"
|
|
22
|
+
|
|
23
|
+
[tool.setuptools]
|
|
24
|
+
packages = ["quorum_api"]
|
|
@@ -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"
|
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
quorum_api
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""The Python client, tested with no network at all.
|
|
2
|
+
|
|
3
|
+
Every test injects a transport, so the suite runs inside the same egress
|
|
4
|
+
blocked CI namespace as everything else. The behaviours under test are the
|
|
5
|
+
two decisions the client is built on: errors are values, and the server's
|
|
6
|
+
Retry-After sets the pace.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import io
|
|
10
|
+
import json
|
|
11
|
+
import unittest
|
|
12
|
+
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
17
|
+
|
|
18
|
+
from quorum_api import ApiError, QuorumClient, Result # noqa: E402
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def transport_returning(status, body, headers=None):
|
|
22
|
+
calls = []
|
|
23
|
+
|
|
24
|
+
def transport(method, url, request_headers, payload, timeout):
|
|
25
|
+
calls.append({
|
|
26
|
+
"method": method, "url": url, "headers": request_headers,
|
|
27
|
+
"body": payload, "timeout": timeout,
|
|
28
|
+
})
|
|
29
|
+
raw = body if isinstance(body, bytes) else json.dumps(body).encode("utf-8")
|
|
30
|
+
return status, headers or {}, raw
|
|
31
|
+
|
|
32
|
+
transport.calls = calls
|
|
33
|
+
return transport
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CallShape(unittest.TestCase):
|
|
37
|
+
def test_paths_headers_and_encoding(self):
|
|
38
|
+
transport = transport_returning(200, {"ok": True})
|
|
39
|
+
client = QuorumClient("https://api.example/", api_key="qk_test\n",
|
|
40
|
+
transport=transport)
|
|
41
|
+
client.get_evidence("rc_abc/def")
|
|
42
|
+
call = transport.calls[0]
|
|
43
|
+
self.assertEqual(call["url"], "https://api.example/v1/evidence/rc_abc%2Fdef")
|
|
44
|
+
self.assertEqual(call["headers"]["authorization"], "Bearer qk_test",
|
|
45
|
+
"a pasted newline never reaches the header")
|
|
46
|
+
self.assertNotIn("content-type", call["headers"], "no body, no content type")
|
|
47
|
+
|
|
48
|
+
def test_create_report_sends_only_what_was_given(self):
|
|
49
|
+
transport = transport_returning(202, {"id": "rep_1", "status": "queued"})
|
|
50
|
+
client = QuorumClient("https://api.example", transport=transport)
|
|
51
|
+
result = client.create_report("running shoes", terms=["sizing"], offline=True)
|
|
52
|
+
self.assertTrue(result.ok)
|
|
53
|
+
sent = json.loads(transport.calls[0]["body"].decode("utf-8"))
|
|
54
|
+
self.assertEqual(sent, {"subject": "running shoes", "terms": ["sizing"],
|
|
55
|
+
"offline": True},
|
|
56
|
+
"absent options are absent, not null")
|
|
57
|
+
|
|
58
|
+
def test_keyless_sends_no_authorization(self):
|
|
59
|
+
transport = transport_returning(200, {"ok": True})
|
|
60
|
+
QuorumClient("https://api.example", transport=transport).healthz()
|
|
61
|
+
self.assertNotIn("authorization", transport.calls[0]["headers"])
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ErrorsAreValues(unittest.TestCase):
|
|
65
|
+
def test_server_error_body_travels_back(self):
|
|
66
|
+
transport = transport_returning(429, {"error": {
|
|
67
|
+
"type": "rate_limited", "message": "too many",
|
|
68
|
+
"requestId": "req-1", "retryAfterSeconds": 30,
|
|
69
|
+
}})
|
|
70
|
+
result = QuorumClient("https://api.example", transport=transport).get_usage()
|
|
71
|
+
self.assertFalse(result.ok)
|
|
72
|
+
self.assertEqual(result.error.type, "rate_limited")
|
|
73
|
+
self.assertEqual(result.error.status, 429)
|
|
74
|
+
self.assertEqual(result.error.request_id, "req-1")
|
|
75
|
+
self.assertEqual(result.error.retry_after_seconds, 30)
|
|
76
|
+
|
|
77
|
+
def test_retry_after_header_wins_over_the_body(self):
|
|
78
|
+
transport = transport_returning(
|
|
79
|
+
503, {"error": {"type": "overloaded", "message": "shed",
|
|
80
|
+
"retryAfterSeconds": 99}},
|
|
81
|
+
headers={"retry-after": "7"})
|
|
82
|
+
result = QuorumClient("https://api.example", transport=transport).healthz()
|
|
83
|
+
self.assertEqual(result.error.retry_after_seconds, 7,
|
|
84
|
+
"a proxy may add a header the app did not")
|
|
85
|
+
|
|
86
|
+
def test_html_from_a_proxy_is_named_not_raised(self):
|
|
87
|
+
transport = transport_returning(502, b"<html>bad gateway</html>")
|
|
88
|
+
result = QuorumClient("https://api.example", transport=transport).healthz()
|
|
89
|
+
self.assertFalse(result.ok)
|
|
90
|
+
self.assertEqual(result.error.type, "bad_response")
|
|
91
|
+
self.assertEqual(result.error.status, 502)
|
|
92
|
+
|
|
93
|
+
def test_no_answer_at_all_is_status_zero(self):
|
|
94
|
+
def transport(method, url, headers, body, timeout):
|
|
95
|
+
raise OSError("connection refused")
|
|
96
|
+
|
|
97
|
+
result = QuorumClient("https://api.example", transport=transport).healthz()
|
|
98
|
+
self.assertFalse(result.ok)
|
|
99
|
+
self.assertEqual(result.error.status, 0,
|
|
100
|
+
"no server is a different problem from any status code")
|
|
101
|
+
self.assertEqual(result.error.type, "network")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class WaitForReport(unittest.TestCase):
|
|
105
|
+
def test_polls_until_terminal_and_honours_retry_after(self):
|
|
106
|
+
answers = [
|
|
107
|
+
(503, {"error": {"type": "overloaded", "message": "shed"}}, {"retry-after": "5"}),
|
|
108
|
+
(200, {"id": "rep_1", "status": "running"}, {}),
|
|
109
|
+
(200, {"id": "rep_1", "status": "complete"}, {}),
|
|
110
|
+
]
|
|
111
|
+
slept = []
|
|
112
|
+
|
|
113
|
+
def transport(method, url, headers, body, timeout):
|
|
114
|
+
status, payload, response_headers = answers.pop(0)
|
|
115
|
+
return status, response_headers, json.dumps(payload).encode("utf-8")
|
|
116
|
+
|
|
117
|
+
client = QuorumClient("https://api.example", transport=transport)
|
|
118
|
+
result = client.wait_for_report("rep_1", poll_seconds=1.0,
|
|
119
|
+
sleep=slept.append,
|
|
120
|
+
clock=lambda: 0.0)
|
|
121
|
+
self.assertTrue(result.ok)
|
|
122
|
+
self.assertEqual(result.data["status"], "complete")
|
|
123
|
+
self.assertEqual(slept[0], 5.0, "the 503's Retry-After set the first wait")
|
|
124
|
+
self.assertEqual(slept[1], 1.0, "then the floor took over")
|
|
125
|
+
|
|
126
|
+
def test_gives_up_honestly_with_the_last_status_seen(self):
|
|
127
|
+
transport = transport_returning(200, {"id": "rep_1", "status": "running"})
|
|
128
|
+
ticks = iter([0.0, 0.0, 1000.0])
|
|
129
|
+
|
|
130
|
+
client = QuorumClient("https://api.example", transport=transport)
|
|
131
|
+
result = client.wait_for_report("rep_1", timeout_seconds=10.0,
|
|
132
|
+
sleep=lambda seconds: None,
|
|
133
|
+
clock=lambda: next(ticks))
|
|
134
|
+
self.assertFalse(result.ok)
|
|
135
|
+
self.assertEqual(result.error.type, "timeout")
|
|
136
|
+
self.assertIn("still running", result.error.message)
|
|
137
|
+
|
|
138
|
+
def test_a_real_error_returns_immediately(self):
|
|
139
|
+
transport = transport_returning(404, {"error": {
|
|
140
|
+
"type": "not_found", "message": "no report carries this id"}})
|
|
141
|
+
result = QuorumClient("https://api.example", transport=transport) \
|
|
142
|
+
.wait_for_report("rep_x", sleep=lambda seconds: None)
|
|
143
|
+
self.assertFalse(result.ok)
|
|
144
|
+
self.assertEqual(result.error.type, "not_found")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class StreamReport(unittest.TestCase):
|
|
148
|
+
def test_frames_survive_chunk_boundaries(self):
|
|
149
|
+
raw = (b"id: 1\nevent: phase\ndata: {\"name\": \"retrieve\"}\n\n"
|
|
150
|
+
b"id: 2\ndata: plain text\n\n")
|
|
151
|
+
|
|
152
|
+
class Response(io.BytesIO):
|
|
153
|
+
pass
|
|
154
|
+
|
|
155
|
+
client = QuorumClient("https://api.example", api_key="k")
|
|
156
|
+
events = list(client.stream_report(
|
|
157
|
+
"rep_1", opener=lambda request, timeout: Response(raw)))
|
|
158
|
+
self.assertEqual(events[0], {"id": 1, "type": "phase",
|
|
159
|
+
"data": {"name": "retrieve"}})
|
|
160
|
+
self.assertEqual(events[1], {"id": 2, "type": "message",
|
|
161
|
+
"data": "plain text"})
|
|
162
|
+
|
|
163
|
+
def test_a_stream_that_never_opens_is_empty_not_an_exception(self):
|
|
164
|
+
def opener(request, timeout):
|
|
165
|
+
raise OSError("refused")
|
|
166
|
+
|
|
167
|
+
client = QuorumClient("https://api.example")
|
|
168
|
+
self.assertEqual(list(client.stream_report("rep_1", opener=opener)), [])
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class Shapes(unittest.TestCase):
|
|
172
|
+
def test_result_and_error_are_plain_data(self):
|
|
173
|
+
error = ApiError(type="x", message="y")
|
|
174
|
+
self.assertEqual(error.status, 0)
|
|
175
|
+
self.assertIsNone(Result(ok=True, data=1).error)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
if __name__ == "__main__":
|
|
179
|
+
unittest.main()
|