interlaced 2.4.0__tar.gz → 2.4.1__tar.gz

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 (137) hide show
  1. {interlaced-2.4.0/src/interlaced.egg-info → interlaced-2.4.1}/PKG-INFO +1 -1
  2. {interlaced-2.4.0 → interlaced-2.4.1}/pyproject.toml +2 -1
  3. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/cli/main.py +3 -1
  4. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/config/config.py +3 -0
  5. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/dsl/discovery.py +69 -10
  6. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/graph/project.py +10 -3
  7. interlaced-2.4.1/src/interlace/ir/macros.py +154 -0
  8. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/plan/apply.py +9 -6
  9. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/project.py +6 -2
  10. {interlaced-2.4.0 → interlaced-2.4.1/src/interlaced.egg-info}/PKG-INFO +1 -1
  11. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlaced.egg-info/SOURCES.txt +1 -0
  12. {interlaced-2.4.0 → interlaced-2.4.1}/LICENSE +0 -0
  13. {interlaced-2.4.0 → interlaced-2.4.1}/MANIFEST.in +0 -0
  14. {interlaced-2.4.0 → interlaced-2.4.1}/README.md +0 -0
  15. {interlaced-2.4.0 → interlaced-2.4.1}/setup.cfg +0 -0
  16. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/__init__.py +0 -0
  17. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/checks/__init__.py +0 -0
  18. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/checks/builtin.py +0 -0
  19. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/checks/runner.py +0 -0
  20. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/checks/spec.py +0 -0
  21. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/cli/__init__.py +0 -0
  22. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/config/__init__.py +0 -0
  23. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/contracts.py +0 -0
  24. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/dsl/__init__.py +0 -0
  25. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/dsl/decorators.py +0 -0
  26. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/dsl/sql_config.py +0 -0
  27. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/engines/__init__.py +0 -0
  28. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/engines/adbc.py +0 -0
  29. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/engines/base.py +0 -0
  30. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/engines/bigquery.py +0 -0
  31. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/engines/duckdb.py +0 -0
  32. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/engines/postgres.py +0 -0
  33. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/engines/quack.py +0 -0
  34. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/engines/redshift.py +0 -0
  35. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/engines/registry.py +0 -0
  36. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/engines/snowflake.py +0 -0
  37. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/engines/spark.py +0 -0
  38. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/exceptions.py +0 -0
  39. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/graph/__init__.py +0 -0
  40. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/graph/column_lineage.py +0 -0
  41. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/graph/dag.py +0 -0
  42. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/graph/selectors.py +0 -0
  43. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/ir/__init__.py +0 -0
  44. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/ir/canonicalize.py +0 -0
  45. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/ir/fingerprint.py +0 -0
  46. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/ir/relation.py +0 -0
  47. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/plan/__init__.py +0 -0
  48. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/plan/differ.py +0 -0
  49. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/plan/plan.py +0 -0
  50. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/plan/resolve.py +0 -0
  51. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/plan/run.py +0 -0
  52. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/py.typed +0 -0
  53. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/query.py +0 -0
  54. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/runtime/__init__.py +0 -0
  55. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/runtime/handles.py +0 -0
  56. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/runtime/python_model.py +0 -0
  57. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/scaffold.py +0 -0
  58. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/scheduler/__init__.py +0 -0
  59. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/scheduler/engine.py +0 -0
  60. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/scheduler/triggers.py +0 -0
  61. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/scheduler/worker.py +0 -0
  62. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/service/__init__.py +0 -0
  63. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/service/app.py +0 -0
  64. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/service/auth.py +0 -0
  65. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/service/ui/app.css +0 -0
  66. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/service/ui/favicon.svg +0 -0
  67. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/service/ui/index.html +0 -0
  68. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/service/ui/js/api.js +0 -0
  69. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/service/ui/js/app.js +0 -0
  70. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/service/ui/js/dag.js +0 -0
  71. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/service/ui/js/timeline.js +0 -0
  72. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/service/ui/js/ui.js +0 -0
  73. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/service/ui/js/views/checks.js +0 -0
  74. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/service/ui/js/views/environments.js +0 -0
  75. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/service/ui/js/views/lineage.js +0 -0
  76. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/service/ui/js/views/models.js +0 -0
  77. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/service/ui/js/views/overview.js +0 -0
  78. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/service/ui/js/views/plan.js +0 -0
  79. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/service/ui/js/views/query.js +0 -0
  80. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/service/ui/js/views/runs.js +0 -0
  81. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/service/ui/js/views/streams.js +0 -0
  82. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/service/ui/js/views/system.js +0 -0
  83. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/sinks.py +0 -0
  84. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/sources/__init__.py +0 -0
  85. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/sources/auth.py +0 -0
  86. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/sources/rest.py +0 -0
  87. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/state/__init__.py +0 -0
  88. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/state/interval.py +0 -0
  89. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/state/janitor.py +0 -0
  90. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/state/snapshot.py +0 -0
  91. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/state/store.py +0 -0
  92. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/strategies/__init__.py +0 -0
  93. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/strategies/append.py +0 -0
  94. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/strategies/base.py +0 -0
  95. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/strategies/full_merge.py +0 -0
  96. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/strategies/hash_merge.py +0 -0
  97. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/strategies/incremental.py +0 -0
  98. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/strategies/merge.py +0 -0
  99. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/strategies/replace.py +0 -0
  100. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/strategies/replace_in_place.py +0 -0
  101. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/strategies/scd.py +0 -0
  102. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/strategies/view.py +0 -0
  103. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/streaming/__init__.py +0 -0
  104. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/streaming/log.py +0 -0
  105. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/streaming/materializer.py +0 -0
  106. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/streaming/schema.py +0 -0
  107. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/events/README.md +0 -0
  108. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/events/generate.py +0 -0
  109. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/events/interlace.yaml +0 -0
  110. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/events/models/events.py +0 -0
  111. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/events/models/events_by_minute.sql +0 -0
  112. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/events/models/events_by_type.sql +0 -0
  113. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/events/models/top_users.sql +0 -0
  114. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/events/models/user_spend.sql +0 -0
  115. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/events/template.yaml +0 -0
  116. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/github/README.md +0 -0
  117. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/github/interlace.yaml +0 -0
  118. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/github/models/github_issues.py +0 -0
  119. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/github/models/issues_by_state.sql +0 -0
  120. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/github/template.yaml +0 -0
  121. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/postgres/README.md +0 -0
  122. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/postgres/docker-compose.yml +0 -0
  123. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/postgres/init/seed.sql +0 -0
  124. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/postgres/interlace.yaml +0 -0
  125. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/postgres/models/orders.py +0 -0
  126. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/postgres/models/orders_by_status.sql +0 -0
  127. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/postgres/template.yaml +0 -0
  128. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/quickstart/README.md +0 -0
  129. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/quickstart/interlace.yaml +0 -0
  130. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/quickstart/models/enriched_events.py +0 -0
  131. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/quickstart/models/event_summary.sql +0 -0
  132. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/quickstart/models/raw_events.sql +0 -0
  133. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlace/templates/quickstart/template.yaml +0 -0
  134. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlaced.egg-info/dependency_links.txt +0 -0
  135. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlaced.egg-info/entry_points.txt +0 -0
  136. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlaced.egg-info/requires.txt +0 -0
  137. {interlaced-2.4.0 → interlaced-2.4.1}/src/interlaced.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: interlaced
