truegrain 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.
- truegrain/__init__.py +63 -0
- truegrain/client.py +445 -0
- truegrain/errors.py +99 -0
- truegrain/filters.py +85 -0
- truegrain/models.py +343 -0
- truegrain/tools.py +223 -0
- truegrain-0.1.0.dist-info/METADATA +224 -0
- truegrain-0.1.0.dist-info/RECORD +10 -0
- truegrain-0.1.0.dist-info/WHEEL +4 -0
- truegrain-0.1.0.dist-info/licenses/LICENSE +202 -0
truegrain/__init__.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Python client for the semantic engine.
|
|
2
|
+
|
|
3
|
+
An Apache Ossie semantic model compiled to governed, dialect-correct SQL. This
|
|
4
|
+
package talks to it over HTTP. There is no method that sends SQL, because there
|
|
5
|
+
is no endpoint that accepts it: the only expressible request is a semantic one.
|
|
6
|
+
|
|
7
|
+
Quick start:
|
|
8
|
+
|
|
9
|
+
>>> from truegrain import Client, filters
|
|
10
|
+
>>> client = Client.from_env() # SEMANTIC_URL, SEMANTIC_TOKEN
|
|
11
|
+
>>> for metric in client.metrics(search="revenue"):
|
|
12
|
+
... print(metric.name, "-", metric.description)
|
|
13
|
+
>>> result = client.query(
|
|
14
|
+
... metrics=["sales.order_revenue"],
|
|
15
|
+
... dimensions=["sales.customers.region"],
|
|
16
|
+
... filters=[filters.in_("sales.orders.status", "shipped", "delivered")],
|
|
17
|
+
... order_by=[{"field": "order_revenue", "desc": True}],
|
|
18
|
+
... )
|
|
19
|
+
>>> result.to_dataframe()
|
|
20
|
+
|
|
21
|
+
Refusals are answers, not failures. Catch :class:`Refused` and branch on
|
|
22
|
+
``retry``:
|
|
23
|
+
|
|
24
|
+
>>> try:
|
|
25
|
+
... client.query(metrics=["sales.order_revenue"],
|
|
26
|
+
... dimensions=["sales.products.category"])
|
|
27
|
+
... except Refused as refusal:
|
|
28
|
+
... if refusal.should_modify():
|
|
29
|
+
... print("try instead:", refusal.hint)
|
|
30
|
+
|
|
31
|
+
The engine has no required dependencies here: this package uses only the
|
|
32
|
+
standard library. pandas is optional and needed only for
|
|
33
|
+
:meth:`Result.to_dataframe`.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
from . import filters, tools
|
|
39
|
+
from .client import OPERATIONS, Client
|
|
40
|
+
from .errors import Refused, Retry, SemanticError, TransportError, Unauthorized
|
|
41
|
+
from .models import Compiled, Dimension, Health, Job, Metric, Namespace, Result
|
|
42
|
+
|
|
43
|
+
__version__ = "0.1.0"
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"Client",
|
|
47
|
+
"Compiled",
|
|
48
|
+
"Dimension",
|
|
49
|
+
"Health",
|
|
50
|
+
"Job",
|
|
51
|
+
"Metric",
|
|
52
|
+
"Namespace",
|
|
53
|
+
"OPERATIONS",
|
|
54
|
+
"Refused",
|
|
55
|
+
"Result",
|
|
56
|
+
"Retry",
|
|
57
|
+
"SemanticError",
|
|
58
|
+
"TransportError",
|
|
59
|
+
"Unauthorized",
|
|
60
|
+
"__version__",
|
|
61
|
+
"filters",
|
|
62
|
+
"tools",
|
|
63
|
+
]
|
truegrain/client.py
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
"""HTTP client for the semantic engine.
|
|
2
|
+
|
|
3
|
+
Deliberately dependency-free. It uses only the standard library so that
|
|
4
|
+
installing it into an agent's environment cannot conflict with anything already
|
|
5
|
+
there. pandas is optional and used only by :meth:`Result.to_dataframe`.
|
|
6
|
+
|
|
7
|
+
There is no method that sends SQL, because there is no endpoint that accepts it.
|
|
8
|
+
The only expressible request is a semantic one.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import time
|
|
16
|
+
import urllib.error
|
|
17
|
+
import urllib.parse
|
|
18
|
+
import urllib.request
|
|
19
|
+
from typing import Any, Iterable, Mapping
|
|
20
|
+
|
|
21
|
+
from .errors import Refused, SemanticError, TransportError, Unauthorized
|
|
22
|
+
from .models import Compiled, Dimension, Health, Job, Metric, Namespace, Result
|
|
23
|
+
|
|
24
|
+
__all__ = ["Client"]
|
|
25
|
+
|
|
26
|
+
#: Maps each OpenAPI operationId to the method that implements it.
|
|
27
|
+
#:
|
|
28
|
+
#: tests/test_covers_spec.py asserts this covers every operation in
|
|
29
|
+
#: api/openapi.yaml, so an endpoint added to the engine cannot quietly go
|
|
30
|
+
#: unsupported here.
|
|
31
|
+
OPERATIONS: dict[str, str] = {
|
|
32
|
+
"getHealth": "health",
|
|
33
|
+
"getModelVersion": "model_version",
|
|
34
|
+
"listNamespaces": "namespaces",
|
|
35
|
+
"listMetrics": "metrics",
|
|
36
|
+
"describeMetric": "metric",
|
|
37
|
+
"listDimensions": "dimensions",
|
|
38
|
+
"query": "query",
|
|
39
|
+
"compile": "compile",
|
|
40
|
+
"submitJob": "submit",
|
|
41
|
+
"getJob": "job",
|
|
42
|
+
"cancelJob": "cancel_job",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
DEFAULT_TIMEOUT = 60.0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Client:
|
|
49
|
+
"""A connection to one semantic engine.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
base_url: Where the engine is served, for example
|
|
53
|
+
``http://127.0.0.1:8080``.
|
|
54
|
+
token: Bearer token identifying the workload. Read it from the
|
|
55
|
+
environment; never hard-code one. Omit it only against a local
|
|
56
|
+
engine running without authentication.
|
|
57
|
+
timeout: Seconds to wait for a response.
|
|
58
|
+
user_agent: Overrides the identifying header, which is useful when you
|
|
59
|
+
want the audit log to distinguish one agent from another.
|
|
60
|
+
|
|
61
|
+
Example:
|
|
62
|
+
>>> from truegrain import Client, filters
|
|
63
|
+
>>> c = Client("http://127.0.0.1:8080", token=os.environ["SEMANTIC_TOKEN"])
|
|
64
|
+
>>> result = c.query(
|
|
65
|
+
... metrics=["sales.order_revenue"],
|
|
66
|
+
... dimensions=["sales.customers.region"],
|
|
67
|
+
... filters=[filters.eq("sales.orders.status", "shipped")],
|
|
68
|
+
... )
|
|
69
|
+
>>> result.to_dataframe()
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
base_url: str,
|
|
75
|
+
token: str | None = None,
|
|
76
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
77
|
+
user_agent: str = "truegrain-python/1.0",
|
|
78
|
+
) -> None:
|
|
79
|
+
if not base_url:
|
|
80
|
+
raise ValueError("base_url is required")
|
|
81
|
+
self.base_url = base_url.rstrip("/")
|
|
82
|
+
self._token = token
|
|
83
|
+
self.timeout = timeout
|
|
84
|
+
self.user_agent = user_agent
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def from_env(
|
|
88
|
+
cls,
|
|
89
|
+
url_var: str = "SEMANTIC_URL",
|
|
90
|
+
token_var: str = "SEMANTIC_TOKEN",
|
|
91
|
+
**kwargs: Any,
|
|
92
|
+
) -> "Client":
|
|
93
|
+
"""Build a client from environment variables.
|
|
94
|
+
|
|
95
|
+
Keeps the token out of source and out of the process list, which is
|
|
96
|
+
where a token passed as a command-line flag ends up.
|
|
97
|
+
"""
|
|
98
|
+
url = os.environ.get(url_var)
|
|
99
|
+
if not url:
|
|
100
|
+
raise SemanticError(f"{url_var} is not set")
|
|
101
|
+
return cls(url, token=os.environ.get(token_var), **kwargs)
|
|
102
|
+
|
|
103
|
+
# ---------- metadata ----------
|
|
104
|
+
|
|
105
|
+
def health(self) -> Health:
|
|
106
|
+
"""What this deployment enforces, including what it does not.
|
|
107
|
+
|
|
108
|
+
Worth reading before trusting the layer with anything sensitive:
|
|
109
|
+
``health().enforcement_notes`` states the gaps in plain language.
|
|
110
|
+
"""
|
|
111
|
+
return Health.parse(self._get("/v1/health"))
|
|
112
|
+
|
|
113
|
+
def model_version(self) -> str:
|
|
114
|
+
"""The workspace digest currently served.
|
|
115
|
+
|
|
116
|
+
Every query result carries this too. Two results with the same digest
|
|
117
|
+
were produced by exactly the same definitions.
|
|
118
|
+
"""
|
|
119
|
+
return str(self._get("/v1/model/version").get("model_version", ""))
|
|
120
|
+
|
|
121
|
+
def namespaces(self) -> list[Namespace]:
|
|
122
|
+
"""The namespaces in the workspace, their owners and availability."""
|
|
123
|
+
payload = self._get("/v1/namespaces")
|
|
124
|
+
return [Namespace.parse(n) for n in payload.get("namespaces", [])]
|
|
125
|
+
|
|
126
|
+
def metrics(self, search: str | None = None) -> list[Metric]:
|
|
127
|
+
"""Every metric this identity may read.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
search: Case-insensitive substring over name and description.
|
|
131
|
+
"""
|
|
132
|
+
params = {"search": search} if search else None
|
|
133
|
+
payload = self._get("/v1/metrics", params)
|
|
134
|
+
return [Metric.parse(m) for m in payload.get("metrics", [])]
|
|
135
|
+
|
|
136
|
+
def metric(self, name: str) -> Metric:
|
|
137
|
+
"""One metric's definition and the dimensions legal for it.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
name: Qualified as ``namespace.metric``, or bare when unambiguous
|
|
141
|
+
across the workspace.
|
|
142
|
+
"""
|
|
143
|
+
if not name:
|
|
144
|
+
raise ValueError("metric name is required")
|
|
145
|
+
return Metric.parse(self._get(f"/v1/metrics/{urllib.parse.quote(name, safe='')}"))
|
|
146
|
+
|
|
147
|
+
def dimensions(self, metric: str | None = None) -> list[Dimension]:
|
|
148
|
+
"""Dimensions available for grouping and filtering.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
metric: Restrict to dimensions valid for this metric, which is
|
|
152
|
+
almost always what you want.
|
|
153
|
+
"""
|
|
154
|
+
params = {"metric": metric} if metric else None
|
|
155
|
+
payload = self._get("/v1/dimensions", params)
|
|
156
|
+
return [Dimension.parse(d) for d in payload.get("dimensions", [])]
|
|
157
|
+
|
|
158
|
+
# ---------- query ----------
|
|
159
|
+
|
|
160
|
+
def query(
|
|
161
|
+
self,
|
|
162
|
+
metrics: Iterable[str],
|
|
163
|
+
dimensions: Iterable[str] | None = None,
|
|
164
|
+
filters: Iterable[Mapping[str, Any]] | None = None,
|
|
165
|
+
grain: str | None = None,
|
|
166
|
+
limit: int | None = None,
|
|
167
|
+
order_by: Iterable[Mapping[str, Any]] | None = None,
|
|
168
|
+
) -> Result:
|
|
169
|
+
"""Answer a question and return rows.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
metrics: Metric names. Metrics at different grains, or in different
|
|
173
|
+
namespaces, are aggregated separately and joined on the shared
|
|
174
|
+
dimensions.
|
|
175
|
+
dimensions: ``dataset.field`` or ``namespace.dataset.field``.
|
|
176
|
+
filters: Structured filters. Use :mod:`truegrain.filters` to
|
|
177
|
+
build them.
|
|
178
|
+
grain: Time bucket for the selected time dimension.
|
|
179
|
+
limit: Maximum rows. Defaults to the server's limit.
|
|
180
|
+
order_by: ``[{"field": "order_revenue", "desc": True}]``.
|
|
181
|
+
|
|
182
|
+
Raises:
|
|
183
|
+
Refused: The engine declined. Check ``err.retry`` before retrying.
|
|
184
|
+
"""
|
|
185
|
+
return Result.parse(
|
|
186
|
+
self._post("/v1/query", self._request_body(metrics, dimensions, filters, grain, limit, order_by))
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
def compile(
|
|
190
|
+
self,
|
|
191
|
+
metrics: Iterable[str],
|
|
192
|
+
dimensions: Iterable[str] | None = None,
|
|
193
|
+
filters: Iterable[Mapping[str, Any]] | None = None,
|
|
194
|
+
grain: str | None = None,
|
|
195
|
+
limit: int | None = None,
|
|
196
|
+
order_by: Iterable[Mapping[str, Any]] | None = None,
|
|
197
|
+
) -> Compiled:
|
|
198
|
+
"""Return the SQL a request compiles to, without running it.
|
|
199
|
+
|
|
200
|
+
A dry run is still governed: a request you may not run returns a refusal
|
|
201
|
+
and no SQL, and the inspection is audited. It is a way to see what a
|
|
202
|
+
query would do, not a way around the gate.
|
|
203
|
+
"""
|
|
204
|
+
return Compiled.parse(
|
|
205
|
+
self._post("/v1/compile", self._request_body(metrics, dimensions, filters, grain, limit, order_by))
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# ---------- asynchronous execution ----------
|
|
209
|
+
|
|
210
|
+
def run(
|
|
211
|
+
self,
|
|
212
|
+
metrics: Iterable[str],
|
|
213
|
+
dimensions: Iterable[str] | None = None,
|
|
214
|
+
filters: Iterable[Mapping[str, Any]] | None = None,
|
|
215
|
+
grain: str | None = None,
|
|
216
|
+
limit: int | None = None,
|
|
217
|
+
order_by: Iterable[Mapping[str, Any]] | None = None,
|
|
218
|
+
max_wait: float = 900.0,
|
|
219
|
+
page_size: int | None = None,
|
|
220
|
+
) -> Result:
|
|
221
|
+
"""Run a query that may take longer than an HTTP request should.
|
|
222
|
+
|
|
223
|
+
Submits the query, polls until it finishes, collects every page, and
|
|
224
|
+
returns one :class:`Result`. Use this instead of :meth:`query` whenever
|
|
225
|
+
the warehouse might be slow: a large BigQuery scan routinely outlives
|
|
226
|
+
the default timeout of an agent framework, and a synchronous call that
|
|
227
|
+
times out leaves the query running and billable with nobody reading it.
|
|
228
|
+
|
|
229
|
+
The governance gate runs during submission, so a request this identity
|
|
230
|
+
may not make raises :class:`Refused` immediately rather than after a
|
|
231
|
+
wait.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
max_wait: Seconds to wait before giving up. On expiry the job is
|
|
235
|
+
cancelled rather than left running, so an abandoned wait does
|
|
236
|
+
not keep spending warehouse time.
|
|
237
|
+
page_size: Rows per page while collecting. The default suits most
|
|
238
|
+
results; lower it when rows are wide.
|
|
239
|
+
|
|
240
|
+
Raises:
|
|
241
|
+
Refused: The engine declined, or the query failed. Check
|
|
242
|
+
``err.retry`` before retrying.
|
|
243
|
+
SemanticError: The wait expired, or the job was cancelled.
|
|
244
|
+
"""
|
|
245
|
+
job = self.submit(metrics, dimensions, filters, grain, limit, order_by)
|
|
246
|
+
job = self.wait(job.job_id, max_wait=max_wait, page_size=page_size)
|
|
247
|
+
job.raise_for_state()
|
|
248
|
+
|
|
249
|
+
# Walk the remaining pages. row_count is the size of the whole result,
|
|
250
|
+
# so it bounds the walk without the loop having to trust the cursor to
|
|
251
|
+
# eventually come back empty.
|
|
252
|
+
rows = list(job.rows)
|
|
253
|
+
cursor = job.next_cursor
|
|
254
|
+
total = job.row_count or None
|
|
255
|
+
while cursor and (total is None or len(rows) < total):
|
|
256
|
+
page = self.job(job.job_id, cursor=cursor, page_size=page_size)
|
|
257
|
+
if not page.rows:
|
|
258
|
+
break
|
|
259
|
+
rows.extend(page.rows)
|
|
260
|
+
cursor = page.next_cursor
|
|
261
|
+
return job.as_result(tuple(rows))
|
|
262
|
+
|
|
263
|
+
def submit(
|
|
264
|
+
self,
|
|
265
|
+
metrics: Iterable[str],
|
|
266
|
+
dimensions: Iterable[str] | None = None,
|
|
267
|
+
filters: Iterable[Mapping[str, Any]] | None = None,
|
|
268
|
+
grain: str | None = None,
|
|
269
|
+
limit: int | None = None,
|
|
270
|
+
order_by: Iterable[Mapping[str, Any]] | None = None,
|
|
271
|
+
) -> Job:
|
|
272
|
+
"""Start a query in the background and return immediately.
|
|
273
|
+
|
|
274
|
+
The returned job already carries ``compiled_sql``, because compilation
|
|
275
|
+
happened synchronously. A job coming back at all means the query was
|
|
276
|
+
authorized, not merely accepted.
|
|
277
|
+
|
|
278
|
+
Prefer :meth:`run` unless you genuinely need to do something else while
|
|
279
|
+
the query runs.
|
|
280
|
+
"""
|
|
281
|
+
return Job.parse(
|
|
282
|
+
self._post(
|
|
283
|
+
"/v1/jobs",
|
|
284
|
+
self._request_body(metrics, dimensions, filters, grain, limit, order_by),
|
|
285
|
+
)
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
def job(self, job_id: str, cursor: str | None = None, page_size: int | None = None) -> Job:
|
|
289
|
+
"""Poll a job and read one page of its rows.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
cursor: From a previous page's ``next_cursor``. Omit for the first.
|
|
293
|
+
page_size: Rows in this page.
|
|
294
|
+
"""
|
|
295
|
+
if not job_id:
|
|
296
|
+
raise ValueError("job_id is required")
|
|
297
|
+
params: dict[str, str] = {}
|
|
298
|
+
if cursor:
|
|
299
|
+
params["cursor"] = cursor
|
|
300
|
+
if page_size is not None:
|
|
301
|
+
params["page_size"] = str(page_size)
|
|
302
|
+
return Job.parse(
|
|
303
|
+
self._get(f"/v1/jobs/{urllib.parse.quote(job_id, safe='')}", params or None)
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
def cancel_job(self, job_id: str) -> Job:
|
|
307
|
+
"""Stop a running query. Idempotent."""
|
|
308
|
+
if not job_id:
|
|
309
|
+
raise ValueError("job_id is required")
|
|
310
|
+
return Job.parse(
|
|
311
|
+
self._delete(f"/v1/jobs/{urllib.parse.quote(job_id, safe='')}")
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
def wait(
|
|
315
|
+
self,
|
|
316
|
+
job_id: str,
|
|
317
|
+
max_wait: float = 900.0,
|
|
318
|
+
page_size: int | None = None,
|
|
319
|
+
) -> Job:
|
|
320
|
+
"""Poll until a job reaches a terminal state.
|
|
321
|
+
|
|
322
|
+
Backs off from a fast first poll to a two second ceiling, so a query
|
|
323
|
+
that finishes quickly is not delayed and a slow one is not hammered.
|
|
324
|
+
|
|
325
|
+
On expiry the job is cancelled before raising, so giving up here also
|
|
326
|
+
stops the warehouse work.
|
|
327
|
+
"""
|
|
328
|
+
deadline = time.monotonic() + max_wait
|
|
329
|
+
interval = 0.25
|
|
330
|
+
while True:
|
|
331
|
+
job = self.job(job_id, page_size=page_size)
|
|
332
|
+
if job.is_done:
|
|
333
|
+
return job
|
|
334
|
+
if time.monotonic() >= deadline:
|
|
335
|
+
# Leaving it running would keep spending warehouse time for an
|
|
336
|
+
# answer this caller has already stopped waiting for.
|
|
337
|
+
try:
|
|
338
|
+
self.cancel_job(job_id)
|
|
339
|
+
except SemanticError:
|
|
340
|
+
pass
|
|
341
|
+
raise SemanticError(
|
|
342
|
+
f"job {job_id} did not finish within {max_wait:g}s and was cancelled"
|
|
343
|
+
)
|
|
344
|
+
time.sleep(min(interval, max(0.0, deadline - time.monotonic())))
|
|
345
|
+
interval = min(interval * 1.5, 2.0)
|
|
346
|
+
|
|
347
|
+
# ---------- internals ----------
|
|
348
|
+
|
|
349
|
+
@staticmethod
|
|
350
|
+
def _request_body(
|
|
351
|
+
metrics: Iterable[str],
|
|
352
|
+
dimensions: Iterable[str] | None,
|
|
353
|
+
filters: Iterable[Mapping[str, Any]] | None,
|
|
354
|
+
grain: str | None,
|
|
355
|
+
limit: int | None,
|
|
356
|
+
order_by: Iterable[Mapping[str, Any]] | None,
|
|
357
|
+
) -> dict[str, Any]:
|
|
358
|
+
names = list(metrics)
|
|
359
|
+
if not names:
|
|
360
|
+
raise ValueError("at least one metric is required")
|
|
361
|
+
body: dict[str, Any] = {"metrics": names}
|
|
362
|
+
# Omitted rather than sent empty: the server rejects unknown and
|
|
363
|
+
# malformed fields, and an empty list is not the same as absent.
|
|
364
|
+
if dimensions:
|
|
365
|
+
body["dimensions"] = list(dimensions)
|
|
366
|
+
if filters:
|
|
367
|
+
body["filters"] = [dict(f) for f in filters]
|
|
368
|
+
if grain:
|
|
369
|
+
body["grain"] = grain
|
|
370
|
+
if limit is not None:
|
|
371
|
+
body["limit"] = limit
|
|
372
|
+
if order_by:
|
|
373
|
+
body["order_by"] = [dict(o) for o in order_by]
|
|
374
|
+
return body
|
|
375
|
+
|
|
376
|
+
def _headers(self) -> dict[str, str]:
|
|
377
|
+
headers = {"Accept": "application/json", "User-Agent": self.user_agent}
|
|
378
|
+
if self._token:
|
|
379
|
+
headers["Authorization"] = f"Bearer {self._token}"
|
|
380
|
+
return headers
|
|
381
|
+
|
|
382
|
+
def _get(self, path: str, params: Mapping[str, str] | None = None) -> dict[str, Any]:
|
|
383
|
+
url = self.base_url + path
|
|
384
|
+
if params:
|
|
385
|
+
url += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v})
|
|
386
|
+
return self._send(urllib.request.Request(url, headers=self._headers(), method="GET"))
|
|
387
|
+
|
|
388
|
+
def _post(self, path: str, body: Mapping[str, Any]) -> dict[str, Any]:
|
|
389
|
+
headers = self._headers()
|
|
390
|
+
headers["Content-Type"] = "application/json"
|
|
391
|
+
payload = json.dumps(body).encode("utf-8")
|
|
392
|
+
request = urllib.request.Request(
|
|
393
|
+
self.base_url + path, data=payload, headers=headers, method="POST"
|
|
394
|
+
)
|
|
395
|
+
return self._send(request)
|
|
396
|
+
|
|
397
|
+
def _delete(self, path: str) -> dict[str, Any]:
|
|
398
|
+
return self._send(
|
|
399
|
+
urllib.request.Request(self.base_url + path, headers=self._headers(), method="DELETE")
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
def _send(self, request: urllib.request.Request) -> dict[str, Any]:
|
|
403
|
+
try:
|
|
404
|
+
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
|
405
|
+
return self._decode(response.read(), response.status)
|
|
406
|
+
except urllib.error.HTTPError as exc:
|
|
407
|
+
# A refusal arrives as an error status with a JSON body. It is an
|
|
408
|
+
# answer, not a transport failure, so it is raised as Refused with
|
|
409
|
+
# the code and retry class intact.
|
|
410
|
+
raw = exc.read()
|
|
411
|
+
if exc.code == 401:
|
|
412
|
+
raise Unauthorized(self._message(raw, "credentials were not accepted")) from None
|
|
413
|
+
try:
|
|
414
|
+
payload = json.loads(raw.decode("utf-8"))
|
|
415
|
+
except (ValueError, UnicodeDecodeError):
|
|
416
|
+
raise TransportError(
|
|
417
|
+
f"HTTP {exc.code} from {request.full_url}: {self._message(raw, exc.reason)}"
|
|
418
|
+
) from None
|
|
419
|
+
if isinstance(payload, dict) and "code" in payload:
|
|
420
|
+
raise Refused.from_payload(payload, exc.code) from None
|
|
421
|
+
raise TransportError(f"HTTP {exc.code} from {request.full_url}: {payload}") from None
|
|
422
|
+
except urllib.error.URLError as exc:
|
|
423
|
+
raise TransportError(f"cannot reach {request.full_url}: {exc.reason}") from None
|
|
424
|
+
except TimeoutError:
|
|
425
|
+
raise TransportError(
|
|
426
|
+
f"{request.full_url} did not respond within {self.timeout:g}s"
|
|
427
|
+
) from None
|
|
428
|
+
|
|
429
|
+
@staticmethod
|
|
430
|
+
def _decode(raw: bytes, status: int) -> dict[str, Any]:
|
|
431
|
+
try:
|
|
432
|
+
payload = json.loads(raw.decode("utf-8"))
|
|
433
|
+
except (ValueError, UnicodeDecodeError) as exc:
|
|
434
|
+
raise TransportError(f"HTTP {status} body was not JSON: {exc}") from None
|
|
435
|
+
if not isinstance(payload, dict):
|
|
436
|
+
raise TransportError(f"HTTP {status} body was not a JSON object")
|
|
437
|
+
return payload
|
|
438
|
+
|
|
439
|
+
@staticmethod
|
|
440
|
+
def _message(raw: bytes, fallback: Any) -> str:
|
|
441
|
+
text = raw.decode("utf-8", errors="replace").strip()
|
|
442
|
+
return text or str(fallback)
|
|
443
|
+
|
|
444
|
+
def __repr__(self) -> str:
|
|
445
|
+
return f"Client(base_url={self.base_url!r}, authenticated={bool(self._token)})"
|
truegrain/errors.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Errors raised by the client.
|
|
2
|
+
|
|
3
|
+
A refusal is not an exception in the usual sense. The engine understood the
|
|
4
|
+
request and declined it, and it told you what to do about that. The distinction
|
|
5
|
+
matters most to an agent: the difference between "change the arguments" and
|
|
6
|
+
"stop" is the difference between a self-correcting loop and an infinite one.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Literal
|
|
12
|
+
|
|
13
|
+
Retry = Literal["modify", "later", "never"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SemanticError(Exception):
|
|
17
|
+
"""Base class, so a caller can catch everything this library raises."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TransportError(SemanticError):
|
|
21
|
+
"""The engine could not be reached, or answered with something unparseable.
|
|
22
|
+
|
|
23
|
+
This is a network or deployment problem, never a statement about the
|
|
24
|
+
request. Retrying the same request is reasonable.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Unauthorized(SemanticError):
|
|
29
|
+
"""Credentials are missing or not recognised."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Refused(SemanticError):
|
|
33
|
+
"""The engine declined the request and said why.
|
|
34
|
+
|
|
35
|
+
Attributes:
|
|
36
|
+
code: Machine-readable reason. Branch on this, never on the text.
|
|
37
|
+
reason: One sentence stating what was wrong.
|
|
38
|
+
hint: What to do instead. For a fan-out refusal this names the metrics
|
|
39
|
+
defined at the grain where the question is well defined.
|
|
40
|
+
retry: One of "modify", "later" or "never". See :meth:`should_modify`,
|
|
41
|
+
:meth:`should_wait` and :meth:`is_final`.
|
|
42
|
+
status: The HTTP status that carried the refusal.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
code: str,
|
|
48
|
+
reason: str,
|
|
49
|
+
hint: str = "",
|
|
50
|
+
retry: Retry = "never",
|
|
51
|
+
status: int = 0,
|
|
52
|
+
) -> None:
|
|
53
|
+
self.code = code
|
|
54
|
+
self.reason = reason
|
|
55
|
+
self.hint = hint
|
|
56
|
+
self.retry: Retry = retry
|
|
57
|
+
self.status = status
|
|
58
|
+
super().__init__(self._render())
|
|
59
|
+
|
|
60
|
+
def _render(self) -> str:
|
|
61
|
+
text = f"{self.code}: {self.reason}"
|
|
62
|
+
if self.hint:
|
|
63
|
+
text += f"\n hint: {self.hint}"
|
|
64
|
+
text += f"\n retry: {self.retry}"
|
|
65
|
+
return text
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def from_payload(cls, payload: dict[str, Any], status: int) -> "Refused":
|
|
69
|
+
return cls(
|
|
70
|
+
code=str(payload.get("code", "unknown")),
|
|
71
|
+
reason=str(payload.get("reason", "")),
|
|
72
|
+
hint=str(payload.get("hint", "")),
|
|
73
|
+
retry=payload.get("retry", "never"),
|
|
74
|
+
status=status,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def should_modify(self) -> bool:
|
|
78
|
+
"""The request is answerable, but not as written.
|
|
79
|
+
|
|
80
|
+
Change the arguments and try again. Repeating it unchanged will not
|
|
81
|
+
work. The hint usually names what to change.
|
|
82
|
+
"""
|
|
83
|
+
return self.retry == "modify"
|
|
84
|
+
|
|
85
|
+
def should_wait(self) -> bool:
|
|
86
|
+
"""Nothing about the request is wrong.
|
|
87
|
+
|
|
88
|
+
Something outside it failed, such as the policy source being
|
|
89
|
+
unreachable. The same request may succeed shortly.
|
|
90
|
+
"""
|
|
91
|
+
return self.retry == "later"
|
|
92
|
+
|
|
93
|
+
def is_final(self) -> bool:
|
|
94
|
+
"""No version of this request from this caller will succeed.
|
|
95
|
+
|
|
96
|
+
Usually a denial. Say so rather than substituting a different metric
|
|
97
|
+
that answers a different question.
|
|
98
|
+
"""
|
|
99
|
+
return self.retry == "never"
|
truegrain/filters.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Constructors for structured filters.
|
|
2
|
+
|
|
3
|
+
A filter is never a SQL fragment. That is what keeps an ungoverned predicate
|
|
4
|
+
inexpressible, and it is the reason these are small functions rather than a
|
|
5
|
+
string builder.
|
|
6
|
+
|
|
7
|
+
Using them instead of hand-written dictionaries catches a misspelled operator at
|
|
8
|
+
the call site rather than as a refusal from the server.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
Filter = dict[str, Any]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _filter(dimension: str, op: str, *values: Any) -> Filter:
|
|
19
|
+
out: Filter = {"dimension": dimension, "op": op}
|
|
20
|
+
if values:
|
|
21
|
+
out["values"] = list(values)
|
|
22
|
+
return out
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def eq(dimension: str, value: Any) -> Filter:
|
|
26
|
+
"""`dimension = value`."""
|
|
27
|
+
return _filter(dimension, "eq", value)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def ne(dimension: str, value: Any) -> Filter:
|
|
31
|
+
"""`dimension <> value`."""
|
|
32
|
+
return _filter(dimension, "ne", value)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def in_(dimension: str, *values: Any) -> Filter:
|
|
36
|
+
"""`dimension IN (...)`. Named with a trailing underscore; `in` is a keyword."""
|
|
37
|
+
if not values:
|
|
38
|
+
raise ValueError("in_ needs at least one value")
|
|
39
|
+
return _filter(dimension, "in", *values)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def not_in(dimension: str, *values: Any) -> Filter:
|
|
43
|
+
"""`dimension NOT IN (...)`."""
|
|
44
|
+
if not values:
|
|
45
|
+
raise ValueError("not_in needs at least one value")
|
|
46
|
+
return _filter(dimension, "not_in", *values)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def gt(dimension: str, value: Any) -> Filter:
|
|
50
|
+
"""`dimension > value`."""
|
|
51
|
+
return _filter(dimension, "gt", value)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def gte(dimension: str, value: Any) -> Filter:
|
|
55
|
+
"""`dimension >= value`."""
|
|
56
|
+
return _filter(dimension, "gte", value)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def lt(dimension: str, value: Any) -> Filter:
|
|
60
|
+
"""`dimension < value`."""
|
|
61
|
+
return _filter(dimension, "lt", value)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def lte(dimension: str, value: Any) -> Filter:
|
|
65
|
+
"""`dimension <= value`."""
|
|
66
|
+
return _filter(dimension, "lte", value)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def between(dimension: str, low: Any, high: Any) -> Filter:
|
|
70
|
+
"""`dimension BETWEEN low AND high`.
|
|
71
|
+
|
|
72
|
+
The engine refuses a reversed range rather than matching no rows, because an
|
|
73
|
+
empty result that looks like a real answer is worse than an error.
|
|
74
|
+
"""
|
|
75
|
+
return _filter(dimension, "between", low, high)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def is_null(dimension: str) -> Filter:
|
|
79
|
+
"""`dimension IS NULL`."""
|
|
80
|
+
return _filter(dimension, "is_null")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def is_not_null(dimension: str) -> Filter:
|
|
84
|
+
"""`dimension IS NOT NULL`."""
|
|
85
|
+
return _filter(dimension, "is_not_null")
|