truegrain

Python client for the semantic engine.

An Apache Ossie semantic model compiled to governed, dialect-correct SQL. This package talks to it over HTTP. There is no method that sends SQL, because there is no endpoint that accepts it: the only expressible request is a semantic one.

Quick start:
>>> from truegrain import Client, filters
>>> client = Client.from_env()          # SEMANTIC_URL, SEMANTIC_TOKEN
>>> for metric in client.metrics(search="revenue"):
...     print(metric.name, "-", metric.description)
>>> result = client.query(
...     metrics=["sales.order_revenue"],
...     dimensions=["sales.customers.region"],
...     filters=[filters.in_("sales.orders.status", "shipped", "delivered")],
...     order_by=[{"field": "order_revenue", "desc": True}],
... )
>>> result.to_dataframe()

Refusals are answers, not failures. Catch Refused and branch on retry:

>>> try:
...     client.query(metrics=["sales.order_revenue"],
...                  dimensions=["sales.products.category"])
... except Refused as refusal:
...     if refusal.should_modify():
...         print("try instead:", refusal.hint)

The engine has no required dependencies here: this package uses only the standard library. pandas is optional and needed only for Result.to_dataframe().

  1"""Python client for the semantic engine.
  2
  3An Apache Ossie semantic model compiled to governed, dialect-correct SQL. This
  4package talks to it over HTTP. There is no method that sends SQL, because there
  5is no endpoint that accepts it: the only expressible request is a semantic one.
  6
  7Quick 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
 21Refusals 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
 31The engine has no required dependencies here: this package uses only the
 32standard library. pandas is optional and needed only for
 33:meth:`Result.to_dataframe`.
 34"""
 35
 36from __future__ import annotations
 37
 38from importlib.metadata import PackageNotFoundError
 39from importlib.metadata import version as _metadata_version
 40
 41from . import filters, tools
 42from .client import OPERATIONS, Client
 43from .errors import Refused, Retry, SemanticError, TransportError, Unauthorized
 44from .models import (
 45    Change,
 46    Diagnosis,
 47    Diff,
 48    DoctorHistory,
 49    DoctorRun,
 50    Finding,
 51    Origin,
 52    Policy,
 53    PolicyExplanation,
 54    TestCase,
 55    TestReport,
 56    AuditEvent,
 57    AuditPage,
 58    Compiled,
 59    Dimension,
 60    Health,
 61    Job,
 62    Metric,
 63    Namespace,
 64    Result,
 65)
 66
 67# Read from the installed metadata rather than written here, so there is one
 68# version in the project and not two. The literal drifted from pyproject.toml
 69# and the release that caught it was the one that failed: a wheel whose
 70# version disagrees with its tag cannot be traced back to a commit.
 71try:
 72    __version__ = _metadata_version("truegrain")
 73except PackageNotFoundError:  # running from a source tree, never installed
 74    __version__ = "0+unknown"
 75
 76__all__ = [
 77    "AuditEvent",
 78    "AuditPage",
 79    "Change",
 80    "Client",
 81    "Compiled",
 82    "Diagnosis",
 83    "Diff",
 84    "Dimension",
 85    "DoctorHistory",
 86    "DoctorRun",
 87    "Finding",
 88    "Health",
 89    "Job",
 90    "Metric",
 91    "Namespace",
 92    "OPERATIONS",
 93    "Origin",
 94    "Policy",
 95    "PolicyExplanation",
 96    "Refused",
 97    "Result",
 98    "Retry",
 99    "SemanticError",
100    "TestCase",
101    "TestReport",
102    "TransportError",
103    "Unauthorized",
104    "__version__",
105    "filters",
106    "tools",
107]
@dataclass(frozen=True)
class AuditEvent:
353@dataclass(frozen=True)
354class AuditEvent:
355    """One decision the engine recorded.
356
357    ``refused`` and ``denied`` are deliberately separate. Denied is access:
358    this caller may not read something. Refused is correctness: nobody can be
359    told this accurately, and ``hint`` names a question that can be. Counting
360    them together would report every fan-out as an access incident.
361
362    Carries no filter values, no compiled SQL and no result rows. ``sql_hash``
363    identifies the statement without disclosing it.
364    """
365
366    #: RFC 3339.
367    time: str
368    #: One of ``allowed``, ``refused``, ``denied``, ``compiled``, ``error``.
369    decision: str
370    #: Who asked. ``anonymous`` on an engine without authentication.
371    identity: str = "anonymous"
372    #: Which refusal, when ``decision`` is ``refused``.
373    code: str = ""
374    #: What the caller should do about a refusal: ``modify``, ``later`` or
375    #: ``never``. Empty for anything that is not a refusal.
376    retry: str = ""
377    reason: str = ""
378    #: What would answer instead. The actionable half of a refusal.
379    hint: str = ""
380    #: What was asked for. Names only, never values.
381    metrics: tuple[str, ...] = ()
382    dimensions: tuple[str, ...] = ()
383    namespace: str = ""
384    model_name: str = ""
385    #: The workspace digest that answered, tying a number to a model revision.
386    model_version: str = ""
387    dialect: str = ""
388    #: Semantic fields refused on a denial. Never returned to the denied caller.
389    denied_fields: tuple[str, ...] = ()
390    sql_hash: str = ""
391    #: The warehouse's own identifier, so two logs can be joined.
392    job_id: str = ""
393    row_count: int = 0
394    #: What the warehouse says the query cost. Zero means not reported rather
395    #: than free: DuckDB bills nobody and reports nothing, and the two are
396    #: indistinguishable here on purpose.
397    bytes_billed: int = 0
398    duration_ms: int = 0
399    #: The failure, when ``decision`` is ``error``.
400    error: str = ""
401    raw: dict[str, Any] = field(default_factory=dict, repr=False)
402
403    @classmethod
404    def parse(cls, payload: dict[str, Any]) -> "AuditEvent":
405        retry = _get(payload, "retry", "")
406        return cls(
407            time=_get(payload, "time", ""),
408            # An unrecognised decision reads as ``error`` rather than as an
409            # empty string, so a caller branching on it cannot silently treat
410            # something the engine meant as significant as nothing at all.
411            decision=_get(payload, "decision", "") or "error",
412            identity=_get(payload, "identity", "") or "anonymous",
413            code=_get(payload, "refusal_code", ""),
414            retry=retry if retry in ("modify", "later", "never") else "",
415            reason=_get(payload, "reason", ""),
416            hint=_get(payload, "hint", ""),
417            metrics=tuple(_get(payload, "metrics", [])),
418            dimensions=tuple(_get(payload, "dimensions", [])),
419            namespace=_get(payload, "namespace", ""),
420            model_name=_get(payload, "model_name", ""),
421            model_version=_get(payload, "model_version", ""),
422            dialect=_get(payload, "dialect", ""),
423            denied_fields=tuple(_get(payload, "denied_fields", [])),
424            sql_hash=_get(payload, "sql_hash", ""),
425            job_id=_get(payload, "job_id", ""),
426            row_count=int(_get(payload, "row_count", 0)),
427            bytes_billed=int(_get(payload, "bytes_billed", 0)),
428            duration_ms=int(_get(payload, "duration_ms", 0)),
429            error=_get(payload, "error", ""),
430            raw=payload,
431        )
432
433    def is_refusal(self) -> bool:
434        """True when the engine declined for correctness rather than access."""
435        return self.decision == "refused"
436
437    def is_denial(self) -> bool:
438        """True when the engine declined because this caller may not read something."""
439        return self.decision == "denied"

One decision the engine recorded.

refused and denied are deliberately separate. Denied is access: this caller may not read something. Refused is correctness: nobody can be told this accurately, and hint names a question that can be. Counting them together would report every fan-out as an access incident.

Carries no filter values, no compiled SQL and no result rows. sql_hash identifies the statement without disclosing it.

AuditEvent( time: str, decision: str, identity: str = 'anonymous', code: str = '', retry: str = '', reason: str = '', hint: str = '', metrics: tuple[str, ...] = (), dimensions: tuple[str, ...] = (), namespace: str = '', model_name: str = '', model_version: str = '', dialect: str = '', denied_fields: tuple[str, ...] = (), sql_hash: str = '', job_id: str = '', row_count: int = 0, bytes_billed: int = 0, duration_ms: int = 0, error: str = '', raw: dict[str, typing.Any] = <factory>)
time: str
decision: str
identity: str = 'anonymous'
code: str = ''
retry: str = ''
reason: str = ''
hint: str = ''
metrics: tuple[str, ...] = ()
dimensions: tuple[str, ...] = ()
namespace: str = ''
model_name: str = ''
model_version: str = ''
dialect: str = ''
denied_fields: tuple[str, ...] = ()
sql_hash: str = ''
job_id: str = ''
row_count: int = 0
bytes_billed: int = 0
duration_ms: int = 0
error: str = ''
raw: dict[str, typing.Any]
@classmethod
def parse(cls, payload: dict[str, typing.Any]) -> AuditEvent:
403    @classmethod
404    def parse(cls, payload: dict[str, Any]) -> "AuditEvent":
405        retry = _get(payload, "retry", "")
406        return cls(
407            time=_get(payload, "time", ""),
408            # An unrecognised decision reads as ``error`` rather than as an
409            # empty string, so a caller branching on it cannot silently treat
410            # something the engine meant as significant as nothing at all.
411            decision=_get(payload, "decision", "") or "error",
412            identity=_get(payload, "identity", "") or "anonymous",
413            code=_get(payload, "refusal_code", ""),
414            retry=retry if retry in ("modify", "later", "never") else "",
415            reason=_get(payload, "reason", ""),
416            hint=_get(payload, "hint", ""),
417            metrics=tuple(_get(payload, "metrics", [])),
418            dimensions=tuple(_get(payload, "dimensions", [])),
419            namespace=_get(payload, "namespace", ""),
420            model_name=_get(payload, "model_name", ""),
421            model_version=_get(payload, "model_version", ""),
422            dialect=_get(payload, "dialect", ""),
423            denied_fields=tuple(_get(payload, "denied_fields", [])),
424            sql_hash=_get(payload, "sql_hash", ""),
425            job_id=_get(payload, "job_id", ""),
426            row_count=int(_get(payload, "row_count", 0)),
427            bytes_billed=int(_get(payload, "bytes_billed", 0)),
428            duration_ms=int(_get(payload, "duration_ms", 0)),
429            error=_get(payload, "error", ""),
430            raw=payload,
431        )
def is_refusal(self) -> bool:
433    def is_refusal(self) -> bool:
434        """True when the engine declined for correctness rather than access."""
435        return self.decision == "refused"

True when the engine declined for correctness rather than access.

def is_denial(self) -> bool:
437    def is_denial(self) -> bool:
438        """True when the engine declined because this caller may not read something."""
439        return self.decision == "denied"

True when the engine declined because this caller may not read something.

@dataclass(frozen=True)
class AuditPage:
442@dataclass(frozen=True)
443class AuditPage:
444    """A window of recorded decisions, newest first.
445
446    A window rather than the whole record: the engine holds a bounded buffer in
447    memory. An engine started with an audit file keeps everything there, and
448    ``note`` says so.
449    """
450
451    events: tuple[AuditEvent, ...] = ()
452    #: How many are in this page, as the engine counted them.
453    count: int = 0
454    note: str = ""
455    raw: dict[str, Any] = field(default_factory=dict, repr=False)
456
457    @classmethod
458    def parse(cls, payload: dict[str, Any]) -> "AuditPage":
459        events = tuple(AuditEvent.parse(e) for e in _get(payload, "events", []))
460        return cls(
461            events=events,
462            # The engine's own count, falling back to what arrived. They agree,
463            # and preferring the engine's means a disagreement is visible rather
464            # than papered over by recounting.
465            count=int(_get(payload, "count", len(events))),
466            note=_get(payload, "note", ""),
467            raw=payload,
468        )
469
470    def __iter__(self) -> Iterator[AuditEvent]:
471        return iter(self.events)
472
473    def __len__(self) -> int:
474        return len(self.events)
475
476    def refusals(self) -> tuple[AuditEvent, ...]:
477        """Only the refusals, which is what an operator opens this to read."""
478        return tuple(e for e in self.events if e.is_refusal())

A window of recorded decisions, newest first.

A window rather than the whole record: the engine holds a bounded buffer in memory. An engine started with an audit file keeps everything there, and note says so.

AuditPage( events: tuple[AuditEvent, ...] = (), count: int = 0, note: str = '', raw: dict[str, typing.Any] = <factory>)
events: tuple[AuditEvent, ...] = ()
count: int = 0
note: str = ''
raw: dict[str, typing.Any]
@classmethod
def parse(cls, payload: dict[str, typing.Any]) -> AuditPage:
457    @classmethod
458    def parse(cls, payload: dict[str, Any]) -> "AuditPage":
459        events = tuple(AuditEvent.parse(e) for e in _get(payload, "events", []))
460        return cls(
461            events=events,
462            # The engine's own count, falling back to what arrived. They agree,
463            # and preferring the engine's means a disagreement is visible rather
464            # than papered over by recounting.
465            count=int(_get(payload, "count", len(events))),
466            note=_get(payload, "note", ""),
467            raw=payload,
468        )
def refusals(self) -> tuple[AuditEvent, ...]:
476    def refusals(self) -> tuple[AuditEvent, ...]:
477        """Only the refusals, which is what an operator opens this to read."""
478        return tuple(e for e in self.events if e.is_refusal())

Only the refusals, which is what an operator opens this to read.

@dataclass(frozen=True)
class Change:
758@dataclass(frozen=True)
759class Change:
760    """What one request compiled to before and after a reload."""
761
762    before: str = ""
763    after: str = ""
764
765    @classmethod
766    def parse(cls, payload: dict[str, Any]) -> "Change":
767        return cls(before=_get(payload, "before", ""), after=_get(payload, "after", ""))

