interlaced 2.0.0a2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- interlace/__init__.py +26 -0
- interlace/checks/__init__.py +10 -0
- interlace/checks/builtin.py +152 -0
- interlace/checks/runner.py +103 -0
- interlace/checks/spec.py +113 -0
- interlace/cli/__init__.py +7 -0
- interlace/cli/main.py +1072 -0
- interlace/config/__init__.py +7 -0
- interlace/config/config.py +172 -0
- interlace/contracts.py +35 -0
- interlace/dsl/__init__.py +16 -0
- interlace/dsl/decorators.py +222 -0
- interlace/dsl/discovery.py +75 -0
- interlace/dsl/sql_config.py +47 -0
- interlace/engines/__init__.py +16 -0
- interlace/engines/base.py +86 -0
- interlace/engines/duckdb.py +249 -0
- interlace/engines/postgres.py +157 -0
- interlace/engines/quack.py +113 -0
- interlace/engines/registry.py +125 -0
- interlace/exceptions.py +66 -0
- interlace/exports.py +145 -0
- interlace/graph/__init__.py +17 -0
- interlace/graph/column_lineage.py +69 -0
- interlace/graph/dag.py +81 -0
- interlace/graph/project.py +242 -0
- interlace/graph/selectors.py +45 -0
- interlace/ir/__init__.py +24 -0
- interlace/ir/canonicalize.py +71 -0
- interlace/ir/fingerprint.py +55 -0
- interlace/ir/relation.py +71 -0
- interlace/ir/schema.py +23 -0
- interlace/plan/__init__.py +23 -0
- interlace/plan/apply.py +527 -0
- interlace/plan/differ.py +211 -0
- interlace/plan/plan.py +177 -0
- interlace/plan/resolve.py +74 -0
- interlace/plan/run.py +79 -0
- interlace/project.py +278 -0
- interlace/py.typed +0 -0
- interlace/runtime/__init__.py +6 -0
- interlace/runtime/handles.py +48 -0
- interlace/runtime/python_model.py +175 -0
- interlace/scaffold.py +83 -0
- interlace/scheduler/__init__.py +17 -0
- interlace/scheduler/engine.py +66 -0
- interlace/scheduler/triggers.py +84 -0
- interlace/scheduler/worker.py +154 -0
- interlace/service/__init__.py +7 -0
- interlace/service/app.py +836 -0
- interlace/service/auth.py +41 -0
- interlace/state/__init__.py +18 -0
- interlace/state/interval.py +134 -0
- interlace/state/janitor.py +142 -0
- interlace/state/snapshot.py +47 -0
- interlace/state/store.py +906 -0
- interlace/strategies/__init__.py +64 -0
- interlace/strategies/base.py +71 -0
- interlace/strategies/full.py +38 -0
- interlace/strategies/full_merge.py +98 -0
- interlace/strategies/incremental_by_time.py +65 -0
- interlace/strategies/merge_by_key.py +69 -0
- interlace/strategies/scd_type_2.py +122 -0
- interlace/strategies/view.py +33 -0
- interlace/streaming/__init__.py +18 -0
- interlace/streaming/log.py +298 -0
- interlace/streaming/materializer.py +166 -0
- interlace/streaming/schema.py +218 -0
- interlaced-2.0.0a2.dist-info/METADATA +216 -0
- interlaced-2.0.0a2.dist-info/RECORD +74 -0
- interlaced-2.0.0a2.dist-info/WHEEL +5 -0
- interlaced-2.0.0a2.dist-info/entry_points.txt +2 -0
- interlaced-2.0.0a2.dist-info/licenses/LICENSE +21 -0
- interlaced-2.0.0a2.dist-info/top_level.txt +1 -0
interlace/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Interlace v2 — Python/SQL-first data platform.
|
|
2
|
+
|
|
3
|
+
Transformation (sqlmesh-grade snapshots, virtual environments, plan/apply),
|
|
4
|
+
built-in orchestration (durable work queue + unified triggers), and durable
|
|
5
|
+
streaming ingestion — in one process. See docs/architecture/v2-design.md.
|
|
6
|
+
|
|
7
|
+
This package is under active greenfield construction; the public surface is the
|
|
8
|
+
``@model`` / ``@stream`` / ``@check`` decorators plus the core IR types.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from interlace.dsl.decorators import check, model, stream
|
|
14
|
+
from interlace.ir.relation import EngineRef, SqlRelation, TableRef
|
|
15
|
+
|
|
16
|
+
__version__ = "2.0.0a2"
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"EngineRef",
|
|
20
|
+
"SqlRelation",
|
|
21
|
+
"TableRef",
|
|
22
|
+
"__version__",
|
|
23
|
+
"check",
|
|
24
|
+
"model",
|
|
25
|
+
"stream",
|
|
26
|
+
]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Data-quality checks. Results gate promotion: an ``error``-severity failure
|
|
2
|
+
aborts the apply before the environment is promoted.
|
|
3
|
+
|
|
4
|
+
Import :mod:`interlace.checks.runner` for execution — kept out of this package
|
|
5
|
+
init so declaring checks (spec) never drags in the runtime.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from interlace.checks.spec import CheckSpec, parse_checks
|
|
9
|
+
|
|
10
|
+
__all__ = ["CheckSpec", "parse_checks"]
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Built-in checks as AST builders.
|
|
2
|
+
|
|
3
|
+
Every check compiles to one SELECT returning a single ``failures`` bigint —
|
|
4
|
+
0 means pass. Row-predicated checks count violating rows; table-level checks
|
|
5
|
+
(row_count, freshness) collapse their condition to 0/1. Queries are sqlglot
|
|
6
|
+
expressions end to end, so they transpile with the engine dialect like any
|
|
7
|
+
strategy output.
|
|
8
|
+
|
|
9
|
+
NULL semantics (deliberate change from v0.x): ``pattern``, ``range``,
|
|
10
|
+
``accepted_values``, and ``relationships`` ignore NULLs — ``not_null`` is the
|
|
11
|
+
dedicated null check, so combine them when NULLs should also fail (v0.x counted
|
|
12
|
+
NULLs as pattern/range failures, which conflated the two).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from sqlglot import exp, parse_one
|
|
22
|
+
|
|
23
|
+
from interlace.checks.spec import CheckSpec
|
|
24
|
+
from interlace.exceptions import DefinitionError
|
|
25
|
+
from interlace.ir.relation import TableRef
|
|
26
|
+
|
|
27
|
+
_FAILURES = "failures"
|
|
28
|
+
_AGE_RE = re.compile(r"^\s*(\d+)\s*([smhdw])\s*$")
|
|
29
|
+
_AGE_UNITS = {"s": "SECOND", "m": "MINUTE", "h": "HOUR", "d": "DAY", "w": "WEEK"}
|
|
30
|
+
|
|
31
|
+
ResolveTable = Callable[[str], TableRef]
|
|
32
|
+
"""Maps an upstream model name to its physical table (for ``relationships``)."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _table(ref: TableRef) -> exp.Table:
|
|
36
|
+
return exp.table_(ref.name, db=ref.schema or None, catalog=ref.catalog)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _count_where(table: TableRef, condition: exp.Expression) -> exp.Select:
|
|
40
|
+
return exp.select(exp.alias_(exp.Count(this=exp.Star()), _FAILURES)).from_(_table(table)).where(condition)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _flag(table: TableRef, condition: exp.Expression) -> exp.Select:
|
|
44
|
+
"""1 if ``condition`` holds over the whole table, else 0."""
|
|
45
|
+
flag = exp.Case().when(condition, exp.Literal.number(1)).else_(exp.Literal.number(0))
|
|
46
|
+
return exp.select(exp.alias_(flag, _FAILURES)).from_(_table(table))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _require(spec: CheckSpec, model: str, *, columns: int | None = None, params: tuple[str, ...] = ()) -> None:
|
|
50
|
+
if columns is not None and len(spec.columns) < columns:
|
|
51
|
+
raise DefinitionError(f"check {spec.type!r} on {model!r} needs a column")
|
|
52
|
+
for name in params:
|
|
53
|
+
if name not in spec.params:
|
|
54
|
+
raise DefinitionError(f"check {spec.type!r} on {model!r} needs {name!r}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _parse_expr(sql: str, dialect: str) -> exp.Expression:
|
|
58
|
+
return parse_one(sql, read=dialect)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _interval(max_age: str) -> exp.Interval:
|
|
62
|
+
match = _AGE_RE.fullmatch(str(max_age))
|
|
63
|
+
if match is None:
|
|
64
|
+
raise DefinitionError(f"invalid max_age {max_age!r}; expected like '2h', '30m', '1d'")
|
|
65
|
+
return exp.Interval(this=exp.Literal.string(match.group(1)), unit=exp.Var(this=_AGE_UNITS[match.group(2)]))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def build_check_query(
|
|
69
|
+
spec: CheckSpec, table: TableRef, model: str, dialect: str, resolve: ResolveTable
|
|
70
|
+
) -> exp.Expression:
|
|
71
|
+
"""Compile ``spec`` against ``table`` into a query returning ``failures``."""
|
|
72
|
+
cols = [exp.column(c) for c in spec.columns]
|
|
73
|
+
params: dict[str, Any] = spec.params
|
|
74
|
+
|
|
75
|
+
if spec.type == "not_null":
|
|
76
|
+
_require(spec, model, columns=1)
|
|
77
|
+
condition: exp.Expression = exp.or_(*[col.is_(exp.null()) for col in cols])
|
|
78
|
+
return _count_where(table, condition)
|
|
79
|
+
|
|
80
|
+
if spec.type == "unique":
|
|
81
|
+
_require(spec, model, columns=1)
|
|
82
|
+
grouped = (
|
|
83
|
+
exp.select(*cols)
|
|
84
|
+
.from_(_table(table))
|
|
85
|
+
.group_by(*cols)
|
|
86
|
+
.having(exp.GT(this=exp.Count(this=exp.Star()), expression=exp.Literal.number(1)))
|
|
87
|
+
)
|
|
88
|
+
return exp.select(exp.alias_(exp.Count(this=exp.Star()), _FAILURES)).from_(grouped.subquery("dup"))
|
|
89
|
+
|
|
90
|
+
if spec.type == "accepted_values":
|
|
91
|
+
_require(spec, model, columns=1, params=("values",))
|
|
92
|
+
values = [exp.Literal.string(v) if isinstance(v, str) else exp.Literal.number(v) for v in params["values"]]
|
|
93
|
+
col = cols[0]
|
|
94
|
+
return _count_where(table, exp.and_(col.is_(exp.null()).not_(), exp.In(this=col, expressions=values).not_()))
|
|
95
|
+
|
|
96
|
+
if spec.type == "range":
|
|
97
|
+
_require(spec, model, columns=1)
|
|
98
|
+
if "min" not in params and "max" not in params:
|
|
99
|
+
raise DefinitionError(f"check 'range' on {model!r} needs min and/or max")
|
|
100
|
+
col = cols[0]
|
|
101
|
+
bounds: list[exp.Expression] = []
|
|
102
|
+
if "min" in params:
|
|
103
|
+
bounds.append(exp.LT(this=col, expression=exp.Literal.number(params["min"])))
|
|
104
|
+
if "max" in params:
|
|
105
|
+
bounds.append(exp.GT(this=col, expression=exp.Literal.number(params["max"])))
|
|
106
|
+
return _count_where(table, exp.or_(*bounds))
|
|
107
|
+
|
|
108
|
+
if spec.type == "pattern":
|
|
109
|
+
_require(spec, model, columns=1, params=("regex",))
|
|
110
|
+
col = cols[0]
|
|
111
|
+
matches = exp.RegexpLike(this=col, expression=exp.Literal.string(str(params["regex"])))
|
|
112
|
+
return _count_where(table, exp.and_(col.is_(exp.null()).not_(), matches.not_()))
|
|
113
|
+
|
|
114
|
+
if spec.type == "expression":
|
|
115
|
+
_require(spec, model, params=("expression",))
|
|
116
|
+
predicate = _parse_expr(str(params["expression"]), dialect)
|
|
117
|
+
return _count_where(table, exp.paren(predicate).not_())
|
|
118
|
+
|
|
119
|
+
if spec.type == "relationships":
|
|
120
|
+
_require(spec, model, columns=1, params=("to", "field"))
|
|
121
|
+
col = cols[0]
|
|
122
|
+
parent = exp.select(exp.column(str(params["field"]))).from_(_table(resolve(str(params["to"]))))
|
|
123
|
+
orphan = exp.In(this=col, query=exp.Subquery(this=parent)).not_()
|
|
124
|
+
return _count_where(table, exp.and_(col.is_(exp.null()).not_(), orphan))
|
|
125
|
+
|
|
126
|
+
if spec.type == "row_count":
|
|
127
|
+
if "min" not in params and "max" not in params:
|
|
128
|
+
raise DefinitionError(f"check 'row_count' on {model!r} needs min and/or max")
|
|
129
|
+
count = exp.Count(this=exp.Star())
|
|
130
|
+
counts: list[exp.Expression] = []
|
|
131
|
+
if "min" in params:
|
|
132
|
+
counts.append(exp.LT(this=count, expression=exp.Literal.number(params["min"])))
|
|
133
|
+
if "max" in params:
|
|
134
|
+
counts.append(exp.GT(this=count.copy(), expression=exp.Literal.number(params["max"])))
|
|
135
|
+
return _flag(table, exp.or_(*counts))
|
|
136
|
+
|
|
137
|
+
if spec.type == "freshness":
|
|
138
|
+
_require(spec, model, columns=1, params=("max_age",))
|
|
139
|
+
threshold = exp.Sub(this=exp.CurrentTimestamp(), expression=_interval(str(params["max_age"])))
|
|
140
|
+
newest = exp.Max(this=cols[0])
|
|
141
|
+
stale = exp.or_(exp.LT(this=newest, expression=threshold), newest.copy().is_(exp.null())) # no rows = stale
|
|
142
|
+
return _flag(table, stale)
|
|
143
|
+
|
|
144
|
+
if spec.type == "sql":
|
|
145
|
+
_require(spec, model, params=("query",))
|
|
146
|
+
sql = str(params["query"]).replace("{table}", _table(table).sql(dialect=dialect))
|
|
147
|
+
inner = _parse_expr(sql, dialect)
|
|
148
|
+
return exp.select(exp.alias_(exp.Count(this=exp.Star()), _FAILURES)).from_(
|
|
149
|
+
exp.Subquery(this=inner, alias=exp.TableAlias(this=exp.to_identifier("q")))
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
raise DefinitionError(f"unknown check type {spec.type!r} on {model!r}")
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Run a model's checks against its freshly built physical table.
|
|
2
|
+
|
|
3
|
+
Declarative checks compile to one ``failures``-count query each and run on the
|
|
4
|
+
engine. Python ``@check`` functions receive a :class:`RelationHandle` over the
|
|
5
|
+
built table and pass by returning a truthy success (``True``/``0``) — return
|
|
6
|
+
``False`` or a failure count to fail. A check that itself crashes is recorded
|
|
7
|
+
as ``error`` status (an engine problem, not a data-quality verdict).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import inspect
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
import pyarrow as pa
|
|
18
|
+
from sqlglot import exp
|
|
19
|
+
|
|
20
|
+
from interlace.checks.builtin import build_check_query
|
|
21
|
+
from interlace.checks.spec import CheckSpec
|
|
22
|
+
from interlace.dsl.decorators import CheckDef
|
|
23
|
+
from interlace.engines.base import EngineAdapter
|
|
24
|
+
from interlace.exceptions import DefinitionError
|
|
25
|
+
from interlace.graph.project import CompiledModel, CompiledProject
|
|
26
|
+
from interlace.ir.relation import TableRef
|
|
27
|
+
from interlace.runtime.handles import RelationHandle
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class CheckOutcome:
|
|
32
|
+
"""The result of one check run."""
|
|
33
|
+
|
|
34
|
+
model: str
|
|
35
|
+
name: str
|
|
36
|
+
type: str
|
|
37
|
+
severity: str
|
|
38
|
+
status: str # "passed" | "failed" | "error"
|
|
39
|
+
failures: int = 0
|
|
40
|
+
message: str | None = None
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def blocking(self) -> bool:
|
|
44
|
+
return self.status != "passed" and self.severity == "error"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def _run_declared(
|
|
48
|
+
spec: CheckSpec,
|
|
49
|
+
model: CompiledModel,
|
|
50
|
+
compiled: CompiledProject,
|
|
51
|
+
engine: EngineAdapter,
|
|
52
|
+
table: TableRef,
|
|
53
|
+
physical: Mapping[str, TableRef] | None,
|
|
54
|
+
) -> CheckOutcome:
|
|
55
|
+
def resolve(name: str) -> TableRef:
|
|
56
|
+
upstream = compiled.models.get(name)
|
|
57
|
+
if upstream is None:
|
|
58
|
+
raise DefinitionError(f"check on {model.name!r} references unknown model {name!r}")
|
|
59
|
+
return (physical or {}).get(name, upstream.physical_table)
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
query = build_check_query(spec, table, model.name, model.dialect, resolve)
|
|
63
|
+
reader = await engine.fetch(query)
|
|
64
|
+
row = reader.read_all().to_pylist()[0]
|
|
65
|
+
failures = int(row["failures"] or 0)
|
|
66
|
+
except DefinitionError:
|
|
67
|
+
raise # a misdeclared check is a definition problem, not a data problem
|
|
68
|
+
except Exception as error:
|
|
69
|
+
return CheckOutcome(model.name, spec.name, spec.type, spec.severity, "error", message=str(error))
|
|
70
|
+
status = "passed" if failures == 0 else "failed"
|
|
71
|
+
return CheckOutcome(model.name, spec.name, spec.type, spec.severity, status, failures=failures)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def _run_python(check: CheckDef, engine: EngineAdapter, table: TableRef, model: str) -> CheckOutcome:
|
|
75
|
+
try:
|
|
76
|
+
query = exp.select("*").from_(table.to_expr())
|
|
77
|
+
handle = RelationHandle(model, await engine.fetch(query))
|
|
78
|
+
if inspect.iscoroutinefunction(check.fn):
|
|
79
|
+
result = await check.fn(handle)
|
|
80
|
+
else:
|
|
81
|
+
result = await asyncio.to_thread(check.fn, handle)
|
|
82
|
+
except Exception as error:
|
|
83
|
+
return CheckOutcome(model, check.name, "python", check.severity, "error", message=str(error))
|
|
84
|
+
if isinstance(result, pa.Table): # returned failing rows: empty = pass
|
|
85
|
+
result = result.num_rows
|
|
86
|
+
if result is True or result is None or result == 0:
|
|
87
|
+
return CheckOutcome(model, check.name, "python", check.severity, "passed")
|
|
88
|
+
failures = int(result) if isinstance(result, int) else 1
|
|
89
|
+
return CheckOutcome(model, check.name, "python", check.severity, "failed", failures=failures)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def run_checks(
|
|
93
|
+
model: CompiledModel,
|
|
94
|
+
compiled: CompiledProject,
|
|
95
|
+
engine: EngineAdapter,
|
|
96
|
+
table: TableRef,
|
|
97
|
+
python_checks: tuple[CheckDef, ...] = (),
|
|
98
|
+
physical: Mapping[str, TableRef] | None = None,
|
|
99
|
+
) -> list[CheckOutcome]:
|
|
100
|
+
"""Run all of ``model``'s checks against ``table``; returns every outcome."""
|
|
101
|
+
outcomes = [await _run_declared(spec, model, compiled, engine, table, physical) for spec in model.checks]
|
|
102
|
+
outcomes += [await _run_python(check, engine, table, model.name) for check in python_checks]
|
|
103
|
+
return outcomes
|
interlace/checks/spec.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Check declarations.
|
|
2
|
+
|
|
3
|
+
A model declares checks in its config (SQL block or ``@model(checks=[...])``)::
|
|
4
|
+
|
|
5
|
+
checks:
|
|
6
|
+
- not_null: order_id # shorthand: {type: column}
|
|
7
|
+
- unique: [order_id, customer_id]
|
|
8
|
+
- accepted_values: {column: status, values: [open, closed]}
|
|
9
|
+
- relationships: {column: customer_id, to: customers, field: customer_id}
|
|
10
|
+
- expression: {expression: "amount >= 0", severity: warn}
|
|
11
|
+
- row_count: {min: 1}
|
|
12
|
+
- range: {column: amount, min: 0}
|
|
13
|
+
- pattern: {column: email, regex: ".+@.+"}
|
|
14
|
+
- freshness: {column: updated_at, max_age: 2h}
|
|
15
|
+
- sql: {query: "SELECT * FROM {table} WHERE total < 0"}
|
|
16
|
+
|
|
17
|
+
Each entry normalises to a :class:`CheckSpec`. Severity: ``error`` (default)
|
|
18
|
+
fails the apply before promotion; ``warn``/``info`` record and continue.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from collections.abc import Sequence
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from interlace.exceptions import DefinitionError
|
|
28
|
+
|
|
29
|
+
SEVERITIES = frozenset({"error", "warn", "info"})
|
|
30
|
+
CHECK_TYPES = frozenset(
|
|
31
|
+
{
|
|
32
|
+
"not_null",
|
|
33
|
+
"unique",
|
|
34
|
+
"accepted_values",
|
|
35
|
+
"row_count",
|
|
36
|
+
"freshness",
|
|
37
|
+
"expression",
|
|
38
|
+
"relationships",
|
|
39
|
+
"pattern",
|
|
40
|
+
"range",
|
|
41
|
+
"sql",
|
|
42
|
+
}
|
|
43
|
+
)
|
|
44
|
+
_COLUMN_SHORTHAND = frozenset({"not_null", "unique", "range", "pattern", "freshness", "accepted_values"})
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class CheckSpec:
|
|
49
|
+
"""One declared check on a model."""
|
|
50
|
+
|
|
51
|
+
type: str
|
|
52
|
+
columns: tuple[str, ...] = () # subject column(s); empty for table-level checks
|
|
53
|
+
severity: str = "error"
|
|
54
|
+
params: dict[str, Any] = field(default_factory=dict) # type-specific settings
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def name(self) -> str:
|
|
58
|
+
return "_".join([self.type, *self.columns]) if self.columns else self.type
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _as_columns(value: Any) -> tuple[str, ...]:
|
|
62
|
+
if value is None:
|
|
63
|
+
return ()
|
|
64
|
+
if isinstance(value, str):
|
|
65
|
+
return (value,)
|
|
66
|
+
if isinstance(value, Sequence):
|
|
67
|
+
return tuple(str(v) for v in value)
|
|
68
|
+
raise DefinitionError(f"check column must be a name or list of names, got {type(value).__name__}")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _parse_one(entry: Any, model: str) -> CheckSpec:
|
|
72
|
+
if isinstance(entry, str): # bare "not_null" is meaningless without a column
|
|
73
|
+
raise DefinitionError(f"check {entry!r} on {model!r} needs a column or config mapping")
|
|
74
|
+
if not isinstance(entry, dict) or not entry:
|
|
75
|
+
raise DefinitionError(f"invalid check entry on {model!r}: {entry!r}")
|
|
76
|
+
|
|
77
|
+
if "type" in entry: # explicit form: {type: not_null, column: id, ...}
|
|
78
|
+
params = dict(entry)
|
|
79
|
+
check_type = str(params.pop("type"))
|
|
80
|
+
value: Any = params.pop("column", None) or params.pop("columns", None)
|
|
81
|
+
else: # shorthand form: {not_null: id} or {row_count: {min: 1}}
|
|
82
|
+
if len(entry) != 1:
|
|
83
|
+
raise DefinitionError(f"ambiguous check entry on {model!r}: {entry!r}; use the {{type: ...}} form")
|
|
84
|
+
check_type, value = next(iter(entry.items()))
|
|
85
|
+
check_type = str(check_type)
|
|
86
|
+
if check_type not in CHECK_TYPES:
|
|
87
|
+
raise DefinitionError(
|
|
88
|
+
f"unknown check type {check_type!r} on {model!r}; expected one of {sorted(CHECK_TYPES)}"
|
|
89
|
+
)
|
|
90
|
+
params = {}
|
|
91
|
+
if isinstance(value, dict): # {accepted_values: {column: status, values: [...]}}
|
|
92
|
+
params = dict(value)
|
|
93
|
+
value = params.pop("column", None) or params.pop("columns", None)
|
|
94
|
+
elif check_type not in _COLUMN_SHORTHAND:
|
|
95
|
+
raise DefinitionError(f"check {check_type!r} on {model!r} needs a config mapping")
|
|
96
|
+
|
|
97
|
+
if check_type not in CHECK_TYPES:
|
|
98
|
+
raise DefinitionError(f"unknown check type {check_type!r} on {model!r}; expected one of {sorted(CHECK_TYPES)}")
|
|
99
|
+
severity = str(params.pop("severity", "error"))
|
|
100
|
+
if severity not in SEVERITIES:
|
|
101
|
+
raise DefinitionError(f"unknown check severity {severity!r} on {model!r}; expected one of {sorted(SEVERITIES)}")
|
|
102
|
+
return CheckSpec(type=check_type, columns=_as_columns(value), severity=severity, params=params)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def parse_checks(value: Any, model: str) -> tuple[CheckSpec, ...]:
|
|
106
|
+
"""Normalise a model's ``checks:`` config into :class:`CheckSpec` tuples."""
|
|
107
|
+
if value is None:
|
|
108
|
+
return ()
|
|
109
|
+
if isinstance(value, CheckSpec):
|
|
110
|
+
return (value,)
|
|
111
|
+
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
|
|
112
|
+
raise DefinitionError(f"checks on {model!r} must be a list")
|
|
113
|
+
return tuple(entry if isinstance(entry, CheckSpec) else _parse_one(entry, model) for entry in value)
|