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]
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.
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 )
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.
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 )
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.
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()
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
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.
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.
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.
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.
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.
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.
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.
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.fieldornamespace.dataset.field. - filters: Structured filters. Use
truegrain.filtersto 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.retrybefore retrying.
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.
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.retrybefore retrying. - SemanticError: The wait expired, or the job was cancelled.
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.
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.
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.
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.
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.
refusedis the useful one.
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.
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.
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.
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().
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.
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.
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.
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.
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 )
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.
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 )
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.
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 )
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.
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 )
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.
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.
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 )
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.
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 )
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.
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 )
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.
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 )
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.
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.
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.
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.
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 )
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.
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 )
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.
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.
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.
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 )
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()andis_final(). - status: The HTTP status that carried the refusal.
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.
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.
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.
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.
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 )
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.
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.
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.
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.
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 )
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.
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 )
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.