What one request compiled to before and after a reload.

Change(before: str = '', after: str = '')
before: str = ''
after: str = ''
@classmethod
def parse(cls, payload: dict[str, typing.Any]) -> Change:
765    @classmethod
766    def parse(cls, payload: dict[str, Any]) -> "Change":
767        return cls(before=_get(payload, "before", ""), after=_get(payload, "after", ""))
class Client:
 72class Client:
 73    """A connection to one semantic engine.
 74
 75    Args:
 76        base_url: Where the engine is served, for example
 77            ``http://127.0.0.1:8080``.
 78        token: Bearer token identifying the workload. Read it from the
 79            environment; never hard-code one. Omit it only against a local
 80            engine running without authentication.
 81        timeout: Seconds to wait for a response.
 82        user_agent: Overrides the identifying header, which is useful when you
 83            want the audit log to distinguish one agent from another.
 84
 85    Example:
 86        >>> from truegrain import Client, filters
 87        >>> c = Client("http://127.0.0.1:8080", token=os.environ["SEMANTIC_TOKEN"])
 88        >>> result = c.query(
 89        ...     metrics=["sales.order_revenue"],
 90        ...     dimensions=["sales.customers.region"],
 91        ...     filters=[filters.eq("sales.orders.status", "shipped")],
 92        ... )
 93        >>> result.to_dataframe()
 94    """
 95
 96    def __init__(
 97        self,
 98        base_url: str,
 99        token: str | None = None,
100        timeout: float = DEFAULT_TIMEOUT,
101        user_agent: str = "truegrain-python/1.0",
102    ) -> None:
103        if not base_url:
104            raise ValueError("base_url is required")
105        self.base_url = base_url.rstrip("/")
106        self._token = token
107        self.timeout = timeout
108        self.user_agent = user_agent
109
110    @classmethod
111    def from_env(
112        cls,
113        url_var: str = "SEMANTIC_URL",
114        token_var: str = "SEMANTIC_TOKEN",
115        **kwargs: Any,
116    ) -> "Client":
117        """Build a client from environment variables.
118
119        Keeps the token out of source and out of the process list, which is
120        where a token passed as a command-line flag ends up.
121        """
122        url = os.environ.get(url_var)
123        if not url:
124            raise SemanticError(f"{url_var} is not set")
125        return cls(url, token=os.environ.get(token_var), **kwargs)
126
127    # ---------- metadata ----------
128
129    def health(self) -> Health:
130        """What this deployment enforces, including what it does not.
131
132        Worth reading before trusting the layer with anything sensitive:
133        ``health().enforcement_notes`` states the gaps in plain language.
134        """
135        return Health.parse(self._get("/v1/health"))
136
137    def model_version(self) -> str:
138        """The workspace digest currently served.
139
140        Every query result carries this too. Two results with the same digest
141        were produced by exactly the same definitions.
142        """
143        return str(self._get("/v1/model/version").get("model_version", ""))
144
145    def namespaces(self) -> list[Namespace]:
146        """The namespaces in the workspace, their owners and availability."""
147        payload = self._get("/v1/namespaces")
148        return [Namespace.parse(n) for n in payload.get("namespaces", [])]
149
150    def metrics(self, search: str | None = None) -> list[Metric]:
151        """Every metric this identity may read.
152
153        Args:
154            search: Case-insensitive substring over name and description.
155        """
156        params = {"search": search} if search else None
157        payload = self._get("/v1/metrics", params)
158        return [Metric.parse(m) for m in payload.get("metrics", [])]
159
160    def metric(self, name: str) -> Metric:
161        """One metric's definition and the dimensions legal for it.
162
163        Args:
164            name: Qualified as ``namespace.metric``, or bare when unambiguous
165                across the workspace.
166        """
167        if not name:
168            raise ValueError("metric name is required")
169        return Metric.parse(self._get(f"/v1/metrics/{urllib.parse.quote(name, safe='')}"))
170
171    def dimensions(self, metric: str | None = None) -> list[Dimension]:
172        """Dimensions available for grouping and filtering.
173
174        Args:
175            metric: Restrict to dimensions valid for this metric, which is
176                almost always what you want.
177        """
178        params = {"metric": metric} if metric else None
179        payload = self._get("/v1/dimensions", params)
180        return [Dimension.parse(d) for d in payload.get("dimensions", [])]
181
182    # ---------- query ----------
183
184    def query(
185        self,
186        metrics: Iterable[str],
187        dimensions: Iterable[str] | None = None,
188        filters: Iterable[Mapping[str, Any]] | None = None,
189        grain: str | None = None,
190        limit: int | None = None,
191        order_by: Iterable[Mapping[str, Any]] | None = None,
192    ) -> Result:
193        """Answer a question and return rows.
194
195        Args:
196            metrics: Metric names. Metrics at different grains, or in different
197                namespaces, are aggregated separately and joined on the shared
198                dimensions.
199            dimensions: ``dataset.field`` or ``namespace.dataset.field``.
200            filters: Structured filters. Use :mod:`truegrain.filters` to
201                build them.
202            grain: Time bucket for the selected time dimension.
203            limit: Maximum rows. Defaults to the server's limit.
204            order_by: ``[{"field": "order_revenue", "desc": True}]``.
205
206        Raises:
207            Refused: The engine declined. Check ``err.retry`` before retrying.
208        """
209        return Result.parse(
210            self._post("/v1/query", self._request_body(metrics, dimensions, filters, grain, limit, order_by))
211        )
212
213    def compile(
214        self,
215        metrics: Iterable[str],
216        dimensions: Iterable[str] | None = None,
217        filters: Iterable[Mapping[str, Any]] | None = None,
218        grain: str | None = None,
219        limit: int | None = None,
220        order_by: Iterable[Mapping[str, Any]] | None = None,
221    ) -> Compiled:
222        """Return the SQL a request compiles to, without running it.
223
224        A dry run is still governed: a request you may not run returns a refusal
225        and no SQL, and the inspection is audited. It is a way to see what a
226        query would do, not a way around the gate.
227        """
228        return Compiled.parse(
229            self._post("/v1/compile", self._request_body(metrics, dimensions, filters, grain, limit, order_by))
230        )
231
232    # ---------- asynchronous execution ----------
233
234    def run(
235        self,
236        metrics: Iterable[str],
237        dimensions: Iterable[str] | None = None,
238        filters: Iterable[Mapping[str, Any]] | None = None,
239        grain: str | None = None,
240        limit: int | None = None,
241        order_by: Iterable[Mapping[str, Any]] | None = None,
242        max_wait: float = 900.0,
243        page_size: int | None = None,
244    ) -> Result:
245        """Run a query that may take longer than an HTTP request should.
246
247        Submits the query, polls until it finishes, collects every page, and
248        returns one :class:`Result`. Use this instead of :meth:`query` whenever
249        the warehouse might be slow: a large BigQuery scan routinely outlives
250        the default timeout of an agent framework, and a synchronous call that
251        times out leaves the query running and billable with nobody reading it.
252
253        The governance gate runs during submission, so a request this identity
254        may not make raises :class:`Refused` immediately rather than after a
255        wait.
256
257        Args:
258            max_wait: Seconds to wait before giving up. On expiry the job is
259                cancelled rather than left running, so an abandoned wait does
260                not keep spending warehouse time.
261            page_size: Rows per page while collecting. The default suits most
262                results; lower it when rows are wide.
263
264        Raises:
265            Refused: The engine declined, or the query failed. Check
266                ``err.retry`` before retrying.
267            SemanticError: The wait expired, or the job was cancelled.
268        """
269        job = self.submit(metrics, dimensions, filters, grain, limit, order_by)
270        job = self.wait(job.job_id, max_wait=max_wait, page_size=page_size)
271        job.raise_for_state()
272
273        # Walk the remaining pages. row_count is the size of the whole result,
274        # so it bounds the walk without the loop having to trust the cursor to
275        # eventually come back empty.
276        rows = list(job.rows)
277        cursor = job.next_cursor
278        total = job.row_count or None
279        while cursor and (total is None or len(rows) < total):
280            page = self.job(job.job_id, cursor=cursor, page_size=page_size)
281            if not page.rows:
282                break
283            rows.extend(page.rows)
284            cursor = page.next_cursor
285        return job.as_result(tuple(rows))
286
287    def submit(
288        self,
289        metrics: Iterable[str],
290        dimensions: Iterable[str] | None = None,
291        filters: Iterable[Mapping[str, Any]] | None = None,
292        grain: str | None = None,
293        limit: int | None = None,
294        order_by: Iterable[Mapping[str, Any]] | None = None,
295    ) -> Job:
296        """Start a query in the background and return immediately.
297
298        The returned job already carries ``compiled_sql``, because compilation
299        happened synchronously. A job coming back at all means the query was
300        authorized, not merely accepted.
301
302        Prefer :meth:`run` unless you genuinely need to do something else while
303        the query runs.
304        """
305        return Job.parse(
306            self._post(
307                "/v1/jobs",
308                self._request_body(metrics, dimensions, filters, grain, limit, order_by),
309            )
310        )
311
312    def job(self, job_id: str, cursor: str | None = None, page_size: int | None = None) -> Job:
313        """Poll a job and read one page of its rows.
314
315        Args:
316            cursor: From a previous page's ``next_cursor``. Omit for the first.
317            page_size: Rows in this page.
318        """
319        if not job_id:
320            raise ValueError("job_id is required")
321        params: dict[str, str] = {}
322        if cursor:
323            params["cursor"] = cursor
324        if page_size is not None:
325            params["page_size"] = str(page_size)
326        return Job.parse(
327            self._get(f"/v1/jobs/{urllib.parse.quote(job_id, safe='')}", params or None)
328        )
329
330    def cancel_job(self, job_id: str) -> Job:
331        """Stop a running query. Idempotent."""
332        if not job_id:
333            raise ValueError("job_id is required")
334        return Job.parse(
335            self._delete(f"/v1/jobs/{urllib.parse.quote(job_id, safe='')}")
336        )
337
338    def wait(
339        self,
340        job_id: str,
341        max_wait: float = 900.0,
342        page_size: int | None = None,
343    ) -> Job:
344        """Poll until a job reaches a terminal state.
345
346        Backs off from a fast first poll to a two second ceiling, so a query
347        that finishes quickly is not delayed and a slow one is not hammered.
348
349        On expiry the job is cancelled before raising, so giving up here also
350        stops the warehouse work.
351        """
352        deadline = time.monotonic() + max_wait
353        interval = 0.25
354        while True:
355            job = self.job(job_id, page_size=page_size)
356            if job.is_done:
357                return job
358            if time.monotonic() >= deadline:
359                # Leaving it running would keep spending warehouse time for an
360                # answer this caller has already stopped waiting for.
361                try:
362                    self.cancel_job(job_id)
363                except SemanticError:
364                    pass
365                raise SemanticError(
366                    f"job {job_id} did not finish within {max_wait:g}s and was cancelled"
367                )
368            time.sleep(min(interval, max(0.0, deadline - time.monotonic())))
369            interval = min(interval * 1.5, 2.0)
370
371    # ---------- audit ----------
372
373    def audit(self, limit: int | None = None, decision: str | None = None) -> AuditPage:
374        """Read the decisions the engine recently made, newest first.
375
376        Not served unless an operator started the engine with ``-audit-readers``
377        naming who may read it, because the record carries every caller's
378        activity. An engine with nobody named answers 404 and a caller who is
379        authenticated but not named gets 403, both as :class:`Refused`. Handle
380        both: this is one of the few calls that can fail for reasons that have
381        nothing to do with the request.
382
383        It is a bounded window rather than the whole record. An engine writing
384        an audit file keeps everything there.
385
386        Args:
387            limit: Most recent decisions to return, up to 200.
388            decision: Return only this decision. ``refused`` is the useful one.
389        """
390        params: dict[str, str] = {}
391        if limit is not None:
392            params["limit"] = str(limit)
393        if decision:
394            params["decision"] = decision
395        return AuditPage.parse(self._get("/v1/audit", params))
396
397    # ---------- what the engine says about itself ----------
398    #
399    # Everything here is metadata. None of it reads a row, which is why all but
400    # reload need only read:model, and why a pipeline that checks a model does
401    # not also need a credential that can read the warehouse.
402    #
403    # Four of them answer 404 rather than an empty success, and the difference
404    # matters enough to say once: an engine with no test suite is not an engine
405    # whose tests pass, an engine that has never reloaded is not an engine in
406    # which nothing changed, and an engine checking nothing on a timer has no
407    # history rather than a clean one. Each arrives as :class:`Refused`.
408
409    def doctor(self) -> Diagnosis:
410        """Ask the warehouse whether the model is still true.
411
412        Everything else validates the model against itself: the YAML parses,
413        the joins resolve, the expressions compile. None of that notices that
414        somebody dropped a column last Tuesday, and the first sign of that is
415        usually a caller getting an error, which is the most expensive place to
416        find it.
417
418        Reports rather than refuses, so an exception here means the check could
419        not run. Read :attr:`Diagnosis.ok` for the verdict and
420        :attr:`Diagnosis.skipped` for the case where nothing could be checked.
421        """
422        return Diagnosis.parse(self._get("/v1/doctor"))
423
424    def doctor_history(self) -> DoctorHistory:
425        """What the scheduled warehouse check has seen, oldest first.
426
427        Drift is found by looking regularly, not by looking once, which is how
428        "when did this start" stays answerable. An engine started without
429        ``-doctor-every`` has nothing scheduled and answers 404 as
430        :class:`Refused`.
431        """
432        return DoctorHistory.parse(self._get("/v1/doctor/history"))
433
434    def run_tests(self) -> TestReport:
435        """Assert what this model answers.
436
437        ``validate`` says the model holds together and :meth:`diff` says a
438        number changed. Neither says a number was ever right.
439
440        Check :attr:`TestReport.withheld` as well as ``ok``. A credential
441        without ``run:query`` cannot cause warehouse execution, so cases that
442        would are withheld and counted rather than run or silently dropped, and
443        a caller reading only ``ok`` would conclude a suite passed when half of
444        it never ran.
445        """
446        return TestReport.parse(self._post("/v1/tests", {}))
447
448    def policy(self) -> Policy:
449        """What this engine enforces, and what it does not.
450
451        Says nothing about who is allowed what. For that, and only about
452        yourself, use :meth:`explain_policy`.
453        """
454        return Policy.parse(self._get("/v1/policy"))
455
456    def explain_policy(self, metric: str) -> PolicyExplanation:
457        """What you may read of a metric, and why.
458
459        Answers "why can I not group by that column" without running a query
460        and being denied. For the calling identity only, which is a security
461        property rather than a limitation: an endpoint that reported another
462        identity's access would publish the policy it was configured to
463        enforce.
464
465        Args:
466            metric: The qualified name, as :meth:`metrics` reports it.
467        """
468        if not metric:
469            # Refused here rather than sent, so a caller who forgot the
470            # argument reads that instead of a 400 naming a field they did not
471            # write.
472            raise ValueError("name the metric to explain")
473        return PolicyExplanation.parse(self._post("/v1/policy/explain", {"metric": metric}))
474
475    def diff(self) -> Diff:
476        """Whether the last model reload moved a number.
477
478        This compares the model being served against the one served before it,
479        which is the comparison nobody can make from outside the process. An
480        engine that has served only one model answers 404 as :class:`Refused`,
481        because that is a different answer from nothing having changed and only
482        one of them is reassuring.
483        """
484        return Diff.parse(self._get("/v1/diff"))
485
486    def reload(self) -> str:
487        """Tell the engine to re-read its model source.
488
489        Returns ``reading`` or ``already running``. Both mean the caller got
490        what they asked for; treating the second as a failure would retry a
491        sync that is already under way.
492
493        It carries no model, deliberately: this means "look now", not "install
494        this". The engine already follows git, so the only thing this changes
495        is the wait. A method that accepted a model would be a second way into
496        production, one that skips the pull request, the checks and the diff
497        that reports which numbers move.
498
499        Needs the ``deploy:model`` scope, which ``read:model`` and
500        ``run:query`` never imply.
501
502        Returning does not mean the new model is serving. A sync is not
503        instant, and reporting a commit before the swap happened would be a
504        claim a pipeline then asserts as fact. Poll :meth:`health` and read
505        ``origin.commit`` to know when the new model is the one answering.
506
507        An engine reading from a path has nothing to re-read and answers 404. A
508        model that fails to load is not a failure of this call either: the
509        engine keeps serving the previous one and says so in a 502. Both are
510        :class:`Refused`.
511        """
512        payload = self._post("/v1/reload", {})
513        return str(payload.get("status", ""))
514
515    # ---------- internals ----------
516
517    @staticmethod
518    def _request_body(
519        metrics: Iterable[str],
520        dimensions: Iterable[str] | None,
521        filters: Iterable[Mapping[str, Any]] | None,
522        grain: str | None,
523        limit: int | None,
524        order_by: Iterable[Mapping[str, Any]] | None,
525    ) -> dict[str, Any]:
526        names = list(metrics)
527        if not names:
528            raise ValueError("at least one metric is required")
529        body: dict[str, Any] = {"metrics": names}
530        # Omitted rather than sent empty: the server rejects unknown and
531        # malformed fields, and an empty list is not the same as absent.
532        if dimensions:
533            body["dimensions"] = list(dimensions)
534        if filters:
535            body["filters"] = [dict(f) for f in filters]
536        if grain:
537            body["grain"] = grain
538        if limit is not None:
539            body["limit"] = limit
540        if order_by:
541            body["order_by"] = [dict(o) for o in order_by]
542        return body
543
544    def _headers(self) -> dict[str, str]:
545        headers = {"Accept": "application/json", "User-Agent": self.user_agent}
546        if self._token:
547            headers["Authorization"] = f"Bearer {self._token}"
548        return headers
549
550    def _get(self, path: str, params: Mapping[str, str] | None = None) -> dict[str, Any]:
551        url = self.base_url + path
552        if params:
553            url += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v})
554        return self._send(urllib.request.Request(url, headers=self._headers(), method="GET"))
555
556    def _post(self, path: str, body: Mapping[str, Any]) -> dict[str, Any]:
557        headers = self._headers()
558        headers["Content-Type"] = "application/json"
559        payload = json.dumps(body).encode("utf-8")
560        request = urllib.request.Request(
561            self.base_url + path, data=payload, headers=headers, method="POST"
562        )
563        return self._send(request)
564
565    def _delete(self, path: str) -> dict[str, Any]:
566        return self._send(
567            urllib.request.Request(self.base_url + path, headers=self._headers(), method="DELETE")
568        )
569
570    def _send(self, request: urllib.request.Request) -> dict[str, Any]:
571        try:
572            with urllib.request.urlopen(request, timeout=self.timeout) as response:
573                return self._decode(response.read(), response.status)
574        except urllib.error.HTTPError as exc:
575            # A refusal arrives as an error status with a JSON body. It is an
576            # answer, not a transport failure, so it is raised as Refused with
577            # the code and retry class intact.
578            raw = exc.read()
579            if exc.code == 401:
580                raise Unauthorized(self._message(raw, "credentials were not accepted")) from None
581            try:
582                payload = json.loads(raw.decode("utf-8"))
583            except (ValueError, UnicodeDecodeError):
584                raise TransportError(
585                    f"HTTP {exc.code} from {request.full_url}: {self._message(raw, exc.reason)}"
586                ) from None
587            if isinstance(payload, dict) and "code" in payload:
588                raise Refused.from_payload(payload, exc.code) from None
589            raise TransportError(f"HTTP {exc.code} from {request.full_url}: {payload}") from None
590        except urllib.error.URLError as exc:
591            raise TransportError(f"cannot reach {request.full_url}: {exc.reason}") from None
592        except TimeoutError:
593            raise TransportError(
594                f"{request.full_url} did not respond within {self.timeout:g}s"
595            ) from None
596
597    @staticmethod
598    def _decode(raw: bytes, status: int) -> dict[str, Any]:
599        try:
600            payload = json.loads(raw.decode("utf-8"))
601        except (ValueError, UnicodeDecodeError) as exc:
602            raise TransportError(f"HTTP {status} body was not JSON: {exc}") from None
603        if not isinstance(payload, dict):
604            raise TransportError(f"HTTP {status} body was not a JSON object")
605        return payload
606
607    @staticmethod
608    def _message(raw: bytes, fallback: Any) -> str:
609        text = raw.decode("utf-8", errors="replace").strip()
610        return text or str(fallback)
611
612    def __repr__(self) -> str:
613        return f"Client(base_url={self.base_url!r}, authenticated={bool(self._token)})"

