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 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")