3
- Version: 2.4.0
3
+ Version: 2.4.1
4
4
  Summary: Python and SQL models in one DAG. Transformation, orchestration and durable streaming in one process, on DuckDB and Postgres.
5
5
  Author-email: Mark <mark@interlace.sh>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "interlaced"
7
- version = "2.4.0"
7
+ version = "2.4.1"
8
8
  description = "Python and SQL models in one DAG. Transformation, orchestration and durable streaming in one process, on DuckDB and Postgres."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -180,6 +180,7 @@ markers = [
180
180
  "integration: Integration tests (slower, require database)",
181
181
  "slow: Slow tests (may take a long time)",
182
182
  "requires_db: Needs a reachable external database (e.g. Postgres)",
183
+ "requires_network: Needs the public internet (e.g. an example reading a remote CSV)",
183
184
  ]
184
185
  asyncio_mode = "auto"
185
186
  asyncio_default_fixture_loop_scope = "function"
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import asyncio
6
6
  import contextlib
7
+ import time
7
8
  from datetime import datetime
8
9
  from pathlib import Path
9
10
  from typing import Any
@@ -467,6 +468,7 @@ async def _execute(
467
468
  restate=restate,
468
469
  )
469
470
  progress = _build_progress(plan_result)
471
+ started = time.perf_counter()
470
472
  try:
471
473
  with progress.progress if progress else contextlib.nullcontext():