A connection to one semantic engine.

Arguments:
  • base_url: Where the engine is served, for example http://127.0.0.1:8080.
  • token: Bearer token identifying the workload. Read it from the environment; never hard-code one. Omit it only against a local engine running without authentication.
  • timeout: Seconds to wait for a response.
  • user_agent: Overrides the identifying header, which is useful when you want the audit log to distinguish one agent from another.
Example:
>>> from truegrain import Client, filters
>>> c = Client("http://127.0.0.1:8080", token=os.environ["SEMANTIC_TOKEN"])
>>> result = c.query(
...     metrics=["sales.order_revenue"],
...     dimensions=["sales.customers.region"],
...     filters=[filters.eq("sales.orders.status", "shipped")],
... )
>>> result.to_dataframe()
Client( base_url: str, token: str | None = None, timeout: float = 60.0, user_agent: str = 'truegrain-python/1.0')
 96    def __init__(
 97        self,
 98        base_url: str,
 99        token: str | None = None,
100        timeout: float = DEFAULT_TIMEOUT,
101        user_agent: str = "truegrain-python/1.0",
102    ) -> None:
103        if not base_url:
104            raise ValueError("base_url is required")
105        self.base_url = base_url.rstrip("/")
106        self._token = token
107        self.timeout = timeout
108        self.user_agent = user_agent
base_url
timeout
user_agent
@classmethod
def from_env( cls, url_var: str = 'SEMANTIC_URL', token_var: str = 'SEMANTIC_TOKEN', **kwargs: Any) -> Client:
110    @classmethod
111    def from_env(
112        cls,
113        url_var: str = "SEMANTIC_URL",
114        token_var: str = "SEMANTIC_TOKEN",
115        **kwargs: Any,
116    ) -> "Client":
117        """Build a client from environment variables.
118
119        Keeps the token out of source and out of the process list, which is
120        where a token passed as a command-line flag ends up.
121        """
122        url = os.environ.get(url_var)
123        if not url:
124            raise SemanticError(f"{url_var} is not set")
125        return cls(url, token=os.environ.get(token_var), **kwargs)

Build a client from environment variables.

Keeps the token out of source and out of the process list, which is where a token passed as a command-line flag ends up.

def health(self) -> Health:
129    def health(self) -> Health:
130        """What this deployment enforces, including what it does not.
131
132        Worth reading before trusting the layer with anything sensitive:
133        ``health().enforcement_notes`` states the gaps in plain language.
134        """
135        return Health.parse(self._get("/v1/health"))

What this deployment enforces, including what it does not.

Worth reading before trusting the layer with anything sensitive: health().enforcement_notes states the gaps in plain language.

def model_version(self) -> str:
137    def model_version(self) -> str:
138        """The workspace digest currently served.
139
140        Every query result carries this too. Two results with the same digest
141        were produced by exactly the same definitions.
142        """
143        return str(self._get("/v1/model/version").get("model_version", ""))

The workspace digest currently served.

Every query result carries this too. Two results with the same digest were produced by exactly the same definitions.

def namespaces(self) -> list[Namespace]:
145    def namespaces(self) -> list[Namespace]:
146        """The namespaces in the workspace, their owners and availability."""
147        payload = self._get("/v1/namespaces")
148        return [Namespace.parse(n) for n in payload.get("namespaces", [])]

The namespaces in the workspace, their owners and availability.

def metrics(self, search: str | None = None) -> list[Metric]:
150    def metrics(self, search: str | None = None) -> list[Metric]:
151        """Every metric this identity may read.
152
153        Args:
154            search: Case-insensitive substring over name and description.
155        """
156        params = {"search": search} if search else None
157        payload = self._get("/v1/metrics", params)
158        return [Metric.parse(m) for m in payload.get("metrics", [])]

Every metric this identity may read.

Arguments:
  • search: Case-insensitive substring over name and description.
def metric(self, name: str) -> Metric:
160    def metric(self, name: str) -> Metric:
161        """One metric's definition and the dimensions legal for it.
162
163        Args:
164            name: Qualified as ``namespace.metric``, or bare when unambiguous
165                across the workspace.
166        """
167        if not name:
168            raise ValueError("metric name is required")
169        return Metric.parse(self._get(f"/v1/metrics/{urllib.parse.quote(name, safe='')}"))

One metric's definition and the dimensions legal for it.

Arguments:
  • name: Qualified as namespace.metric, or bare when unambiguous across the workspace.
def dimensions(self, metric: str | None = None) -> list[Dimension]:
171    def dimensions(self, metric: str | None = None) -> list[Dimension]:
172        """Dimensions available for grouping and filtering.
173
174        Args:
175            metric: Restrict to dimensions valid for this metric, which is
176                almost always what you want.
177        """
178        params = {"metric": metric} if metric else None
179        payload = self._get("/v1/dimensions", params)
180        return [Dimension.parse(d) for d in payload.get("dimensions", [])]

Dimensions available for grouping and filtering.

Arguments:
  • metric: Restrict to dimensions valid for this metric, which is almost always what you want.
def query( self, metrics: Iterable[str], dimensions: Optional[Iterable[str]] = None, filters: Optional[Iterable[Mapping[str, Any]]] = None, grain: str | None = None, limit: int | None = None, order_by: Optional[Iterable[Mapping[str, Any]]] = None) -> Result:
184    def query(
185        self,
186        metrics: Iterable[str],
187        dimensions: Iterable[str] | None = None,
188        filters: Iterable[Mapping[str, Any]] | None = None,
189        grain: str | None = None,
190        limit: int | None = None,
191        order_by: Iterable[Mapping[str, Any]] | None = None,
192    ) -> Result:
193        """Answer a question and return rows.
194
195        Args:
196            metrics: Metric names. Metrics at different grains, or in different
197                namespaces, are aggregated separately and joined on the shared
198                dimensions.
199            dimensions: ``dataset.field`` or ``namespace.dataset.field``.
200            filters: Structured filters. Use :mod:`truegrain.filters` to
201                build them.
202            grain: Time bucket for the selected time dimension.
203            limit: Maximum rows. Defaults to the server's limit.
204            order_by: ``[{"field": "order_revenue", "desc": True}]``.
205
206        Raises:
207            Refused: The engine declined. Check ``err.retry`` before retrying.
208        """
209        return Result.parse(
210            self._post("/v1/query", self._request_body(metrics, dimensions, filters, grain, limit, order_by))
211        )

Answer a question and return rows.

Arguments:
  • metrics: Metric names. Metrics at different grains, or in different namespaces, are aggregated separately and joined on the shared dimensions.
  • dimensions: dataset.field or namespace.dataset.field.
  • filters: Structured filters. Use truegrain.filters to build them.
  • grain: Time bucket for the selected time dimension.
  • limit: Maximum rows. Defaults to the server's limit.
  • order_by: [{"field": "order_revenue", "desc": True}].
Raises:
  • Refused: The engine declined. Check err.retry before retrying.
