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.
Files changed (74) hide show
  1. interlace/__init__.py +26 -0
  2. interlace/checks/__init__.py +10 -0
  3. interlace/checks/builtin.py +152 -0
  4. interlace/checks/runner.py +103 -0
  5. interlace/checks/spec.py +113 -0
  6. interlace/cli/__init__.py +7 -0
  7. interlace/cli/main.py +1072 -0
  8. interlace/config/__init__.py +7 -0
  9. interlace/config/config.py +172 -0
  10. interlace/contracts.py +35 -0
  11. interlace/dsl/__init__.py +16 -0
  12. interlace/dsl/decorators.py +222 -0
  13. interlace/dsl/discovery.py +75 -0
  14. interlace/dsl/sql_config.py +47 -0
  15. interlace/engines/__init__.py +16 -0
  16. interlace/engines/base.py +86 -0
  17. interlace/engines/duckdb.py +249 -0
  18. interlace/engines/postgres.py +157 -0
  19. interlace/engines/quack.py +113 -0
  20. interlace/engines/registry.py +125 -0
  21. interlace/exceptions.py +66 -0
  22. interlace/exports.py +145 -0
  23. interlace/graph/__init__.py +17 -0
  24. interlace/graph/column_lineage.py +69 -0
  25. interlace/graph/dag.py +81 -0
  26. interlace/graph/project.py +242 -0
  27. interlace/graph/selectors.py +45 -0
  28. interlace/ir/__init__.py +24 -0
  29. interlace/ir/canonicalize.py +71 -0
  30. interlace/ir/fingerprint.py +55 -0
  31. interlace/ir/relation.py +71 -0
  32. interlace/ir/schema.py +23 -0
  33. interlace/plan/__init__.py +23 -0
  34. interlace/plan/apply.py +527 -0
  35. interlace/plan/differ.py +211 -0
  36. interlace/plan/plan.py +177 -0
  37. interlace/plan/resolve.py +74 -0
  38. interlace/plan/run.py +79 -0
  39. interlace/project.py +278 -0
  40. interlace/py.typed +0 -0
  41. interlace/runtime/__init__.py +6 -0
  42. interlace/runtime/handles.py +48 -0
  43. interlace/runtime/python_model.py +175 -0
  44. interlace/scaffold.py +83 -0
  45. interlace/scheduler/__init__.py +17 -0
  46. interlace/scheduler/engine.py +66 -0
  47. interlace/scheduler/triggers.py +84 -0
  48. interlace/scheduler/worker.py +154 -0
  49. interlace/service/__init__.py +7 -0
  50. interlace/service/app.py +836 -0
  51. interlace/service/auth.py +41 -0
  52. interlace/state/__init__.py +18 -0
  53. interlace/state/interval.py +134 -0
  54. interlace/state/janitor.py +142 -0
  55. interlace/state/snapshot.py +47 -0
  56. interlace/state/store.py +906 -0
  57. interlace/strategies/__init__.py +64 -0
  58. interlace/strategies/base.py +71 -0
  59. interlace/strategies/full.py +38 -0
  60. interlace/strategies/full_merge.py +98 -0
  61. interlace/strategies/incremental_by_time.py +65 -0
  62. interlace/strategies/merge_by_key.py +69 -0
  63. interlace/strategies/scd_type_2.py +122 -0
  64. interlace/strategies/view.py +33 -0
  65. interlace/streaming/__init__.py +18 -0
  66. interlace/streaming/log.py +298 -0
  67. interlace/streaming/materializer.py +166 -0
  68. interlace/streaming/schema.py +218 -0
  69. interlaced-2.0.0a2.dist-info/METADATA +216 -0
  70. interlaced-2.0.0a2.dist-info/RECORD +74 -0
  71. interlaced-2.0.0a2.dist-info/WHEEL +5 -0
  72. interlaced-2.0.0a2.dist-info/entry_points.txt +2 -0
  73. interlaced-2.0.0a2.dist-info/licenses/LICENSE +21 -0
  74. 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
@@ -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)
@@ -0,0 +1,7 @@
1
+ """Command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from interlace.cli.main import app, main
6
+
7
+ __all__ = ["app", "main"]