472
474
  result = await apply_plan(
@@ -487,7 +489,7 @@ async def _execute(
487
489
  await _render_empty_incrementals(result, compiled, engines)
488
490
  verb = "Restated" if restate else "Ran"
489
491
  console.print(
490
- f"[green]{verb} {len(set(result.built))} model(s) ({len(result.built)} task(s)); "
492
+ f"[green]{verb} {len(set(result.built))} model(s) in {time.perf_counter() - started:.2f}s; "
491
493
  f"promoted {result.promoted} to '{environment}'.[/green]"
492
494
  )
493
495
  finally:
@@ -145,6 +145,9 @@ class ProjectConfig(BaseModel):
145
145
  # This is T0 federation (all SQL still runs in DuckDB) — see docs/architecture/MULTI_ENGINE.md.
146
146
  attach: dict[str, str] = Field(default_factory=dict)
147
147
  model_paths: list[str] = Field(default_factory=lambda: ["models"])
148
+ # Directories of CREATE MACRO definitions, expanded into every model at compile
149
+ # time (see interlace.ir.macros). Missing directories are ignored.
150
+ macro_paths: list[str] = Field(default_factory=lambda: ["macros"])
148
151
  # How many models build concurrently. Lower it to serialise a build end-to-end
149
152
  # when diagnosing a crash or a warehouse that dislikes parallel writers; `run`
150
153
  # and `apply` also take --parallelism, which wins over this.
@@ -9,8 +9,10 @@ cleared first for a clean load.
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
+ import contextlib
12
13
  import importlib.util
13
14
  import sys
15
+ from collections.abc import Iterator
14
16
  from pathlib import Path
15
17
  from typing import Any
16
18
 
@@ -18,22 +20,61 @@ from interlace.checks.spec import parse_checks
18
20
  from interlace.dsl.decorators import REGISTRY, ModelDef, _as_columns, _as_tuple, validate_materialise
19
21
  from interlace.dsl.sql_config import extract_sql_config
20
22
  from interlace.exceptions import DefinitionError, InterlaceError
23
+ from interlace.ir.macros import Macro, parse_macros
21
24
 
22
25
 
23
26
  def discover_models(root: Path, model_paths: list[str], default_dialect: str) -> list[ModelDef]:
24
27
  REGISTRY.clear()
25
- for relative in model_paths:
28
+ imported = set(sys.modules)
29
+ try:
30
+ for relative in model_paths:
31
+ base = root / relative
32
+ if not base.is_dir():
33
+ continue
34
+ for sql_file in sorted(base.rglob("*.sql")):
35
+ config, sql = extract_sql_config(sql_file.read_text())
36
+ REGISTRY.register_model(_sql_model(_model_name(base, sql_file), sql, config, default_dialect))
37
+ for py_file in sorted(base.rglob("*.py")):
38
+ if py_file.name.startswith("_"):
39
+ continue
40
+ _import_module(base, py_file)
41
+ finally:
42
+ _forget_project_modules(root, imported)
43
+ return list(REGISTRY.models.values())
44
+
45
+
46
+ def _forget_project_modules(root: Path, before: set[str]) -> None:
47
+ """Drop the project's own helper modules from the import cache.
48
+
49
+ A helper is imported under whatever plain name it has (``_macros``), so leaving it
50
+ cached would hand the *next* project — a reload under ``interlace serve``, another
51
+ project in the same process — the first one's version of a file with the same name.
52
+ Only modules loaded from inside this project during this pass are dropped."""
53
+ for name in set(sys.modules) - before:
54
+ module = sys.modules.get(name)
55
+ origin = getattr(module, "__file__", None)
56
+ if origin and Path(origin).is_relative_to(root):
57
+ del sys.modules[name]
58
+
59
+
60
+ def discover_macros(root: Path, macro_paths: list[str], default_dialect: str) -> dict[str, Macro]:
61
+ """Parse every ``CREATE MACRO`` under the macro paths, keyed by casefolded name."""
62
+ macros: dict[str, Macro] = {}
63
+ for relative in macro_paths:
26
64
  base = root / relative
27
65
  if not base.is_dir():
28
66
  continue
29
- for sql_file in sorted(base.rglob("*.sql")):
30
- config, sql = extract_sql_config(sql_file.read_text())
31
- REGISTRY.register_model(_sql_model(_model_name(base, sql_file), sql, config, default_dialect))
32
- for py_file in sorted(base.rglob("*.py")):
33
- if py_file.name.startswith("_"):
34
- continue
35
- _import_module(base, py_file)
36
- return list(REGISTRY.models.values())
67
+ for macro_file in sorted(base.rglob("*.sql")):
68
+ source = str(macro_file.relative_to(root))
69
+ for macro in parse_macros(macro_file.read_text(), default_dialect, source):
70
+ key = macro.name.casefold()
71
+ if key in macros:
72
+ raise DefinitionError(
73
+ f"macro {macro.name!r} is defined twice: {macros[key].source} and {source}",
74
+ details={"macro": macro.name},
75
+ )
76
+ macros[key] = macro
77
+ return macros
37
78
 
38
79
 
39
80
  def _sql_model(default_name: str, sql: str, config: dict[str, Any], default_dialect: str) -> ModelDef:
@@ -89,6 +130,18 @@ def _relative_to_cwd(file: Path) -> str:
89
130
  return str(file)
90
131
 
91
132
 
133
+ @contextlib.contextmanager
134
+ def _importable(directory: Path) -> Iterator[None]:
135
+ """Put ``directory`` at the front of ``sys.path``, and take it back off."""
136
+ entry = str(directory)
137
+ sys.path.insert(0, entry)
138
+ try:
139
+ yield
140
+ finally:
141
+ with contextlib.suppress(ValueError):
142
+ sys.path.remove(entry)
143
+
144
+
92
145
  def _import_module(base: Path, file: Path) -> None:
93
146
  module_name = "interlace_model_" + "_".join(file.relative_to(base).with_suffix("").parts)
94
147
  spec = importlib.util.spec_from_file_location(module_name, file)
@@ -97,7 +150,13 @@ def _import_module(base: Path, file: Path) -> None:
97
150
  module = importlib.util.module_from_spec(spec)
98
151
  sys.modules[module_name] = module
99
152
  try:
100
- spec.loader.exec_module(module)
153
+ # The model's own directory goes on the path for the duration of the import, so a
154
+ # model can `from _shared import ...` a helper sitting next to it — the closest
155
+ # thing to a macro. Files starting with `_` are already skipped as models, which
156
+ # is only useful if they can be imported. Removed again straight after: a project's
157
+ # helpers must not leak into the import path of everything that follows.
158
+ with _importable(file.parent):
159
+ spec.loader.exec_module(module)
101
160
  except InterlaceError:
102
161
  raise # a bad @model config is already a clean, actionable error — don't rewrap it
103
162
  except Exception as exc:
@@ -12,7 +12,7 @@ from __future__ import annotations
12
12
 
13
13
  import inspect
14
14
  import textwrap
15
- from collections.abc import Iterable
15
+ from collections.abc import Iterable, Mapping
16
16
  from dataclasses import dataclass, field
17
17
 
18
18
  from sqlglot import exp
@@ -23,6 +23,7 @@ from interlace.exceptions import CompilationError, DefinitionError
23
23
  from interlace.graph.dag import DependencyGraph
24
24
  from interlace.ir.canonicalize import parse, table_references
25
25
  from interlace.ir.fingerprint import canonical_sql, data_fingerprint, metadata_fingerprint
26
+ from interlace.ir.macros import Macro, expand_macros
26
27
  from interlace.ir.relation import TableRef
27
28
 
28
29
  _PHYSICAL_PREFIX = "interlace__"
@@ -93,7 +94,7 @@ def _physical_table(name: str, fingerprint: str, catalog: str | None) -> TableRe
93
94
 
94
95
 
95
96
  def _resolve_dependencies(
96
- model: ModelDef, names: set[str], default_dialect: str
97
+ model: ModelDef, names: set[str], default_dialect: str, macros: Mapping[str, Macro]
97
98
  ) -> tuple[tuple[str, ...], exp.Expression | None, str]:
98
99
  dialect = model.dialect or default_dialect
99
100
  deps: list[str] = []
@@ -115,6 +116,10 @@ def _resolve_dependencies(
115
116
  raise CompilationError(
116
117
  f"model {model.name!r}: {exc.message}", details={**exc.details, "model": model.name}
117
118
  ) from exc
119
+ # Before anything reads the AST: the fingerprint is canonical SQL, so expanding
120
+ # here is what makes a macro edit rebuild its callers, and what lets a macro body
121
+ # reference a model and have that count as a dependency.
122
+ ast = expand_macros(ast, macros, model.name)
118
123
  for ref in table_references(ast):
119
124
  if ref in names:
120
125
  add(ref)
@@ -149,12 +154,14 @@ def compile_models(
149
154
  known_engines: set[str] | None = None,
150
155
  catalog: str | None = None,
151
156
  checks: Iterable[CheckDef] = (),
157
+ macros: Mapping[str, Macro] | None = None,
152
158
  ) -> CompiledProject:
153
159
  """Compile models into a fingerprinted, topologically-ordered project.
154
160
 
155
161
  ``checks`` are ``@check``-decorated Python functions, attached by model name.
156
162
  ``engine_dialects`` maps engine name → sqlglot dialect (used when a model
157
163
  omits ``dialect``). ``known_engines`` validates model ``engine`` pins.
164
+ ``macros`` are expanded into every model's AST before it is fingerprinted.
158
165
  """
159
166
  definitions = {m.name: m for m in models}
160
167
  names = set(definitions)
@@ -178,7 +185,7 @@ def compile_models(
178
185
  )
179
186
  # Authoring dialect: explicit model dialect, else the engine's, else project default.
180
187
  model_default_dialect = dialects_by_engine.get(engine, default_dialect)
181
- deps, ast, dialect = _resolve_dependencies(definition, names, model_default_dialect)
188
+ deps, ast, dialect = _resolve_dependencies(definition, names, model_default_dialect, macros or {})
182
189
  resolved[name] = (deps, ast, dialect, engine)
183
190
  graph.add_node(name)
184
191
  for dep in deps:
@@ -0,0 +1,154 @@
1
+ """SQL macros: one definition, expanded into the IR before anything else sees it.
2
+
3
+ A macro is written in SQL, in the engine's own ``CREATE MACRO`` syntax::
4
+
5
+ CREATE MACRO cents_to_dollars(amount) AS (amount / 100)::numeric(16, 2);
6
+
7
+ and called like a function from any model::
8
+
9
+ SELECT cents_to_dollars(subtotal) AS subtotal FROM raw_orders
10
+
11
+ The call is substituted for the macro's body while the model is compiled — before the
12
+ fingerprint, before lineage, before transpilation. That ordering is the whole design:
13
+
14
+ - **The fingerprint covers the macro.** A model's fingerprint is its canonical SQL, and
15
+ the expansion is part of it, so editing a macro rebuilds every model that calls it.
16
+ A macro created in the warehouse instead would be invisible to the fingerprint, and a
17
+ changed one would leave stale tables behind with nothing to notice.
18
+ - **Lineage sees through it.** Column lineage reads the AST, and the AST no longer has
19
+ an opaque function call in it.
20
+ - **One definition, every engine.** The expansion happens in dialect-agnostic AST, so
21
+ the transpiler renders it per engine. This is the job dbt does with adapter dispatch —
22
+ ``postgres__cents_to_dollars``, ``bigquery__cents_to_dollars`` — and it is not needed
23
+ here: ``(amount / 100)`` becomes integer division on Postgres by itself, so sqlglot
24
+ emits ``CAST(amount AS DOUBLE PRECISION) / NULLIF(100, 0)`` and the macro stays one
25
+ line.
26
+
27
+ The cost, and it is a real one: the macro exists at build time, not in the warehouse.
28
+ Someone querying the tables by hand cannot call it.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from collections.abc import Mapping
34
+ from dataclasses import dataclass
35
+
36
+ import sqlglot
37
+ from sqlglot import exp
38
+
39
+ from interlace.exceptions import DefinitionError
40
+
41
+ _MAX_DEPTH = 10 # macros may call macros; this is the runaway/recursion stop
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class Macro:
46
+ """A parsed ``CREATE MACRO``: its name, parameter names, and body expression."""
47
+
48
+ name: str
49
+ params: tuple[str, ...]
50
+ body: exp.Expression
51
+ source: str # the file it came from, for error messages
52
+
53
+
54
+ def parse_macros(sql: str, dialect: str, source: str) -> list[Macro]:
55
+ """Parse the ``CREATE MACRO`` statements in one file."""
56
+ try:
57
+ statements = sqlglot.parse(sql, read=dialect)
58
+ except Exception as exc: # noqa: BLE001 — sqlglot raises a family of parse errors
59
+ raise DefinitionError(f"could not parse macros in {source}: {exc}", details={"source": source}) from exc
60
+
61
+ macros: list[Macro] = []
62
+ for statement in statements:
63
+ if statement is None:
64
+ continue
65
+ if isinstance(statement, exp.Command):
66
+ # sqlglot could not parse it — a table macro (AS TABLE SELECT ...) lands here
67
+ raise DefinitionError(
68
+ f"{source}: could not read this as a macro definition: {statement.sql()[:80]!r}. A macro is a "
69
+ f"scalar expression — CREATE MACRO name(args) AS <expression>. A table macro has no call site "
70
+ f"to expand into; write it as a model.",
71
+ details={"source": source},
72
+ )
73
+ udf = statement.this if isinstance(statement, exp.Create) else None
74
+ if not isinstance(udf, exp.UserDefinedFunction):
75
+ raise DefinitionError(
76
+ f"{source}: expected only CREATE MACRO statements, found {type(statement).__name__.upper()}",
77
+ details={"source": source},
78
+ )
79
+ body = statement.expression
80
+ if body is None or isinstance(body, exp.Query):
81
+ raise DefinitionError(
82
+ f"{source}: macro {udf.this.name!r} must be a scalar expression — a table macro "
83
+ f"(AS TABLE SELECT ...) has no call site to expand into; write it as a model",
84
+ details={"source": source, "macro": udf.this.name},
85
+ )
86
+ # the UDF's `this` is a Table node wrapping the identifier, so the name is a
87
+ # level down from where UserDefinedFunction.name looks
88
+ name = udf.this.name if isinstance(udf.this, exp.Expression) else str(udf.this)
89
+ macros.append(
90
+ Macro(
91
+ name=name,
92
+ params=tuple(param.name for param in udf.expressions),
93
+ body=body,
94
+ source=source,
95
+ )
96
+ )
97
+ return macros
98
+
99
+
100
+ def expand_macros(ast: exp.Expression, macros: Mapping[str, Macro], model: str) -> exp.Expression:
101
+ """Substitute every macro call in ``ast`` for its body. Returns a new expression."""
102
+ if not macros:
103
+ return ast
104
+ expanded = ast
105
+ for _ in range(_MAX_DEPTH):
106
+ expanded, hits = _expand_once(expanded, macros, model)
107
+ if not hits:
108
+ return expanded
109
+ raise DefinitionError(
110
+ f"model {model!r}: macro expansion did not settle after {_MAX_DEPTH} passes — a macro calls itself, "
111
+ f"directly or through another",
112
+ details={"model": model},
113
+ )
114
+
115
+
116
+ def _expand_once(ast: exp.Expression, macros: Mapping[str, Macro], model: str) -> tuple[exp.Expression, int]:
117
+ hits = 0
118
+
119
+ def substitute(node: exp.Expression) -> exp.Expression:
120
+ nonlocal hits
121
+ if not isinstance(node, exp.Anonymous):
122
+ return node
123
+ macro = macros.get(node.name.casefold())
124
+ if macro is None:
125
+ return node # an ordinary engine function
126
+ args = list(node.expressions)
127
+ if len(args) != len(macro.params):
128
+ raise DefinitionError(
129
+ f"model {model!r}: macro {macro.name!r} takes {len(macro.params)} argument(s) "
130
+ f"({', '.join(macro.params) or 'none'}), called with {len(args)}",
131
+ details={"model": model, "macro": macro.name, "source": macro.source},
132
+ )
133
+ hits += 1
134
+ return exp.paren(_bind(macro, args))
135
+
136
+ return ast.transform(substitute, copy=True), hits
137
+
138
+
139
+ def _bind(macro: Macro, args: list[exp.Expression]) -> exp.Expression:
140
+ """The macro's body with each parameter reference replaced by its argument."""
141
+ body = macro.body.copy()
142
+ if not macro.params:
143
+ return body
144
+ by_name = {param.casefold(): arg for param, arg in zip(macro.params, args, strict=True)}
145
+
146
+ def replace(node: exp.Expression) -> exp.Expression:
147
+ if isinstance(node, exp.Column) and not node.table:
148
+ argument = by_name.get(node.name.casefold())
149
+ if argument is not None:
150
+ return argument.copy()
151
+ return node
152
+
153
+ bound: exp.Expression = body.transform(replace, copy=False)
154
+ return bound
@@ -733,16 +733,19 @@ async def apply(
733
733
  return found
734
734
 
735
735
  # Checks that read *other* models (relationships, sql) add scheduling edges the
736
- # data DAG doesn't have — and may point "backwards", which would deadlock the
737
- # per-model events. Keep a check edge only when it agrees with the topological
738
- # order (dep built before the reader); a back edge is dropped (its check may run
739
- # a beat early, far better than a hang). Data edges always agree, so they all stay.
740
- topo = {name: index for index, name in enumerate(compiled.graph.topological_sort())}
736
+ # data DAG doesn't have. Keep one unless it would close a cycle — which it does
737
+ # exactly when the reader is already an ancestor of what its check reads (a check
738
+ # pointing downstream, e.g. order_items testing against orders, which is built
739
+ # from order_items). Those are dropped: the check runs before its target exists
740
+ # and fails, but a cycle would hang the whole apply, and the model can point its
741
+ # check at an upstream instead. Everything else is a plain sibling reference and
742
+ # is enforced — dropping those on a topological-order heuristic failed checks
743
+ # whose target simply happened to sort later.
741
744
  blocking: dict[str, set[str]] = {}
742
745
  for name in per_model:
743
746
  deps = in_plan_ancestors(name)
744
747
  for ref in _check_references(compiled.models[name], compiled):
745
- if ref in per_model and topo.get(ref, -1) < topo.get(name, 0):
748
+ if ref in per_model and name not in compiled.graph.ancestors(ref):
746
749
  deps.add(ref)
747
750
  blocking[name] = deps
748
751
 
@@ -9,17 +9,18 @@ from __future__ import annotations
9
9
 
10
10
  import os
11
11
  import re
12
- from dataclasses import dataclass
12
+ from dataclasses import dataclass, field
13
13
  from pathlib import Path
14
14
 
15
15
  from interlace.config.config import CONFIG_FILE, EngineConfig, ProjectConfig, SecretConfig, load_config
16
16
  from interlace.dsl.decorators import REGISTRY, CheckDef, ModelDef, StreamDef
17
- from interlace.dsl.discovery import discover_models
17
+ from interlace.dsl.discovery import discover_macros, discover_models
18
18
  from interlace.engines.base import EngineAdapter
19
19
  from interlace.engines.duckdb import DuckDBAdapter
20
20
  from interlace.engines.registry import EngineRegistry
21
21
  from interlace.exceptions import ConfigurationError
22
22
  from interlace.graph.project import CompiledProject, compile_models
23
+ from interlace.ir.macros import Macro
23
24
  from interlace.state.store import SqliteStateStore
24
25
  from interlace.streaming.log import SqliteStreamLog
25
26
 
@@ -86,6 +87,7 @@ class Project:
86
87
  models: list[ModelDef]
87
88
  checks: list[CheckDef]
88
89
  streams: list[StreamDef]
90
+ macros: dict[str, Macro] = field(default_factory=dict)
89
91
 
90
92
  @classmethod
91
93
  def load(cls, root: Path | str) -> Project:
@@ -98,6 +100,7 @@ class Project:
98
100
  models=models,
99
101
  checks=list(REGISTRY.checks),
100
102
  streams=list(REGISTRY.streams.values()),
103
+ macros=discover_macros(root, config.macro_paths, config.default_dialect),
101
104
  )
102
105
 
103
106
  def compile(self) -> CompiledProject:
@@ -109,6 +112,7 @@ class Project:
109
112
  engine_dialects={name: cfg.resolved_dialect() for name, cfg in engine_cfgs.items()},
110
113
  known_engines=set(engine_cfgs),
111
114
  checks=self.checks,
115
+ macros=self.macros,
112
116
  )
113
117
 
114
118
  def open_engine(self, name: str | None = None) -> EngineAdapter:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: interlaced
3
- Version: 2.4.0
3
+ Version: 2.4.1
4
4
  Summary: Python and SQL models in one DAG. Transformation, orchestration and durable streaming in one process, on DuckDB and Postgres.
5
5
  Author-email: Mark <mark@interlace.sh>
6
6
  License-Expression: MIT
@@ -41,6 +41,7 @@ src/interlace/graph/selectors.py
41
41
  src/interlace/ir/__init__.py
42
42
  src/interlace/ir/canonicalize.py
43
43
  src/interlace/ir/fingerprint.py
44
+ src/interlace/ir/macros.py
44
45
  src/interlace/ir/relation.py
45
46
  src/interlace/plan/__init__.py
46
47
  src/interlace/plan/apply.py
File without changes
File without changes
File without changes
File without changes