def compile( self, metrics: Iterable[str], dimensions: Optional[Iterable[str]] = None, filters: Optional[Iterable[Mapping[str, Any]]] = None, grain: str | None = None, limit: int | None = None, order_by: Optional[Iterable[Mapping[str, Any]]] = None) -> Compiled:
213    def compile(
214        self,
215        metrics: Iterable[str],
216        dimensions: Iterable[str] | None = None,
217        filters: Iterable[Mapping[str, Any]] | None = None,
218        grain: str | None = None,
219        limit: int | None = None,
220        order_by: Iterable[Mapping[str, Any]] | None = None,
221    ) -> Compiled:
222        """Return the SQL a request compiles to, without running it.
223
224        A dry run is still governed: a request you may not run returns a refusal
225        and no SQL, and the inspection is audited. It is a way to see what a
226        query would do, not a way around the gate.
227        """
228        return Compiled.parse(
229            self._post("/v1/compile", self._request_body(metrics, dimensions, filters, grain, limit, order_by))
230        )

Return the SQL a request compiles to, without running it.

A dry run is still governed: a request you may not run returns a refusal and no SQL, and the inspection is audited. It is a way to see what a query would do, not a way around the gate.

def run( self, metrics: Iterable[str], dimensions: Optional[Iterable[str]] = None, filters: Optional[Iterable[Mapping[str, Any]]] = None, grain: str | None = None, limit: int | None = None, order_by: Optional[Iterable[Mapping[str, Any]]] = None, max_wait: float = 900.0, page_size: int | None = None) -> Result:
234    def run(
235        self,
236        metrics: Iterable[str],
237        dimensions: Iterable[str] | None = None,
238        filters: Iterable[Mapping[str, Any]] | None = None,
239        grain: str | None = None,
240        limit: int | None = None,
241        order_by: Iterable[Mapping[str, Any]] | None = None,
242        max_wait: float = 900.0,
243        page_size: int | None = None,
244    ) -> Result:
245        """Run a query that may take longer than an HTTP request should.
246
247        Submits the query, polls until it finishes, collects every page, and
248        returns one :class:`Result`. Use this instead of :meth:`query` whenever
249        the warehouse might be slow: a large BigQuery scan routinely outlives
250        the default timeout of an agent framework, and a synchronous call that
251        times out leaves the query running and billable with nobody reading it.
252
253        The governance gate runs during submission, so a request this identity
254        may not make raises :class:`Refused` immediately rather than after a
255        wait.
256
257        Args:
258            max_wait: Seconds to wait before giving up. On expiry the job is
259                cancelled rather than left running, so an abandoned wait does
260                not keep spending warehouse time.
261            page_size: Rows per page while collecting. The default suits most
262                results; lower it when rows are wide.
263
264        Raises:
265            Refused: The engine declined, or the query failed. Check
266                ``err.retry`` before retrying.
267            SemanticError: The wait expired, or the job was cancelled.
268        """
269        job = self.submit(metrics, dimensions, filters, grain, limit, order_by)
270        job = self.wait(job.job_id, max_wait=max_wait, page_size=page_size)
271        job.raise_for_state()
272
273        # Walk the remaining pages. row_count is the size of the whole result,
274        # so it bounds the walk without the loop having to trust the cursor to
275        # eventually come back empty.
276        rows = list(job.rows)
277        cursor = job.next_cursor
278        total = job.row_count or None
279        while cursor and (total is None or len(rows) < total):
280            page = self.job(job.job_id, cursor=cursor, page_size=page_size)
281            if not page.rows:
282                break
283            rows.extend(page.rows)
284            cursor = page.next_cursor
285        return job.as_result(tuple(rows))

Run a query that may take longer than an HTTP request should.

Submits the query, polls until it finishes, collects every page, and returns one Result. Use this instead of query() whenever the warehouse might be slow: a large BigQuery scan routinely outlives the default timeout of an agent framework, and a synchronous call that times out leaves the query running and billable with nobody reading it.

The governance gate runs during submission, so a request this identity may not make raises Refused immediately rather than after a wait.

Arguments:
  • max_wait: Seconds to wait before giving up. On expiry the job is cancelled rather than left running, so an abandoned wait does not keep spending warehouse time.
  • page_size: Rows per page while collecting. The default suits most results; lower it when rows are wide.
Raises:
  • Refused: The engine declined, or the query failed. Check err.retry before retrying.
  • SemanticError: The wait expired, or the job was cancelled.
def submit( self, metrics: Iterable[str], dimensions: Optional[Iterable[str]] = None, filters: Optional[Iterable[Mapping[str, Any]]] = None, grain: str | None = None, limit: int | None = None, order_by: Optional[Iterable[Mapping[str, Any]]] = None) -> Job:
287    def submit(
288        self,
289        metrics: Iterable[str],
290        dimensions: Iterable[str] | None = None,
291        filters: Iterable[Mapping[str, Any]] | None = None,
292        grain: str | None = None,
293        limit: int | None = None,
294        order_by: Iterable[Mapping[str, Any]] | None = None,
295    ) -> Job:
296        """Start a query in the background and return immediately.
297
298        The returned job already carries ``compiled_sql``, because compilation
299        happened synchronously. A job coming back at all means the query was
300        authorized, not merely accepted.
301
302        Prefer :meth:`run` unless you genuinely need to do something else while
303        the query runs.
304        """
305        return Job.parse(
306            self._post(
307                "/v1/jobs",
308                self._request_body(metrics, dimensions, filters, grain, limit, order_by),
309            )
310        )

Start a query in the background and return immediately.

The returned job already carries compiled_sql, because compilation happened synchronously. A job coming back at all means the query was authorized, not merely accepted.

Prefer run() unless you genuinely need to do something else while the query runs.

def job( self, job_id: str, cursor: str | None = None, page_size: int | None = None) -> Job:
312    def job(self, job_id: str, cursor: str | None = None, page_size: int | None = None) -> Job:
313        """Poll a job and read one page of its rows.
314
315        Args:
316            cursor: From a previous page's ``next_cursor``. Omit for the first.
317            page_size: Rows in this page.
318        """
319        if not job_id:
320            raise ValueError("job_id is required")
321        params: dict[str, str] = {}
322        if cursor:
323            params["cursor"] = cursor
324        if page_size is not None:
325            params["page_size"] = str(page_size)
326        return Job.parse(
327            self._get(f"/v1/jobs/{urllib.parse.quote(job_id, safe='')}", params or None)
328        )

Poll a job and read one page of its rows.

Arguments:
  • cursor: From a previous page's next_cursor. Omit for the first.
  • page_size: Rows in this page.
def cancel_job(self, job_id: str) -> Job:
330    def cancel_job(self, job_id: str) -> Job:
331        """Stop a running query. Idempotent."""
332        if not job_id:
333            raise ValueError("job_id is required")
334        return Job.parse(
335            self._delete(f"/v1/jobs/{urllib.parse.quote(job_id, safe='')}")
336        )

Stop a running query. Idempotent.

def wait( self, job_id: str, max_wait: float = 900.0, page_size: int | None = None) -> Job:
338    def wait(
339        self,
340        job_id: str,
341        max_wait: float = 900.0,
342        page_size: int | None = None,
343    ) -> Job:
344        """Poll until a job reaches a terminal state.
345
346        Backs off from a fast first poll to a two second ceiling, so a query
347        that finishes quickly is not delayed and a slow one is not hammered.
348
349        On expiry the job is cancelled before raising, so giving up here also
350        stops the warehouse work.
351        """
352        deadline = time.monotonic() + max_wait
353        interval = 0.25
354        while True:
355            job = self.job(job_id, page_size=page_size)
356            if job.is_done:
357                return job
358            if time.monotonic() >= deadline:
359                # Leaving it running would keep spending warehouse time for an
360                # answer this caller has already stopped waiting for.
361                try:
362                    self.cancel_job(job_id)
363                except SemanticError:
364                    pass
365                raise SemanticError(
366                    f"job {job_id} did not finish within {max_wait:g}s and was cancelled"
367                )
368            time.sleep(min(interval, max(0.0, deadline - time.monotonic())))
369            interval = min(interval * 1.5, 2.0)

Poll until a job reaches a terminal state.

Backs off from a fast first poll to a two second ceiling, so a query that finishes quickly is not delayed and a slow one is not hammered.

On expiry the job is cancelled before raising, so giving up here also stops the warehouse work.

def audit( self, limit: int | None = None, decision: str | None = None) -> AuditPage:
373    def audit(self, limit: int | None = None, decision: str | None = None) -> AuditPage:
374        """Read the decisions the engine recently made, newest first.
375
376        Not served unless an operator started the engine with ``-audit-readers``
377        naming who may read it, because the record carries every caller's
378        activity. An engine with nobody named answers 404 and a caller who is
379        authenticated but not named gets 403, both as :class:`Refused`. Handle
380        both: this is one of the few calls that can fail for reasons that have
381        nothing to do with the request.
382
383        It is a bounded window rather than the whole record. An engine writing
384        an audit file keeps everything there.
385
386        Args:
387            limit: Most recent decisions to return, up to 200.
388            decision: Return only this decision. ``refused`` is the useful one.
389        """
390        params: dict[str, str] = {}
391        if limit is not None:
392            params["limit"] = str(limit)
393        if decision:
394            params["decision"] = decision
395        return AuditPage.parse(self._get("/v1/audit", params))

Read the decisions the engine recently made, newest first.

Not served unless an operator started the engine with -audit-readers naming who may read it, because the record carries every caller's activity. An engine with nobody named answers 404 and a caller who is authenticated but not named gets 403, both as Refused. Handle both: this is one of the few calls that can fail for reasons that have nothing to do with the request.

It is a bounded window rather than the whole record. An engine writing an audit file keeps everything there.

Arguments:
  • limit: Most recent decisions to return, up to 200.
  • decision: Return only this decision. refused is the useful one.
def doctor(self) -> Diagnosis:
409    def doctor(self) -> Diagnosis:
410        """Ask the warehouse whether the model is still true.
411
412        Everything else validates the model against itself: the YAML parses,
413        the joins resolve, the expressions compile. None of that notices that
414        somebody dropped a column last Tuesday, and the first sign of that is
415        usually a caller getting an error, which is the most expensive place to
416        find it.
417
418        Reports rather than refuses, so an exception here means the check could
419        not run. Read :attr:`Diagnosis.ok` for the verdict and
420        :attr:`Diagnosis.skipped` for the case where nothing could be checked.
421        """
422        return Diagnosis.parse(self._get("/v1/doctor"))

Ask the warehouse whether the model is still true.

Everything else validates the model against itself: the YAML parses, the joins resolve, the expressions compile. None of that notices that somebody dropped a column last Tuesday, and the first sign of that is usually a caller getting an error, which is the most expensive place to find it.

Reports rather than refuses, so an exception here means the check could not run. Read Diagnosis.ok for the verdict and Diagnosis.skipped for the case where nothing could be checked.

def doctor_history(self) -> DoctorHistory:
424    def doctor_history(self) -> DoctorHistory:
425        """What the scheduled warehouse check has seen, oldest first.
426
427        Drift is found by looking regularly, not by looking once, which is how
428        "when did this start" stays answerable. An engine started without
429        ``-doctor-every`` has nothing scheduled and answers 404 as
430        :class:`Refused`.
431        """
432        return DoctorHistory.parse(self._get("/v1/doctor/history"))

What the scheduled warehouse check has seen, oldest first.

Drift is found by looking regularly, not by looking once, which is how "when did this start" stays answerable. An engine started without -doctor-every has nothing scheduled and answers 404 as Refused.

def run_tests(self) -> TestReport:
434    def run_tests(self) -> TestReport:
435        """Assert what this model answers.
436
437        ``validate`` says the model holds together and :meth:`diff` says a
438        number changed. Neither says a number was ever right.
439
440        Check :attr:`TestReport.withheld` as well as ``ok``. A credential
441        without ``run:query`` cannot cause warehouse execution, so cases that
442        would are withheld and counted rather than run or silently dropped, and
443        a caller reading only ``ok`` would conclude a suite passed when half of
444        it never ran.
445        """
446        return TestReport.parse(self._post("/v1/tests", {}))

Assert what this model answers.

validate says the model holds together and diff() says a number changed. Neither says a number was ever right.

Check TestReport.withheld as well as ok. A credential without run:query cannot cause warehouse execution, so cases that would are withheld and counted rather than run or silently dropped, and a caller reading only ok would conclude a suite passed when half of it never ran.

def policy(self) -> Policy:
448    def policy(self) -> Policy:
449        """What this engine enforces, and what it does not.
450
451        Says nothing about who is allowed what. For that, and only about
452        yourself, use :meth:`explain_policy`.
453        """
454        return Policy.parse(self._get("/v1/policy"))

What this engine enforces, and what it does not.

Says nothing about who is allowed what. For that, and only about yourself, use explain_policy().

def explain_policy(self, metric: str) -> PolicyExplanation:
456    def explain_policy(self, metric: str) -> PolicyExplanation:
457        """What you may read of a metric, and why.
458
459        Answers "why can I not group by that column" without running a query
460        and being denied. For the calling identity only, which is a security
461        property rather than a limitation: an endpoint that reported another
462        identity's access would publish the policy it was configured to
463        enforce.
464
465        Args:
466            metric: The qualified name, as :meth:`metrics` reports it.
467        """
468        if not metric:
469            # Refused here rather than sent, so a caller who forgot the
470            # argument reads that instead of a 400 naming a field they did not
471            # write.
472            raise ValueError("name the metric to explain")
473        return PolicyExplanation.parse(self._post("/v1/policy/explain", {"metric": metric}))

What you may read of a metric, and why.

Answers "why can I not group by that column" without running a query and being denied. For the calling identity only, which is a security property rather than a limitation: an endpoint that reported another identity's access would publish the policy it was configured to enforce.

Arguments:
  • metric: The qualified name, as metrics() reports it.
def diff(self) -> Diff:
475    def diff(self) -> Diff:
476        """Whether the last model reload moved a number.
477
478        This compares the model being served against the one served before it,
479        which is the comparison nobody can make from outside the process. An
480        engine that has served only one model answers 404 as :class:`Refused`,
481        because that is a different answer from nothing having changed and only
482        one of them is reassuring.
483        """
484        return Diff.parse(self._get("/v1/diff"))

Whether the last model reload moved a number.

This compares the model being served against the one served before it, which is the comparison nobody can make from outside the process. An engine that has served only one model answers 404 as Refused, because that is a different answer from nothing having changed and only one of them is reassuring.

def reload(self) -> str:
486    def reload(self) -> str:
487        """Tell the engine to re-read its model source.
488
489        Returns ``reading`` or ``already running``. Both mean the caller got
490        what they asked for; treating the second as a failure would retry a
491        sync that is already under way.
492
493        It carries no model, deliberately: this means "look now", not "install
494        this". The engine already follows git, so the only thing this changes
495        is the wait. A method that accepted a model would be a second way into
496        production, one that skips the pull request, the checks and the diff
497        that reports which numbers move.
498
499        Needs the ``deploy:model`` scope, which ``read:model`` and
500        ``run:query`` never imply.
501
502        Returning does not mean the new model is serving. A sync is not
503        instant, and reporting a commit before the swap happened would be a
504        claim a pipeline then asserts as fact. Poll :meth:`health` and read
505        ``origin.commit`` to know when the new model is the one answering.
506
507        An engine reading from a path has nothing to re-read and answers 404. A
508        model that fails to load is not a failure of this call either: the
509        engine keeps serving the previous one and says so in a 502. Both are
510        :class:`Refused`.
511        """
512        payload = self._post("/v1/reload", {})
513        return str(payload.get("status", ""))

Tell the engine to re-read its model source.

Returns reading or already running. Both mean the caller got what they asked for; treating the second as a failure would retry a sync that is already under way.

It carries no model, deliberately: this means "look now", not "install this". The engine already follows git, so the only thing this changes is the wait. A method that accepted a model would be a second way into production, one that skips the pull request, the checks and the diff that reports which numbers move.

Needs the deploy:model scope, which read:model and run:query never imply.

Returning does not mean the new model is serving. A sync is not instant, and reporting a commit before the swap happened would be a claim a pipeline then asserts as fact. Poll health() and read origin.commit to know when the new model is the one answering.

An engine reading from a path has nothing to re-read and answers 404. A model that fails to load is not a failure of this call either: the engine keeps serving the previous one and says so in a 502. Both are Refused.

@dataclass(frozen=True)
class Compiled:
151@dataclass(frozen=True)
152class Compiled:
153    """The SQL a request compiles to, from a dry run."""
154
155    compiled_sql: str
156    columns: tuple[str, ...]
157    dialect: str
158    model_version: str
159    namespace: str = ""
160    #: How many facts were aggregated separately and joined. More than one means
161    #: the query spanned grains or namespaces.
162    parts: int = 1
163    raw: dict[str, Any] = field(default_factory=dict, repr=False)
164
165    @classmethod
166    def parse(cls, payload: dict[str, Any]) -> "Compiled":
167        return cls(
168            compiled_sql=_get(payload, "compiled_sql", ""),
169            columns=tuple(_get(payload, "columns", [])),
170            dialect=_get(payload, "dialect", ""),
171            model_version=_get(payload, "model_version", ""),
172            namespace=_get(payload, "namespace", ""),
173            parts=int(_get(payload, "parts", 1)),
174            raw=payload,
175        )

The SQL a request compiles to, from a dry run.

Compiled( compiled_sql: str, columns: tuple[str, ...], dialect: str, model_version: str, namespace: str = '', parts: int = 1, raw: dict[str, typing.Any] = <factory>)
compiled_sql: str
columns: tuple[str, ...]
dialect: str
model_version: str
namespace: str = ''
parts: int = 1
raw: dict[str, typing.Any]
@classmethod
def parse(cls, payload: dict[str, typing.Any]) -> Compiled:
165    @classmethod
166    def parse(cls, payload: dict[str, Any]) -> "Compiled":
167        return cls(
168            compiled_sql=_get(payload, "compiled_sql", ""),
169            columns=tuple(_get(payload, "columns", [])),
170            dialect=_get(payload, "dialect", ""),
171            model_version=_get(payload, "model_version", ""),
172            namespace=_get(payload, "namespace", ""),
173            parts=int(_get(payload, "parts", 1)),
174            raw=payload,
175        )
@dataclass(frozen=True)
class Diagnosis:
548@dataclass(frozen=True)
549class Diagnosis:
550    """What the warehouse says about the model right now.
551
552    It reports rather than refuses: a model can be wrong in ways that do not
553    matter yet, and which of those to act on is a person's decision.
554    """
555
556    #: False when a finding would break a query. Not the same as having no
557    #: findings.
558    ok: bool = True
559    tables_checked: int = 0
560    findings: tuple[Finding, ...] = ()
561    #: Why nothing was checked, when nothing was. A Diagnosis with no findings
562    #: and ``skipped`` set is the engine saying it could not look, not saying
563    #: everything is fine.
564    skipped: str = ""
565    raw: dict[str, Any] = field(default_factory=dict, repr=False)
566
567    @classmethod
568    def parse(cls, payload: dict[str, Any]) -> "Diagnosis":
569        return cls(
570            ok=bool(_get(payload, "ok", True)),
571            tables_checked=int(_get(payload, "tables_checked", 0)),
572            findings=tuple(Finding.parse(f) for f in _get(payload, "findings", [])),
573            skipped=_get(payload, "skipped", ""),
574            raw=payload,
575        )
576
577    def errors(self) -> tuple[Finding, ...]:
578        """Only the findings that will break a query."""
579        return tuple(f for f in self.findings if f.severity == "error")

What the warehouse says about the model right now.

It reports rather than refuses: a model can be wrong in ways that do not matter yet, and which of those to act on is a person's decision.

Diagnosis( ok: bool = True, tables_checked: int = 0, findings: tuple[Finding, ...] = (), skipped: str = '', raw: dict[str, typing.Any] = <factory>)
ok: bool = True
tables_checked: int = 0
findings: tuple[Finding, ...] = ()
skipped: str = ''
raw: dict[str, typing.Any]
@classmethod
def parse(cls, payload: dict[str, typing.Any]) -> Diagnosis:
567    @classmethod
568    def parse(cls, payload: dict[str, Any]) -> "Diagnosis":
569        return cls(
570            ok=bool(_get(payload, "ok", True)),
571            tables_checked=int(_get(payload, "tables_checked", 0)),
572            findings=tuple(Finding.parse(f) for f in _get(payload, "findings", [])),
573            skipped=_get(payload, "skipped", ""),
574            raw=payload,
575        )
def errors(self) -> tuple[Finding, ...]:
577    def errors(self) -> tuple[Finding, ...]:
578        """Only the findings that will break a query."""
579        return tuple(f for f in self.findings if f.severity == "error")

Only the findings that will break a query.

@dataclass(frozen=True)
class Diff:
770@dataclass(frozen=True)
771class Diff:
772    """What the last reload moved.
773
774    Compared on compiled SQL rather than on model text, because that is where a
775    silent correctness incident lives: the model still validates, the tests
776    still pass, and every dashboard quietly moves. Renaming a description does
777    not appear here; changing a join, a grain or an expression does.
778    """
779
780    changed: bool = False
781    #: ``from`` on the wire. Renamed here because ``from`` is a keyword.
782    from_version: str = ""
783    to_version: str = ""
784    added: tuple[str, ...] = ()
785    removed: tuple[str, ...] = ()
786    #: Request label to its SQL before and after.
787    altered: dict[str, Change] = field(default_factory=dict)
788    raw: dict[str, Any] = field(default_factory=dict, repr=False)
789
790    @classmethod
791    def parse(cls, payload: dict[str, Any]) -> "Diff":
792        return cls(
793            changed=bool(_get(payload, "changed", False)),
794            from_version=_get(payload, "from", ""),
795            to_version=_get(payload, "to", ""),
796            added=tuple(_get(payload, "added", [])),
797            removed=tuple(_get(payload, "removed", [])),
798            altered={k: Change.parse(v) for k, v in _get(payload, "altered", {}).items()},
799            raw=payload,
800        )

What the last reload moved.

Compared on compiled SQL rather than on model text, because that is where a silent correctness incident lives: the model still validates, the tests still pass, and every dashboard quietly moves. Renaming a description does not appear here; changing a join, a grain or an expression does.

Diff( changed: bool = False, from_version: str = '', to_version: str = '', added: tuple[str, ...] = (), removed: tuple[str, ...] = (), altered: dict[str, Change] = <factory>, raw: dict[str, typing.Any] = <factory>)
changed: bool = False
from_version: str = ''
to_version: str = ''
added: tuple[str, ...] = ()
removed: tuple[str, ...] = ()
altered: dict[str, Change]
raw: dict[str, typing.Any]
@classmethod
def parse(cls, payload: dict[str, typing.Any]) -> Diff:
790    @classmethod
791    def parse(cls, payload: dict[str, Any]) -> "Diff":
792        return cls(
793            changed=bool(_get(payload, "changed", False)),
794            from_version=_get(payload, "from", ""),
795            to_version=_get(payload, "to", ""),
796            added=tuple(_get(payload, "added", [])),
797            removed=tuple(_get(payload, "removed", [])),
798            altered={k: Change.parse(v) for k, v in _get(payload, "altered", {}).items()},
799            raw=payload,
800        )
@dataclass(frozen=True)
class Dimension:
 83@dataclass(frozen=True)
 84class Dimension:
 85    """A field usable for grouping and filtering, named `namespace.dataset.field`."""
 86
 87    name: str
 88    is_time: bool
 89    namespace: str = ""
 90    description: str = ""
 91    datatype: str = ""
 92    synonyms: tuple[str, ...] = ()
 93    #: Time buckets available, present only for a time dimension.
 94    grains: tuple[str, ...] = ()
 95    raw: dict[str, Any] = field(default_factory=dict, repr=False)
 96
 97    @classmethod
 98    def parse(cls, payload: dict[str, Any]) -> "Dimension":
 99        return cls(
100            name=_get(payload, "name", ""),
101            is_time=bool(_get(payload, "is_time", False)),
102            namespace=_get(payload, "namespace", ""),
103            description=_get(payload, "description", ""),
104            datatype=_get(payload, "datatype", ""),
105            synonyms=tuple(_get(payload, "synonyms", [])),
106            grains=tuple(_get(payload, "grains", [])),
107            raw=payload,
108        )

A field usable for grouping and filtering, named namespace.dataset.field.

Dimension( name: str, is_time: bool, namespace: str = '', description: str = '', datatype: str = '', synonyms: tuple[str, ...] = (), grains: tuple[str, ...] = (), raw: dict[str, typing.Any] = <factory>)
name: str
is_time: bool
namespace: str = ''
description: str = ''
datatype: str = ''
synonyms: tuple[str, ...] = ()
grains: tuple[str, ...] = ()
raw: dict[str, typing.Any]
@classmethod
def parse(cls, payload: dict[str, typing.Any]) -> Dimension:
 97    @classmethod
 98    def parse(cls, payload: dict[str, Any]) -> "Dimension":
 99        return cls(
100            name=_get(payload, "name", ""),
101            is_time=bool(_get(payload, "is_time", False)),
102            namespace=_get(payload, "namespace", ""),
103            description=_get(payload, "description", ""),
104            datatype=_get(payload, "datatype", ""),
105            synonyms=tuple(_get(payload, "synonyms", [])),
106            grains=tuple(_get(payload, "grains", [])),
107            raw=payload,
108        )
@dataclass(frozen=True)
class DoctorHistory:
612@dataclass(frozen=True)
613class DoctorHistory:
614    """What the scheduled check has seen, oldest first."""
615
616    runs: tuple[DoctorRun, ...] = ()
617    #: The configured interval, which is how a reader tells a gap from a check
618    #: that has simply not come round yet.
619    every_seconds: int = 0
620    #: Runs that completed and found the warehouse changed.
621    drifted: int = 0
622    raw: dict[str, Any] = field(default_factory=dict, repr=False)
623
624    @classmethod
625    def parse(cls, payload: dict[str, Any]) -> "DoctorHistory":
626        return cls(
627            runs=tuple(DoctorRun.parse(r) for r in _get(payload, "runs", [])),
628            every_seconds=int(_get(payload, "every_seconds", 0)),
629            drifted=int(_get(payload, "drifted", 0)),
630            raw=payload,
631        )
632
633    def __iter__(self) -> Iterator[DoctorRun]:
634        return iter(self.runs)
635
636    def __len__(self) -> int:
637        return len(self.runs)

What the scheduled check has seen, oldest first.

DoctorHistory( runs: tuple[DoctorRun, ...] = (), every_seconds: int = 0, drifted: int = 0, raw: dict[str, typing.Any] = <factory>)
runs: tuple[DoctorRun, ...] = ()
every_seconds: int = 0
drifted: int = 0
raw: dict[str, typing.Any]
@classmethod
def parse(cls, payload: dict[str, typing.Any]) -> DoctorHistory:
624    @classmethod
625    def parse(cls, payload: dict[str, Any]) -> "DoctorHistory":
626        return cls(
627            runs=tuple(DoctorRun.parse(r) for r in _get(payload, "runs", [])),
628            every_seconds=int(_get(payload, "every_seconds", 0)),
629            drifted=int(_get(payload, "drifted", 0)),
630            raw=payload,
631        )
@dataclass(frozen=True)
class DoctorRun:
582@dataclass(frozen=True)
583class DoctorRun:
584    """One scheduled check."""
585
586    #: RFC 3339.
587    at: str = ""
588    ok: bool = True
589    tables_checked: int = 0
590    findings: int = 0
591    #: Set when the check could not run at all, which is a different thing from
592    #: running and finding something wrong.
593    error: str = ""
594    #: Ties the result to what was being served, so a run from before a reload
595    #: is not read as evidence about the model after it.
596    model_version: str = ""
597    raw: dict[str, Any] = field(default_factory=dict, repr=False)
598
599    @classmethod
600    def parse(cls, payload: dict[str, Any]) -> "DoctorRun":
601        return cls(
602            at=_get(payload, "at", ""),
603            ok=bool(_get(payload, "ok", True)),
604            tables_checked=int(_get(payload, "tables_checked", 0)),
605            findings=int(_get(payload, "findings", 0)),
606            error=_get(payload, "error", ""),
607            model_version=_get(payload, "model_version", ""),
608            raw=payload,
609        )

One scheduled check.

DoctorRun( at: str = '', ok: bool = True, tables_checked: int = 0, findings: int = 0, error: str = '', model_version: str = '', raw: dict[str, typing.Any] = <factory>)
at: str = ''
ok: bool = True
tables_checked: int = 0
findings: int = 0
error: str = ''
model_version: str = ''
raw: dict[str, typing.Any]
@classmethod
def parse(cls, payload: dict[str, typing.Any]) -> DoctorRun:
599    @classmethod
600    def parse(cls, payload: dict[str, Any]) -> "DoctorRun":
601        return cls(
602            at=_get(payload, "at", ""),
603            ok=bool(_get(payload, "ok", True)),
604            tables_checked=int(_get(payload, "tables_checked", 0)),
605            findings=int(_get(payload, "findings", 0)),
606            error=_get(payload, "error", ""),
607            model_version=_get(payload, "model_version", ""),
608            raw=payload,
609        )
@dataclass(frozen=True)
class Finding:
516@dataclass(frozen=True)
517class Finding:
518    """One thing the warehouse disagrees with the model about."""
519
520    #: ``error`` for something that will break a query, ``warning`` for
521    #: something that will not break today.
522    severity: str = ""
523    #: What it is about, in model terms.
524    dataset: str = ""
525    #: ``field`` on the wire. Renamed here because this module already uses
526    #: that name for dataclasses.field.
527    field_name: str = ""
528    #: The physical table, for somebody about to go and look.
529    source: str = ""
530    message: str = ""
531    #: What to do, when there is something to do.
532    hint: str = ""
533    raw: dict[str, Any] = field(default_factory=dict, repr=False)
534
535    @classmethod
536    def parse(cls, payload: dict[str, Any]) -> "Finding":
537        return cls(
538            severity=_get(payload, "severity", ""),
539            dataset=_get(payload, "dataset", ""),
540            field_name=_get(payload, "field", ""),
541            source=_get(payload, "source", ""),
542            message=_get(payload, "message", ""),
543            hint=_get(payload, "hint", ""),
544            raw=payload,
545        )

One thing the warehouse disagrees with the model about.

Finding( severity: str = '', dataset: str = '', field_name: str = '', source: str = '', message: str = '', hint: str = '', raw: dict[str, typing.Any] = <factory>)
severity: str = ''
dataset: str = ''
field_name: str = ''
source: str = ''
message: str = ''
hint: str = ''
raw: dict[str, typing.Any]
@classmethod
def parse(cls, payload: dict[str, typing.Any]) -> Finding:
535    @classmethod
536    def parse(cls, payload: dict[str, Any]) -> "Finding":
537        return cls(
538            severity=_get(payload, "severity", ""),
539            dataset=_get(payload, "dataset", ""),
540            field_name=_get(payload, "field", ""),
541            source=_get(payload, "source", ""),
542            message=_get(payload, "message", ""),
543            hint=_get(payload, "hint", ""),
544            raw=payload,
545        )
@dataclass(frozen=True)
class Health:
111@dataclass(frozen=True)
112class Health:
113    """What a deployment enforces, including what it does not."""
114
115    workspace: str
116    workspace_digest: str
117    executor: str = ""
118    metric_count: int = 0
119    dimension_count: int = 0
120    namespaces: tuple[Namespace, ...] = ()
121    #: Plain-language statements of what this configuration does NOT enforce.
122    #: Read them before trusting the layer with anything sensitive.
123    enforcement_notes: tuple[str, ...] = ()
124    #: Where the served model came from, or ``None`` when the engine read it
125    #: from a path. ``None`` is the answer to "is this deployment under version
126    #: control", so check it before reading through it.
127    origin: "Origin | None" = None
128    raw: dict[str, Any] = field(default_factory=dict, repr=False)
129
130    @classmethod
131    def parse(cls, payload: dict[str, Any]) -> "Health":
132        origin = payload.get("origin")
133        return cls(
134            workspace=_get(payload, "workspace", ""),
135            workspace_digest=_get(payload, "workspace_digest", ""),
136            executor=_get(payload, "executor", ""),
137            metric_count=int(_get(payload, "metric_count", 0)),
138            dimension_count=int(_get(payload, "dimension_count", 0)),
139            namespaces=tuple(Namespace.parse(n) for n in _get(payload, "namespaces", [])),
140            enforcement_notes=tuple(_get(payload, "enforcement_notes", [])),
141            origin=Origin.parse(origin) if origin else None,
142            raw=payload,
143        )
144
145    @property
146    def governs_columns(self) -> bool:
147        """Whether column-level access control is actually configured."""
148        return bool(self.raw.get("governance", {}).get("column_level", False))

What a deployment enforces, including what it does not.

Health( workspace: str, workspace_digest: str, executor: str = '', metric_count: int = 0, dimension_count: int = 0, namespaces: tuple[Namespace, ...] = (), enforcement_notes: tuple[str, ...] = (), origin: Origin | None = None, raw: dict[str, typing.Any] = <factory>)
workspace: str
workspace_digest: str
executor: str = ''
metric_count: int = 0
dimension_count: int = 0
namespaces: tuple[Namespace, ...] = ()
enforcement_notes: tuple[str, ...] = ()
origin: Origin | None = None
raw: dict[str, typing.Any]
@classmethod
def parse(cls, payload: dict[str, typing.Any]) -> Health:
130    @classmethod
131    def parse(cls, payload: dict[str, Any]) -> "Health":
132        origin = payload.get("origin")
133        return cls(
134            workspace=_get(payload, "workspace", ""),
135            workspace_digest=_get(payload, "workspace_digest", ""),
136            executor=_get(payload, "executor", ""),
137            metric_count=int(_get(payload, "metric_count", 0)),
138            dimension_count=int(_get(payload, "dimension_count", 0)),
139            namespaces=tuple(Namespace.parse(n) for n in _get(payload, "namespaces", [])),
140            enforcement_notes=tuple(_get(payload, "enforcement_notes", [])),
141            origin=Origin.parse(origin) if origin else None,
142            raw=payload,
143        )
governs_columns: bool
145    @property
146    def governs_columns(self) -> bool:
147        """Whether column-level access control is actually configured."""
148        return bool(self.raw.get("governance", {}).get("column_level", False))

Whether column-level access control is actually configured.

@dataclass(frozen=True)
class Job:
246@dataclass(frozen=True)
247class Job:
248    """One asynchronously executing query.
249
250    Exists because a warehouse query can run longer than an agent's HTTP
251    timeout. ``state`` is the field to branch on: ``running`` is the only
252    non-terminal one, and ``cancelled`` is distinct from ``failed`` because the
253    caller stopped it rather than the warehouse failing.
254
255    A job is visible only to the identity that submitted it.
256    """
257
258    job_id: str
259    state: str
260    #: Available from the moment of submission: compilation, and therefore the
261    #: governance gate, runs synchronously before the job exists.
262    compiled_sql: str = ""
263    columns: tuple[str, ...] = ()
264    #: One page of rows. Use :meth:`Client.run` to collect every page.
265    rows: tuple[tuple[Cell, ...], ...] = ()
266    #: Rows in the whole result, not in this page.
267    row_count: int = 0
268    #: Present when more rows remain. Pass it back as ``cursor``.
269    next_cursor: str = ""
270    model_version: str = ""
271    namespace: str = ""
272    dialect: str = ""
273    #: Set only when the job failed. Same vocabulary as a synchronous refusal.
274    code: str = ""
275    reason: str = ""
276    hint: str = ""
277    retry: str = ""
278    raw: dict[str, Any] = field(default_factory=dict, repr=False)
279
280    @classmethod
281    def parse(cls, payload: dict[str, Any]) -> "Job":
282        return cls(
283            job_id=_get(payload, "job_id", ""),
284            state=_get(payload, "state", ""),
285            compiled_sql=_get(payload, "compiled_sql", ""),
286            columns=tuple(_get(payload, "columns", [])),
287            rows=tuple(tuple(r) for r in _get(payload, "rows", [])),
288            row_count=int(_get(payload, "row_count", 0)),
289            next_cursor=_get(payload, "next_cursor", ""),
290            model_version=_get(payload, "model_version", ""),
291            namespace=_get(payload, "namespace", ""),
292            dialect=_get(payload, "dialect", ""),
293            code=_get(payload, "code", ""),
294            reason=_get(payload, "reason", ""),
295            hint=_get(payload, "hint", ""),
296            retry=_get(payload, "retry", ""),
297            raw=payload,
298        )
299
300    @property
301    def is_running(self) -> bool:
302        return self.state == "running"
303
304    @property
305    def is_done(self) -> bool:
306        """True once the job reached any terminal state, including failure."""
307        return self.state in ("succeeded", "failed", "cancelled")
308
309    @property
310    def succeeded(self) -> bool:
311        return self.state == "succeeded"
312
313    def raise_for_state(self) -> None:
314        """Raise if the job did not succeed.
315
316        A failure carries the same code, hint and retry class a synchronous
317        refusal would have, so an agent branches on it identically.
318        """
319        from .errors import Refused, SemanticError
320
321        if self.succeeded or self.is_running:
322            return
323        if self.state == "cancelled":
324            raise SemanticError(f"job {self.job_id} was cancelled")
325        raise Refused(
326            code=self.code or "execution_failed",
327            reason=self.reason,
328            hint=self.hint,
329            retry=self.retry or "later",  # type: ignore[arg-type]
330        )
331
332    def as_result(self, rows: tuple[tuple[Cell, ...], ...] | None = None) -> Result:
333        """Render this job's rows as a :class:`Result`.
334
335        Args:
336            rows: Overrides the single page held here, which is how
337                :meth:`Client.run` returns every page as one result.
338        """
339        collected = self.rows if rows is None else rows
340        payload = dict(self.raw)
341        payload["rows"] = [list(r) for r in collected]
342        return Result(
343            columns=self.columns,
344            rows=collected,
345            compiled_sql=self.compiled_sql,
346            model_version=self.model_version,
347            dialect=self.dialect,
348            namespace=self.namespace,
349            raw=payload,
350        )

One asynchronously executing query.

Exists because a warehouse query can run longer than an agent's HTTP timeout. state is the field to branch on: running is the only non-terminal one, and cancelled is distinct from failed because the caller stopped it rather than the warehouse failing.

A job is visible only to the identity that submitted it.

Job( job_id: str, state: str, compiled_sql: str = '', columns: tuple[str, ...] = (), rows: tuple[tuple[typing.Any, ...], ...] = (), row_count: int = 0, next_cursor: str = '', model_version: str = '', namespace: str = '', dialect: str = '', code: str = '', reason: str = '', hint: str = '', retry: str = '', raw: dict[str, typing.Any] = <factory>)
job_id: str
state: str
compiled_sql: str = ''
columns: tuple[str, ...] = ()
rows: tuple[tuple[typing.Any, ...], ...] = ()
row_count: int = 0
next_cursor: str = ''
model_version: str = ''
namespace: str = ''
dialect: str = ''
code: str = ''
reason: str = ''
hint: str = ''
retry: str = ''
raw: dict[str, typing.Any]
@classmethod
def parse(cls, payload: dict[str, typing.Any]) -> Job:
280    @classmethod
281    def parse(cls, payload: dict[str, Any]) -> "Job":
282        return cls(
283            job_id=_get(payload, "job_id", ""),
284            state=_get(payload, "state", ""),
285            compiled_sql=_get(payload, "compiled_sql", ""),
286            columns=tuple(_get(payload, "columns", [])),
287            rows=tuple(tuple(r) for r in _get(payload, "rows", [])),
288            row_count=int(_get(payload, "row_count", 0)),
289            next_cursor=_get(payload, "next_cursor", ""),
290            model_version=_get(payload, "model_version", ""),
291            namespace=_get(payload, "namespace", ""),
292            dialect=_get(payload, "dialect", ""),
293            code=_get(payload, "code", ""),
294            reason=_get(payload, "reason", ""),
295            hint=_get(payload, "hint", ""),
296            retry=_get(payload, "retry", ""),
297            raw=payload,
298        )
is_running: bool
300    @property
301    def is_running(self) -> bool:
302        return self.state == "running"
is_done: bool
304    @property
305    def is_done(self) -> bool:
306        """True once the job reached any terminal state, including failure."""
307        return self.state in ("succeeded", "failed", "cancelled")

True once the job reached any terminal state, including failure.

succeeded: bool
309    @property
310    def succeeded(self) -> bool:
311        return self.state == "succeeded"
def raise_for_state(self) -> None:
313    def raise_for_state(self) -> None:
314        """Raise if the job did not succeed.
315
316        A failure carries the same code, hint and retry class a synchronous
317        refusal would have, so an agent branches on it identically.
318        """
319        from .errors import Refused, SemanticError
320
321        if self.succeeded or self.is_running:
322            return
323        if self.state == "cancelled":
324            raise SemanticError(f"job {self.job_id} was cancelled")
325        raise Refused(
326            code=self.code or "execution_failed",
327            reason=self.reason,
328            hint=self.hint,
329            retry=self.retry or "later",  # type: ignore[arg-type]
330        )

Raise if the job did not succeed.

A failure carries the same code, hint and retry class a synchronous refusal would have, so an agent branches on it identically.

def as_result( self, rows: tuple[tuple[typing.Any, ...], ...] | None = None) -> Result:
332    def as_result(self, rows: tuple[tuple[Cell, ...], ...] | None = None) -> Result:
333        """Render this job's rows as a :class:`Result`.
334
335        Args:
336            rows: Overrides the single page held here, which is how
337                :meth:`Client.run` returns every page as one result.
338        """
339        collected = self.rows if rows is None else rows
340        payload = dict(self.raw)
341        payload["rows"] = [list(r) for r in collected]
342        return Result(
343            columns=self.columns,
344            rows=collected,
345            compiled_sql=self.compiled_sql,
346            model_version=self.model_version,
347            dialect=self.dialect,
348            namespace=self.namespace,
349            raw=payload,
350        )

Render this job's rows as a Result.

Arguments:
  • rows: Overrides the single page held here, which is how Client.run() returns every page as one result.
@dataclass(frozen=True)
class Metric:
51@dataclass(frozen=True)
52class Metric:
53    """A metric, named `namespace.metric`."""
54
55    name: str
56    namespace: str
57    description: str
58    datatype: str = ""
59    synonyms: tuple[str, ...] = ()
60    #: Only the dimensions this identity may group the metric by.
61    dimensions: tuple[str, ...] = ()
62    #: The Ossie expression, present on describe but not on list.
63    definition: str = ""
64    raw: dict[str, Any] = field(default_factory=dict, repr=False)
65
66    @classmethod
67    def parse(cls, payload: dict[str, Any]) -> "Metric":
68        dims = _get(payload, "dimensions", [])
69        # describe_metric returns dimension objects; list_metrics returns names.
70        names = tuple(d["name"] if isinstance(d, dict) else d for d in dims)
71        return cls(
72            name=_get(payload, "name", ""),
73            namespace=_get(payload, "namespace", ""),
74            description=_get(payload, "description", ""),
75            datatype=_get(payload, "datatype", ""),
76            synonyms=tuple(_get(payload, "synonyms", [])),
77            dimensions=names,
78            definition=_get(payload, "definition", ""),
79            raw=payload,
80        )

A metric, named namespace.metric.

Metric( name: str, namespace: str, description: str, datatype: str = '', synonyms: tuple[str, ...] = (), dimensions: tuple[str, ...] = (), definition: str = '', raw: dict[str, typing.Any] = <factory>)
name: str
namespace: str
description: str
datatype: str = ''
synonyms: tuple[str, ...] = ()
dimensions: tuple[str, ...] = ()
definition: str = ''
raw: dict[str, typing.Any]
@classmethod
def parse(cls, payload: dict[str, typing.Any]) -> Metric:
66    @classmethod
67    def parse(cls, payload: dict[str, Any]) -> "Metric":
68        dims = _get(payload, "dimensions", [])
69        # describe_metric returns dimension objects; list_metrics returns names.
70        names = tuple(d["name"] if isinstance(d, dict) else d for d in dims)
71        return cls(
72            name=_get(payload, "name", ""),
73            namespace=_get(payload, "namespace", ""),
74            description=_get(payload, "description", ""),
75            datatype=_get(payload, "datatype", ""),
76            synonyms=tuple(_get(payload, "synonyms", [])),
77            dimensions=names,
78            definition=_get(payload, "definition", ""),
79            raw=payload,
80        )
@dataclass(frozen=True)
class Namespace:
23@dataclass(frozen=True)
24class Namespace:
25    """One team's independently owned set of definitions."""
26
27    name: str
28    available: bool
29    metric_count: int
30    digest: str = ""
31    owners: tuple[str, ...] = ()
32    #: Present only when the namespace failed to load. An unavailable namespace
33    #: is reported rather than omitted, so absence is never mistaken for a
34    #: model that simply had no such metrics.
35    error: str = ""
36    raw: dict[str, Any] = field(default_factory=dict, repr=False)
37
38    @classmethod
39    def parse(cls, payload: dict[str, Any]) -> "Namespace":
40        return cls(
41            name=_get(payload, "name", ""),
42            available=bool(_get(payload, "available", False)),
43            metric_count=int(_get(payload, "metric_count", 0)),
44            digest=_get(payload, "digest", ""),
45            owners=tuple(_get(payload, "owners", [])),
46            error=_get(payload, "error", ""),
47            raw=payload,
48        )

One team's independently owned set of definitions.

Namespace( name: str, available: bool, metric_count: int, digest: str = '', owners: tuple[str, ...] = (), error: str = '', raw: dict[str, typing.Any] = <factory>)
name: str
available: bool
metric_count: int
digest: str = ''
owners: tuple[str, ...] = ()
error: str = ''
raw: dict[str, typing.Any]
@classmethod
def parse(cls, payload: dict[str, typing.Any]) -> Namespace:
38    @classmethod
39    def parse(cls, payload: dict[str, Any]) -> "Namespace":
40        return cls(
41            name=_get(payload, "name", ""),
42            available=bool(_get(payload, "available", False)),
43            metric_count=int(_get(payload, "metric_count", 0)),
44            digest=_get(payload, "digest", ""),
45            owners=tuple(_get(payload, "owners", [])),
46            error=_get(payload, "error", ""),
47            raw=payload,
48        )
OPERATIONS = {'getHealth': 'health', 'getModelVersion': 'model_version', 'listNamespaces': 'namespaces', 'listMetrics': 'metrics', 'describeMetric': 'metric', 'listDimensions': 'dimensions', 'query': 'query', 'compile': 'compile', 'submitJob': 'submit', 'getJob': 'job', 'cancelJob': 'cancel_job', 'listAudit': 'audit', 'doctor': 'doctor', 'doctorHistory': 'doctor_history', 'runTests': 'run_tests', 'policy': 'policy', 'explainPolicy': 'explain_policy', 'diff': 'diff', 'reload': 'reload'}
@dataclass(frozen=True)
class Origin:
481@dataclass(frozen=True)
482class Origin:
483    """The commit a served model came from.
484
485    How :meth:`Client.reload` is confirmed. Reload names no commit, because a
486    sync is not instant and reporting one before the swap happened would be a
487    claim a pipeline then asserts as fact. A deploy is finished when
488    :attr:`Health.origin` reports the commit you merged, and not before.
489
490    Carries no credential. A repository URL with one in it is refused at
491    startup rather than stored here and redacted on the way out.
492    """
493
494    #: The clone URL, without credentials.
495    repository: str = ""
496    #: The branch, tag or commit asked for. A ref moves; read ``commit`` to
497    #: know what actually answered.
498    ref: str = ""
499    #: The revision serving right now.
500    commit: str = ""
501    #: The directory inside the repository holding the workspace.
502    subdirectory: str = ""
503    raw: dict[str, Any] = field(default_factory=dict, repr=False)
504
505    @classmethod
506    def parse(cls, payload: dict[str, Any]) -> "Origin":
507        return cls(
508            repository=_get(payload, "repository", ""),
509            ref=_get(payload, "ref", ""),
510            commit=_get(payload, "commit", ""),
511            subdirectory=_get(payload, "subdirectory", ""),
512            raw=payload,
513        )

The commit a served model came from.

How Client.reload() is confirmed. Reload names no commit, because a sync is not instant and reporting one before the swap happened would be a claim a pipeline then asserts as fact. A deploy is finished when Health.origin reports the commit you merged, and not before.

Carries no credential. A repository URL with one in it is refused at startup rather than stored here and redacted on the way out.

Origin( repository: str = '', ref: str = '', commit: str = '', subdirectory: str = '', raw: dict[str, typing.Any] = <factory>)
repository: str = ''
ref: str = ''
commit: str = ''
subdirectory: str = ''
raw: dict[str, typing.Any]
@classmethod
def parse(cls, payload: dict[str, typing.Any]) -> Origin:
505    @classmethod
506    def parse(cls, payload: dict[str, Any]) -> "Origin":
507        return cls(
508            repository=_get(payload, "repository", ""),
509            ref=_get(payload, "ref", ""),
510            commit=_get(payload, "commit", ""),
511            subdirectory=_get(payload, "subdirectory", ""),
512            raw=payload,
513        )
@dataclass(frozen=True)
class Policy:
704@dataclass(frozen=True)
705class Policy:
706    """What the engine enforces. Says nothing about who is allowed what."""
707
708    #: The resolver in force and whether it makes column-level decisions.
709    governance: dict[str, Any] = field(default_factory=dict)
710    #: The gaps in plain language. Read these: an engine running allow-all
711    #: says so here rather than letting a reader assume a gate exists because
712    #: the product has one.
713    enforcement_notes: tuple[str, ...] = ()
714    raw: dict[str, Any] = field(default_factory=dict, repr=False)
715
716    @classmethod
717    def parse(cls, payload: dict[str, Any]) -> "Policy":
718        return cls(
719            governance=dict(_get(payload, "governance", {})),
720            enforcement_notes=tuple(_get(payload, "enforcement_notes", [])),
721            raw=payload,
722        )
723
724    @property
725    def governs_columns(self) -> bool:
726        """Whether column-level access control is actually configured."""
727        return bool(self.governance.get("column_level", False))

What the engine enforces. Says nothing about who is allowed what.

Policy( governance: dict[str, typing.Any] = <factory>, enforcement_notes: tuple[str, ...] = (), raw: dict[str, typing.Any] = <factory>)
governance: dict[str, typing.Any]
enforcement_notes: tuple[str, ...] = ()
raw: dict[str, typing.Any]
@classmethod
def parse(cls, payload: dict[str, typing.Any]) -> Policy:
716    @classmethod
717    def parse(cls, payload: dict[str, Any]) -> "Policy":
718        return cls(
719            governance=dict(_get(payload, "governance", {})),
720            enforcement_notes=tuple(_get(payload, "enforcement_notes", [])),
721            raw=payload,
722        )
governs_columns: bool
724    @property
725    def governs_columns(self) -> bool:
726        """Whether column-level access control is actually configured."""
727        return bool(self.governance.get("column_level", False))

Whether column-level access control is actually configured.

@dataclass(frozen=True)
class PolicyExplanation:
730@dataclass(frozen=True)
731class PolicyExplanation:
732    """What the calling identity may read of a metric, and why.
733
734    For the caller only. An engine that reported what somebody else can see
735    would publish the policy it was configured to enforce, so there is no
736    field here for another identity and no endpoint that takes one.
737    """
738
739    metric: str = ""
740    identity: str = ""
741    #: The dimensions this caller may group the metric by, qualified and
742    #: sorted.
743    readable: tuple[str, ...] = ()
744    governance: dict[str, Any] = field(default_factory=dict)
745    raw: dict[str, Any] = field(default_factory=dict, repr=False)
746
747    @classmethod
748    def parse(cls, payload: dict[str, Any]) -> "PolicyExplanation":
749        return cls(
750            metric=_get(payload, "metric", ""),
751            identity=_get(payload, "identity", ""),
752            readable=tuple(_get(payload, "readable", [])),
753            governance=dict(_get(payload, "governance", {})),
754            raw=payload,
755        )

What the calling identity may read of a metric, and why.

For the caller only. An engine that reported what somebody else can see would publish the policy it was configured to enforce, so there is no field here for another identity and no endpoint that takes one.

PolicyExplanation( metric: str = '', identity: str = '', readable: tuple[str, ...] = (), governance: dict[str, typing.Any] = <factory>, raw: dict[str, typing.Any] = <factory>)
metric: str = ''
identity: str = ''
readable: tuple[str, ...] = ()
governance: dict[str, typing.Any]
raw: dict[str, typing.Any]
@classmethod
def parse( cls, payload: dict[str, typing.Any]) -> PolicyExplanation:
747    @classmethod
748    def parse(cls, payload: dict[str, Any]) -> "PolicyExplanation":
749        return cls(
750            metric=_get(payload, "metric", ""),
751            identity=_get(payload, "identity", ""),
752            readable=tuple(_get(payload, "readable", [])),
753            governance=dict(_get(payload, "governance", {})),
754            raw=payload,
755        )
class Refused(truegrain.SemanticError):
 33class Refused(SemanticError):
 34    """The engine declined the request and said why.
 35
 36    Attributes:
 37        code: Machine-readable reason. Branch on this, never on the text.
 38        reason: One sentence stating what was wrong.
 39        hint: What to do instead. For a fan-out refusal this names the metrics
 40            defined at the grain where the question is well defined.
 41        retry: One of "modify", "later" or "never". See :meth:`should_modify`,
 42            :meth:`should_wait` and :meth:`is_final`.
 43        status: The HTTP status that carried the refusal.
 44    """
 45
 46    def __init__(
 47        self,
 48        code: str,
 49        reason: str,
 50        hint: str = "",
 51        retry: Retry = "never",
 52        status: int = 0,
 53    ) -> None:
 54        self.code = code
 55        self.reason = reason
 56        self.hint = hint
 57        self.retry: Retry = retry
 58        self.status = status
 59        super().__init__(self._render())
 60
 61    def _render(self) -> str:
 62        text = f"{self.code}: {self.reason}"
 63        if self.hint:
 64            text += f"\n  hint: {self.hint}"
 65        text += f"\n  retry: {self.retry}"
 66        return text
 67
 68    @classmethod
 69    def from_payload(cls, payload: dict[str, Any], status: int) -> "Refused":
 70        return cls(
 71            code=str(payload.get("code", "unknown")),
 72            reason=str(payload.get("reason", "")),
 73            hint=str(payload.get("hint", "")),
 74            retry=payload.get("retry", "never"),
 75            status=status,
 76        )
 77
 78    def should_modify(self) -> bool:
 79        """The request is answerable, but not as written.
 80
 81        Change the arguments and try again. Repeating it unchanged will not
 82        work. The hint usually names what to change.
 83        """
 84        return self.retry == "modify"
 85
 86    def should_wait(self) -> bool:
 87        """Nothing about the request is wrong.
 88
 89        Something outside it failed, such as the policy source being
 90        unreachable. The same request may succeed shortly.
 91        """
 92        return self.retry == "later"
 93
 94    def is_final(self) -> bool:
 95        """No version of this request from this caller will succeed.
 96
 97        Usually a denial. Say so rather than substituting a different metric
 98        that answers a different question.
 99        """
100        return self.retry == "never"

The engine declined the request and said why.

Attributes:
  • code: Machine-readable reason. Branch on this, never on the text.
  • reason: One sentence stating what was wrong.
  • hint: What to do instead. For a fan-out refusal this names the metrics defined at the grain where the question is well defined.
  • retry: One of "modify", "later" or "never". See should_modify(), should_wait() and is_final().
  • status: The HTTP status that carried the refusal.
Refused( code: str, reason: str, hint: str = '', retry: Literal['modify', 'later', 'never'] = 'never', status: int = 0)
46    def __init__(
47        self,
48        code: str,
49        reason: str,
50        hint: str = "",
51        retry: Retry = "never",
52        status: int = 0,
53    ) -> None:
54        self.code = code
55        self.reason = reason
56        self.hint = hint
57        self.retry: Retry = retry
58        self.status = status
59        super().__init__(self._render())
code
reason
hint
retry: Literal['modify', 'later', 'never']
status
@classmethod
def from_payload( cls, payload: dict[str, typing.Any], status: int) -> Refused:
68    @classmethod
69    def from_payload(cls, payload: dict[str, Any], status: int) -> "Refused":
70        return cls(
71            code=str(payload.get("code", "unknown")),
72            reason=str(payload.get("reason", "")),
73            hint=str(payload.get("hint", "")),
74            retry=payload.get("retry", "never"),
75            status=status,
76        )
def should_modify(self) -> bool:
78    def should_modify(self) -> bool:
79        """The request is answerable, but not as written.
80
81        Change the arguments and try again. Repeating it unchanged will not
82        work. The hint usually names what to change.
83        """
84        return self.retry == "modify"

The request is answerable, but not as written.

Change the arguments and try again. Repeating it unchanged will not work. The hint usually names what to change.

def should_wait(self) -> bool:
86    def should_wait(self) -> bool:
87        """Nothing about the request is wrong.
88
89        Something outside it failed, such as the policy source being
90        unreachable. The same request may succeed shortly.
91        """
92        return self.retry == "later"

Nothing about the request is wrong.

Something outside it failed, such as the policy source being unreachable. The same request may succeed shortly.

def is_final(self) -> bool:
 94    def is_final(self) -> bool:
 95        """No version of this request from this caller will succeed.
 96
 97        Usually a denial. Say so rather than substituting a different metric
 98        that answers a different question.
 99        """
100        return self.retry == "never"

No version of this request from this caller will succeed.

Usually a denial. Say so rather than substituting a different metric that answers a different question.

@dataclass(frozen=True)
class Result:
178@dataclass(frozen=True)
179class Result:
180    """Rows, plus the provenance needed to defend the numbers in them."""
181
182    columns: tuple[str, ...]
183    rows: tuple[tuple[Cell, ...], ...]
184    #: The exact statement executed. Carried on every response on purpose: it is
185    #: how a disagreement about a number gets settled.
186    compiled_sql: str
187    model_version: str
188    dialect: str = ""
189    namespace: str = ""
190    raw: dict[str, Any] = field(default_factory=dict, repr=False)
191
192    @classmethod
193    def parse(cls, payload: dict[str, Any]) -> "Result":
194        return cls(
195            columns=tuple(_get(payload, "columns", [])),
196            rows=tuple(tuple(r) for r in _get(payload, "rows", [])),
197            compiled_sql=_get(payload, "compiled_sql", ""),
198            model_version=_get(payload, "model_version", ""),
199            dialect=_get(payload, "dialect", ""),
200            namespace=_get(payload, "namespace", ""),
201            raw=payload,
202        )
203
204    @property
205    def row_count(self) -> int:
206        return len(self.rows)
207
208    def __len__(self) -> int:
209        return len(self.rows)
210
211    def __iter__(self) -> Iterator[dict[str, Cell]]:
212        """Iterate rows as dictionaries keyed by column name."""
213        for row in self.rows:
214            yield dict(zip(self.columns, row))
215
216    def dicts(self) -> list[dict[str, Cell]]:
217        """Every row as a dictionary keyed by column name."""
218        return list(self)
219
220    def to_dataframe(self):  # type: ignore[no-untyped-def]
221        """Return a pandas DataFrame.
222
223        pandas is an optional dependency: the client itself needs nothing beyond
224        the standard library, because an SDK that drags dependencies into an
225        agent's environment causes conflicts nobody asked for.
226
227        The compiled SQL and the model version travel with the frame in
228        ``df.attrs``, so provenance survives into a notebook instead of being
229        dropped at the client boundary.
230        """
231        try:
232            import pandas as pd
233        except ModuleNotFoundError as exc:  # pragma: no cover - environment dependent
234            raise ModuleNotFoundError(
235                "to_dataframe() needs pandas. Install it with "
236                "`pip install truegrain[pandas]` or `pip install pandas`."
237            ) from exc
238
239        frame = pd.DataFrame(list(self.rows), columns=list(self.columns))
240        frame.attrs["compiled_sql"] = self.compiled_sql
241        frame.attrs["model_version"] = self.model_version
242        frame.attrs["namespace"] = self.namespace
243        return frame

Rows, plus the provenance needed to defend the numbers in them.

Result( columns: tuple[str, ...], rows: tuple[tuple[typing.Any, ...], ...], compiled_sql: str, model_version: str, dialect: str = '', namespace: str = '', raw: dict[str, typing.Any] = <factory>)
columns: tuple[str, ...]
rows: tuple[tuple[typing.Any, ...], ...]
compiled_sql: str
model_version: str
dialect: str = ''
namespace: str = ''
raw: dict[str, typing.Any]
@classmethod
def parse(cls, payload: dict[str, typing.Any]) -> Result:
192    @classmethod
193    def parse(cls, payload: dict[str, Any]) -> "Result":
194        return cls(
195            columns=tuple(_get(payload, "columns", [])),
196            rows=tuple(tuple(r) for r in _get(payload, "rows", [])),
197            compiled_sql=_get(payload, "compiled_sql", ""),
198            model_version=_get(payload, "model_version", ""),
199            dialect=_get(payload, "dialect", ""),
200            namespace=_get(payload, "namespace", ""),
201            raw=payload,
202        )
row_count: int
204    @property
205    def row_count(self) -> int:
206        return len(self.rows)
def dicts(self) -> list[dict[str, typing.Any]]:
216    def dicts(self) -> list[dict[str, Cell]]:
217        """Every row as a dictionary keyed by column name."""
218        return list(self)

Every row as a dictionary keyed by column name.

def to_dataframe(self):
220    def to_dataframe(self):  # type: ignore[no-untyped-def]
221        """Return a pandas DataFrame.
222
223        pandas is an optional dependency: the client itself needs nothing beyond
224        the standard library, because an SDK that drags dependencies into an
225        agent's environment causes conflicts nobody asked for.
226
227        The compiled SQL and the model version travel with the frame in
228        ``df.attrs``, so provenance survives into a notebook instead of being
229        dropped at the client boundary.
230        """
231        try:
232            import pandas as pd
233        except ModuleNotFoundError as exc:  # pragma: no cover - environment dependent
234            raise ModuleNotFoundError(
235                "to_dataframe() needs pandas. Install it with "
236                "`pip install truegrain[pandas]` or `pip install pandas`."
237            ) from exc
238
239        frame = pd.DataFrame(list(self.rows), columns=list(self.columns))
240        frame.attrs["compiled_sql"] = self.compiled_sql
241        frame.attrs["model_version"] = self.model_version
242        frame.attrs["namespace"] = self.namespace
243        return frame

Return a pandas DataFrame.

pandas is an optional dependency: the client itself needs nothing beyond the standard library, because an SDK that drags dependencies into an agent's environment causes conflicts nobody asked for.

The compiled SQL and the model version travel with the frame in df.attrs, so provenance survives into a notebook instead of being dropped at the client boundary.

Retry = typing.Literal['modify', 'later', 'never']
class SemanticError(builtins.Exception):
17class SemanticError(Exception):
18    """Base class, so a caller can catch everything this library raises."""

Base class, so a caller can catch everything this library raises.

@dataclass(frozen=True)
class TestCase:
640@dataclass(frozen=True)
641class TestCase:
642    """One assertion and what became of it."""
643
644    name: str = ""
645    passed: bool = False
646    skipped: bool = False
647    #: Why, for a case that failed or was skipped.
648    reason: str = ""
649    duration_ms: int = 0
650    raw: dict[str, Any] = field(default_factory=dict, repr=False)
651
652    @classmethod
653    def parse(cls, payload: dict[str, Any]) -> "TestCase":
654        return cls(
655            name=_get(payload, "name", ""),
656            passed=bool(_get(payload, "passed", False)),
657            skipped=bool(_get(payload, "skipped", False)),
658            reason=_get(payload, "reason", ""),
659            duration_ms=int(_get(payload, "duration_ms", 0)),
660            raw=payload,
661        )

One assertion and what became of it.

TestCase( name: str = '', passed: bool = False, skipped: bool = False, reason: str = '', duration_ms: int = 0, raw: dict[str, typing.Any] = <factory>)
name: str = ''
passed: bool = False
skipped: bool = False
reason: str = ''
duration_ms: int = 0
raw: dict[str, typing.Any]
@classmethod
def parse(cls, payload: dict[str, typing.Any]) -> TestCase:
652    @classmethod
653    def parse(cls, payload: dict[str, Any]) -> "TestCase":
654        return cls(
655            name=_get(payload, "name", ""),
656            passed=bool(_get(payload, "passed", False)),
657            skipped=bool(_get(payload, "skipped", False)),
658            reason=_get(payload, "reason", ""),
659            duration_ms=int(_get(payload, "duration_ms", 0)),
660            raw=payload,
661        )
@dataclass(frozen=True)
class TestReport:
664@dataclass(frozen=True)
665class TestReport:
666    """The result of every case in the suite.
667
668    Read :attr:`withheld` as well as :attr:`ok`. A credential without
669    ``run:query`` cannot cause warehouse execution, so cases that would are
670    withheld and counted rather than run or silently dropped, and a caller
671    reading only ``ok`` would conclude a suite passed when half of it never
672    ran.
673    """
674
675    ok: bool = False
676    passed: int = 0
677    failed: int = 0
678    skipped: int = 0
679    #: Cases this credential may not run.
680    withheld: int = 0
681    results: tuple[TestCase, ...] = ()
682    raw: dict[str, Any] = field(default_factory=dict, repr=False)
683
684    @classmethod
685    def parse(cls, payload: dict[str, Any]) -> "TestReport":
686        return cls(
687            ok=bool(_get(payload, "ok", False)),
688            passed=int(_get(payload, "passed", 0)),
689            failed=int(_get(payload, "failed", 0)),
690            skipped=int(_get(payload, "skipped", 0)),
691            withheld=int(_get(payload, "withheld", 0)),
692            results=tuple(TestCase.parse(r) for r in _get(payload, "results", [])),
693            raw=payload,
694        )
695
696    def __iter__(self) -> Iterator[TestCase]:
697        return iter(self.results)
698
699    def failures(self) -> tuple[TestCase, ...]:
700        """Only the cases that failed, which is what a pipeline prints."""
701        return tuple(c for c in self.results if not c.passed and not c.skipped)

The result of every case in the suite.

Read withheld as well as ok. A credential without run:query cannot cause warehouse execution, so cases that would are withheld and counted rather than run or silently dropped, and a caller reading only ok would conclude a suite passed when half of it never ran.

TestReport( ok: bool = False, passed: int = 0, failed: int = 0, skipped: int = 0, withheld: int = 0, results: tuple[TestCase, ...] = (), raw: dict[str, typing.Any] = <factory>)
ok: bool = False
passed: int = 0
failed: int = 0
skipped: int = 0
withheld: int = 0
results: tuple[TestCase, ...] = ()
raw: dict[str, typing.Any]
@classmethod
def parse(cls, payload: dict[str, typing.Any]) -> TestReport:
684    @classmethod
685    def parse(cls, payload: dict[str, Any]) -> "TestReport":
686        return cls(
687            ok=bool(_get(payload, "ok", False)),
688            passed=int(_get(payload, "passed", 0)),
689            failed=int(_get(payload, "failed", 0)),
690            skipped=int(_get(payload, "skipped", 0)),
691            withheld=int(_get(payload, "withheld", 0)),
692            results=tuple(TestCase.parse(r) for r in _get(payload, "results", [])),
693            raw=payload,
694        )
def failures(self) -> tuple[TestCase, ...]:
699    def failures(self) -> tuple[TestCase, ...]:
700        """Only the cases that failed, which is what a pipeline prints."""
701        return tuple(c for c in self.results if not c.passed and not c.skipped)

Only the cases that failed, which is what a pipeline prints.

class TransportError(truegrain.SemanticError):
21class TransportError(SemanticError):
22    """The engine could not be reached, or answered with something unparseable.
23
24    This is a network or deployment problem, never a statement about the
25    request. Retrying the same request is reasonable.
26    """

The engine could not be reached, or answered with something unparseable.

This is a network or deployment problem, never a statement about the request. Retrying the same request is reasonable.

class Unauthorized(truegrain.SemanticError):
29class Unauthorized(SemanticError):
30    """Credentials are missing or not recognised."""

Credentials are missing or not recognised.

__version__ = '1.1.0'