interlaced 2.4.0__tar.gz → 2.5.0__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 (144) hide show
  1. {interlaced-2.4.0/src/interlaced.egg-info → interlaced-2.5.0}/PKG-INFO +3 -2
  2. {interlaced-2.4.0 → interlaced-2.5.0}/README.md +2 -1
  3. {interlaced-2.4.0 → interlaced-2.5.0}/pyproject.toml +3 -1
  4. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/cli/main.py +194 -63
  5. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/config/config.py +6 -3
  6. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/dsl/decorators.py +33 -0
  7. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/dsl/discovery.py +73 -10
  8. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/engines/base.py +20 -0
  9. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/engines/bigquery.py +3 -0
  10. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/engines/duckdb.py +27 -1
  11. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/engines/postgres.py +30 -1
  12. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/engines/redshift.py +3 -0
  13. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/engines/snowflake.py +3 -0
  14. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/engines/spark.py +3 -0
  15. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/exceptions.py +4 -0
  16. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/graph/project.py +28 -6
  17. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/ir/fingerprint.py +12 -0
  18. interlaced-2.5.0/src/interlace/ir/macros.py +154 -0
  19. interlaced-2.5.0/src/interlace/physical/__init__.py +1 -0
  20. interlaced-2.5.0/src/interlace/physical/annotate.py +80 -0
  21. interlaced-2.5.0/src/interlace/physical/ddl.py +177 -0
  22. interlaced-2.5.0/src/interlace/physical/drift.py +80 -0
  23. interlaced-2.5.0/src/interlace/physical/reconcile.py +76 -0
  24. interlaced-2.5.0/src/interlace/physical/spec.py +308 -0
  25. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/plan/apply.py +238 -44
  26. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/plan/differ.py +69 -2
  27. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/plan/plan.py +43 -1
  28. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/project.py +23 -2
  29. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/query.py +31 -6
  30. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/service/app.py +244 -16
  31. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/service/auth.py +12 -1
  32. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/service/ui/app.css +0 -1
  33. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/service/ui/js/api.js +34 -28
  34. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/service/ui/js/app.js +17 -7
  35. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/service/ui/js/views/checks.js +9 -2
  36. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/service/ui/js/views/environments.js +10 -2
  37. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/service/ui/js/views/models.js +50 -0
  38. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/service/ui/js/views/overview.js +70 -49
  39. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/service/ui/js/views/plan.js +19 -1
  40. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/service/ui/js/views/system.js +51 -0
  41. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/state/janitor.py +148 -2
  42. interlaced-2.5.0/src/interlace/state/locks.py +59 -0
  43. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/state/snapshot.py +3 -0
  44. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/state/store.py +151 -3
  45. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/streaming/log.py +22 -1
  46. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/streaming/materializer.py +39 -13
  47. {interlaced-2.4.0 → interlaced-2.5.0/src/interlaced.egg-info}/PKG-INFO +3 -2
  48. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlaced.egg-info/SOURCES.txt +8 -0
  49. {interlaced-2.4.0 → interlaced-2.5.0}/LICENSE +0 -0
  50. {interlaced-2.4.0 → interlaced-2.5.0}/MANIFEST.in +0 -0
  51. {interlaced-2.4.0 → interlaced-2.5.0}/setup.cfg +0 -0
  52. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/__init__.py +0 -0
  53. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/checks/__init__.py +0 -0
  54. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/checks/builtin.py +0 -0
  55. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/checks/runner.py +0 -0
  56. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/checks/spec.py +0 -0
  57. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/cli/__init__.py +0 -0
  58. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/config/__init__.py +0 -0
  59. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/contracts.py +0 -0
  60. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/dsl/__init__.py +0 -0
  61. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/dsl/sql_config.py +0 -0
  62. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/engines/__init__.py +0 -0
  63. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/engines/adbc.py +0 -0
  64. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/engines/quack.py +0 -0
  65. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/engines/registry.py +0 -0
  66. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/graph/__init__.py +0 -0
  67. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/graph/column_lineage.py +0 -0
  68. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/graph/dag.py +0 -0
  69. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/graph/selectors.py +0 -0
  70. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/ir/__init__.py +0 -0
  71. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/ir/canonicalize.py +0 -0
  72. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/ir/relation.py +0 -0
  73. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/plan/__init__.py +0 -0
  74. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/plan/resolve.py +0 -0
  75. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/plan/run.py +0 -0
  76. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/py.typed +0 -0
  77. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/runtime/__init__.py +0 -0
  78. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/runtime/handles.py +0 -0
  79. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/runtime/python_model.py +0 -0
  80. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/scaffold.py +0 -0
  81. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/scheduler/__init__.py +0 -0
  82. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/scheduler/engine.py +0 -0
  83. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/scheduler/triggers.py +0 -0
  84. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/scheduler/worker.py +0 -0
  85. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/service/__init__.py +0 -0
  86. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/service/ui/favicon.svg +0 -0
  87. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/service/ui/index.html +0 -0
  88. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/service/ui/js/dag.js +0 -0
  89. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/service/ui/js/timeline.js +0 -0
  90. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/service/ui/js/ui.js +0 -0
  91. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/service/ui/js/views/lineage.js +0 -0
  92. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/service/ui/js/views/query.js +0 -0
  93. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/service/ui/js/views/runs.js +0 -0
  94. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/service/ui/js/views/streams.js +0 -0
  95. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/sinks.py +0 -0
  96. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/sources/__init__.py +0 -0
  97. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/sources/auth.py +0 -0
  98. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/sources/rest.py +0 -0
  99. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/state/__init__.py +0 -0
  100. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/state/interval.py +0 -0
  101. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/strategies/__init__.py +0 -0
  102. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/strategies/append.py +0 -0
  103. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/strategies/base.py +0 -0
  104. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/strategies/full_merge.py +0 -0
  105. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/strategies/hash_merge.py +0 -0
  106. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/strategies/incremental.py +0 -0
  107. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/strategies/merge.py +0 -0
  108. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/strategies/replace.py +0 -0
  109. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/strategies/replace_in_place.py +0 -0
  110. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/strategies/scd.py +0 -0
  111. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/strategies/view.py +0 -0
  112. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/streaming/__init__.py +0 -0
  113. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/streaming/schema.py +0 -0
  114. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/events/README.md +0 -0
  115. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/events/generate.py +0 -0
  116. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/events/interlace.yaml +0 -0
  117. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/events/models/events.py +0 -0
  118. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/events/models/events_by_minute.sql +0 -0
  119. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/events/models/events_by_type.sql +0 -0
  120. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/events/models/top_users.sql +0 -0
  121. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/events/models/user_spend.sql +0 -0
  122. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/events/template.yaml +0 -0
  123. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/github/README.md +0 -0
  124. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/github/interlace.yaml +0 -0
  125. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/github/models/github_issues.py +0 -0
  126. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/github/models/issues_by_state.sql +0 -0
  127. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/github/template.yaml +0 -0
  128. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/postgres/README.md +0 -0
  129. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/postgres/docker-compose.yml +0 -0
  130. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/postgres/init/seed.sql +0 -0
  131. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/postgres/interlace.yaml +0 -0
  132. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/postgres/models/orders.py +0 -0
  133. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/postgres/models/orders_by_status.sql +0 -0
  134. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/postgres/template.yaml +0 -0
  135. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/quickstart/README.md +0 -0
  136. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/quickstart/interlace.yaml +0 -0
  137. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/quickstart/models/enriched_events.py +0 -0
  138. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/quickstart/models/event_summary.sql +0 -0
  139. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/quickstart/models/raw_events.sql +0 -0
  140. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlace/templates/quickstart/template.yaml +0 -0
  141. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlaced.egg-info/dependency_links.txt +0 -0
  142. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlaced.egg-info/entry_points.txt +0 -0
  143. {interlaced-2.4.0 → interlaced-2.5.0}/src/interlaced.egg-info/requires.txt +0 -0
  144. {interlaced-2.4.0 → interlaced-2.5.0}/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.5.0
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
@@ -192,7 +192,8 @@ $ interlace plan
192
192
  - **`interlace env rollback`** repoints an environment's views at any earlier promotion — nothing
193
193
  rebuilds, the views move; every apply records a generation, so a bad deploy reverts in one
194
194
  command. `interlace gc` removes snapshots no environment references (reference-aware: tables
195
- shared through reuse survive).
195
+ shared through reuse survive). `interlace reset --yes` wipes owned state for a fresh apply
196
+ without dropping `materialise: table` / `file` destinations.
196
197
 
197
198
  ## Streaming
198
199
 
@@ -126,7 +126,8 @@ $ interlace plan
126
126
  - **`interlace env rollback`** repoints an environment's views at any earlier promotion — nothing
127
127
  rebuilds, the views move; every apply records a generation, so a bad deploy reverts in one
128
128
  command. `interlace gc` removes snapshots no environment references (reference-aware: tables
129
- shared through reuse survive).
129
+ shared through reuse survive). `interlace reset --yes` wipes owned state for a fresh apply
130
+ without dropping `materialise: table` / `file` destinations.
130
131
 
131
132
  ## Streaming
132
133
 
@@ -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.5.0"
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"
@@ -138,6 +138,7 @@ module = [
138
138
  "sqlglot.*",
139
139
  "pyarrow.*",
140
140
  "yaml",
141
+ "adbc_driver_postgresql.*", # optional 'adbc' extra — absent in the base env
141
142
  "adbc_driver_snowflake.*", # optional 'adbc-snowflake' extra — absent in the base env
142
143
  "adbc_driver_bigquery.*", # optional 'adbc-bigquery' extra — absent in the base env
143
144
  "pyspark.*", # optional 'spark' extra — absent in the base env
@@ -180,6 +181,7 @@ markers = [
180
181
  "integration: Integration tests (slower, require database)",
181
182
  "slow: Slow tests (may take a long time)",
182
183
  "requires_db: Needs a reachable external database (e.g. Postgres)",
184
+ "requires_network: Needs the public internet (e.g. an example reading a remote CSV)",
183
185
  ]
184
186
  asyncio_mode = "auto"
185
187
  asyncio_default_fixture_loop_scope = "function"
@@ -4,6 +4,8 @@ from __future__ import annotations
4
4
 
5
5
  import asyncio
6
6
  import contextlib
7
+ import os
8
+ import time
7
9
  from datetime import datetime
8
10
  from pathlib import Path
9
11
  from typing import Any
@@ -15,10 +17,11 @@ from rich.markup import escape
15
17
  from rich.progress import Progress, SpinnerColumn, TaskID, TextColumn, TimeElapsedColumn
16
18
  from rich.table import Table
17
19
 
18
- from interlace.exceptions import CheckError, ConfigurationError, InterlaceError, QueryError, SelectionError
20
+ from interlace.exceptions import CheckError, ConfigurationError, InterlaceError, LockError, QueryError, SelectionError
19
21
  from interlace.graph.column_lineage import column_impact, column_lineage, split_target
20
22
  from interlace.graph.project import CompiledProject
21
23
  from interlace.graph.selectors import select_models, wants_state
24
+ from interlace.physical.annotate import annotate_plan
22
25
  from interlace.plan.apply import ApplyResult
23
26
  from interlace.plan.apply import apply as apply_plan
24
27
  from interlace.plan.differ import diff
@@ -29,6 +32,7 @@ from interlace.scaffold import list_templates, scaffold_project
29
32
  from interlace.scheduler.engine import TriggerEngine, build_triggers
30
33
  from interlace.scheduler.worker import drain
31
34
  from interlace.sinks import target_ref
35
+ from interlace.state.locks import hold_apply_lock
32
36
  from interlace.streaming import ensure_stream_tables, flush_streams
33
37
 
34
38
  # pretty_exceptions_enable=False: let InterlaceError propagate out of app() so main()
@@ -107,9 +111,9 @@ _END = typer.Option("", "--end", help="Window end (ISO), for incremental models.
107
111
  _FORWARD_ONLY = typer.Option(
108
112
  False,
109
113
  "--forward-only",
110
- help="Modified history-keeping models (merge/full_merge/scd/incremental) carry their history "
111
- "forward: it is copied to the new version, the new logic applies to the copy, and checks gate "
112
- "before views move. Requires a shape-compatible change.",
114
+ help="Modified history-keeping models (merge/full_merge/hash_merge/scd/incremental) carry their "
115
+ "history forward: it is copied to the new version, the new logic applies to the copy, and "
116
+ "checks gate before views move. Requires a shape-compatible change.",
113
117
  )
114
118
  _JSON = typer.Option(False, "--json", help="Emit JSON instead of a table (for scripts and CI).")
115
119
  _PARALLELISM = typer.Option(
@@ -301,17 +305,22 @@ async def _plan(
301
305
  project = Project.load(path)
302
306
  compiled = project.compile()
303
307
  state = await project.open_state()
308
+ engines = project.open_engines()
304
309
  try:
305
310
  promoted = await _promoted_if_needed(state, environment, select)
306
311
  result = await diff(
307
312
  compiled, environment, state, select=_selection(compiled, select, promoted), forward_only=forward_only
308
313
  )
314
+ await annotate_plan(result, compiled, engines)
309
315
  if as_json:
310
316
  _emit_json(_plan_dict(result, environment))
311
317
  else:
312
318
  _render(result, environment)
319
+ if result.blocking:
320
+ raise typer.Exit(1)
313
321
  finally:
314
322
  await state.close()
323
+ engines.close()
315
324
 
316
325
 
317
326
  def _plan_dict(plan: Plan, environment: str) -> dict:
@@ -334,6 +343,12 @@ def _plan_dict(plan: Plan, environment: str) -> dict:
334
343
  f"{t.model}: {t.source.name} -> {t.target.name} ({t.via} -> {t.table.schema}.{t.table.name})"
335
344
  for t in plan.transfers
336
345
  ],
346
+ "physical": [
347
+ f"{'+' if change.op == 'add' else '-'} {change.kind} {change.name}"
348
+ for action in plan.physical
349
+ for change in action.changes
350
+ ],
351
+ "drift": [note.message for note in plan.drift],
337
352
  }
338
353
 
339
354
 
@@ -358,6 +373,7 @@ async def _apply(
358
373
  plan_result = await diff(
359
374
  compiled, environment, state, select=_selection(compiled, select, promoted), forward_only=forward_only
360
375
  )
376
+ await annotate_plan(plan_result, compiled, engines)
361
377
  _render(plan_result, environment)
362
378
  if plan_result.is_empty:
363
379
  return
@@ -370,18 +386,22 @@ async def _apply(
370
386
  progress = _build_progress(plan_result)
371
387
  try:
372
388
  with progress.progress if progress else contextlib.nullcontext():
373
- result = await apply_plan(
374
- plan_result,
375
- compiled=compiled,
376
- engines=engines,
377
- state=state,
378
- base_path=project.root,
379
- on_progress=progress,
380
- parallelism=parallelism or project.config.parallelism, # --parallelism wins over config
381
- )
389
+ async with hold_apply_lock(state, owner=f"cli:{os.getpid()}:apply"):
390
+ result = await apply_plan(
391
+ plan_result,
392
+ compiled=compiled,
393
+ engines=engines,
394
+ state=state,
395
+ base_path=project.root,
396
+ on_progress=progress,
397
+ parallelism=parallelism or project.config.parallelism, # --parallelism wins over config
398
+ )
382
399
  except CheckError as exc:
383
400
  console.print(f"[red]{escape(exc.message)}[/red]")
384
401
  raise typer.Exit(1) from exc
402
+ except LockError as exc:
403
+ console.print(f"[red]{escape(exc.message)}[/red]")
404
+ raise typer.Exit(1) from exc
385
405
  _render_build_results(result, compiled)
386
406
  _render_checks(result)
387
407
  await _render_empty_incrementals(result, compiled, engines)
@@ -467,27 +487,32 @@ async def _execute(
467
487
  restate=restate,
468
488
  )
469
489
  progress = _build_progress(plan_result)
490
+ started = time.perf_counter()
470
491
  try:
471
492
  with progress.progress if progress else contextlib.nullcontext():
472
- result = await apply_plan(
473
- plan_result,
474
- compiled=compiled,
475
- engines=engines,
476
- state=state,
477
- base_path=project.root,
478
- on_progress=progress,
479
- parallelism=parallelism or project.config.parallelism, # --parallelism wins over config
480
- )
493
+ async with hold_apply_lock(state, owner=f"cli:{os.getpid()}:run"):
494
+ result = await apply_plan(
495
+ plan_result,
496
+ compiled=compiled,
497
+ engines=engines,
498
+ state=state,
499
+ base_path=project.root,
500
+ on_progress=progress,
501
+ parallelism=parallelism or project.config.parallelism, # --parallelism wins over config
502
+ )
481
503
  except CheckError as exc:
482
504
  console.print(f"[red]{escape(exc.message)}[/red]")
483
505
  raise typer.Exit(1) from exc
506
+ except LockError as exc:
507
+ console.print(f"[red]{escape(exc.message)}[/red]")
508
+ raise typer.Exit(1) from exc
484
509
  _render_build_results(result, compiled)
485
510
  _render_checks(result)
486
511
  _render_warnings(plan_result)
487
512
  await _render_empty_incrementals(result, compiled, engines)
488
513
  verb = "Restated" if restate else "Ran"
489
514
  console.print(
490
- f"[green]{verb} {len(set(result.built))} model(s) ({len(result.built)} task(s)); "
515
+ f"[green]{verb} {len(set(result.built))} model(s) in {time.perf_counter() - started:.2f}s; "
491
516
  f"promoted {result.promoted} to '{environment}'.[/green]"
492
517
  )
493
518
  finally:
@@ -519,7 +544,8 @@ async def _gc(path: Path, grace: str, dry_run: bool) -> None:
519
544
  engines = project.open_engines()
520
545
  state = await project.open_state()
521
546
  try:
522
- result = await run_gc(state, engines=engines, grace=parsed_grace, dry_run=dry_run)
547
+ async with hold_apply_lock(state, owner=f"cli:{os.getpid()}:gc"):
548
+ result = await run_gc(state, engines=engines, grace=parsed_grace, dry_run=dry_run)
523
549
  verb = "Would remove" if dry_run else "Removed"
524
550
  console.print(
525
551
  f"{verb} {len(result.removed_snapshots)} snapshot(s), dropped {len(result.dropped_tables)} table(s); "
@@ -547,6 +573,77 @@ async def _gc(path: Path, grace: str, dry_run: bool) -> None:
547
573
  engines.close()
548
574
 
549
575
 
576
+ @app.command()
577
+ def reset(
578
+ path: Path = _PATH,
579
+ yes: bool = typer.Option(False, "--yes", help="Required. Confirm the reset."),
580
+ dry_run: bool = typer.Option(False, "--dry-run", help="Report what would be removed without touching anything."),
581
+ as_json: bool = _JSON,
582
+ ) -> None:
583
+ """Wipe Interlace-owned state for a fresh apply.
584
+
585
+ Drops environment views, snapshot tables, runs, events, and the stream log.
586
+ External table/file destinations are not dropped; terminal models stay
587
+ recorded so the next apply will not re-deliver into them. API keys are kept.
588
+ """
589
+ asyncio.run(_reset(path, yes, dry_run, as_json))
590
+
591
+
592
+ async def _reset(path: Path, yes: bool, dry_run: bool, as_json: bool) -> None:
593
+ from dataclasses import asdict
594
+
595
+ from interlace.state.janitor import reset as run_reset
596
+
597
+ if not yes and not dry_run:
598
+ console.print(
599
+ "This wipes Interlace-owned views, snapshot tables, runs, and stream state. "
600
+ "External table/file destinations are not dropped."
601
+ )
602
+ console.print("Pass [bold]--yes[/bold] to proceed (or [bold]--dry-run[/bold] to preview).")
603
+ raise typer.Exit(1)
604
+ project = Project.load(path)
605
+ compiled = project.compile()
606
+ keep = [model.name for model in compiled.models.values() if model.is_terminal]
607
+ engines = project.open_engines()
608
+ state = await project.open_state()
609
+ stream_log = None
610
+ stream_path = project.root / project.config.stream_path
611
+ if project.streams or stream_path.exists():
612
+ stream_log = await project.open_stream_log()
613
+ try:
614
+ async with hold_apply_lock(state, owner=f"cli:{os.getpid()}:reset"):
615
+ result = await run_reset(
616
+ state,
617
+ engines=engines,
618
+ keep_models=keep,
619
+ stream_log=stream_log,
620
+ clear_streams=bool(project.streams),
621
+ dry_run=dry_run,
622
+ )
623
+ if as_json:
624
+ _emit_json(asdict(result))
625
+ return
626
+ verb = "Would drop" if dry_run else "Dropped"
627
+ console.print(
628
+ f"{verb} {len(result.dropped_views)} view(s), {len(result.dropped_schemas)} schema(s); "
629
+ f"{'would clear' if dry_run else 'cleared'} {result.cleared_snapshots} snapshot(s)."
630
+ )
631
+ if result.kept_terminals:
632
+ console.print(
633
+ f"[dim]Left {len(result.kept_terminals)} terminal model(s) recorded "
634
+ f"(table/file destinations were not dropped).[/dim]"
635
+ )
636
+ if result.stream_log_cleared:
637
+ console.print("[dim]Stream log cleared.[/dim]")
638
+ if not dry_run:
639
+ console.print("[dim]Next apply rebuilds owned models from scratch.[/dim]")
640
+ finally:
641
+ if stream_log is not None:
642
+ await stream_log.close()
643
+ await state.close()
644
+ engines.close()
645
+
646
+
550
647
  @app.command()
551
648
  def scheduler(
552
649
  environment: str = _ENV,
@@ -574,16 +671,17 @@ async def _scheduler(environment: str, path: Path, interval: float, once: bool)
574
671
  await trigger_engine.tick(datetime.now())
575
672
  # materialize anything published since the last tick, then run models
576
673
  # (an API-only `serve --no-scheduler` process relies on this loop to flush)
577
- if stream_log is not None:
578
- await flush_streams(project.streams, stream_log, engines.get())
579
- ran = await drain(
580
- state,
581
- compiled,
582
- engines=engines,
583
- environment=environment,
584
- base_path=project.root,
585
- parallelism=project.config.parallelism,
586
- )
674
+ async with hold_apply_lock(state, owner=f"cli:{os.getpid()}:scheduler"):
675
+ if stream_log is not None:
676
+ await flush_streams(project.streams, stream_log, engines.get())
677
+ ran = await drain(
678
+ state,
679
+ compiled,
680
+ engines=engines,
681
+ environment=environment,
682
+ base_path=project.root,
683
+ parallelism=project.config.parallelism,
684
+ )
587
685
  if ran:
588
686
  console.print(f"[green]ran {ran} scheduled run(s) in '{environment}'[/green]")
589
687
  if stream_log is not None:
@@ -641,6 +739,11 @@ def serve(
641
739
  True, "--scheduler/--no-scheduler", help="Run the scheduler loop in this process (combined daemon)."
642
740
  ),
643
741
  interval: float = typer.Option(60.0, "--interval", help="Seconds between scheduler ticks."),
742
+ allow_open: bool = typer.Option(
743
+ False,
744
+ "--allow-open",
745
+ help="Permit a non-loopback bind with no API keys (insecure). Refused without this flag.",
746
+ ),
644
747
  ) -> None:
645
748
  """Run the interlace daemon: HTTP API + scheduler in one process (requires the `service` extra).
646
749
 
@@ -657,15 +760,22 @@ def serve(
657
760
  if bound != port:
658
761
  console.print(f"[yellow]port {port} is in use — serving on {bound}[/yellow]")
659
762
  port = bound
660
- # Auth is open until the first API key exists (see auth.py). That's fine on
661
- # loopback; on a routable bind it exposes every endpoint — including the SQL
662
- # console and apply/gc — to the network unauthenticated. Warn loudly.
763
+ # Auth is open until the first API key exists (see auth.py). Fine on loopback;
764
+ # on a routable bind it exposes apply/gc/query unauthenticated — refuse unless
765
+ # --allow-open (or keys already exist).
663
766
  if host not in ("127.0.0.1", "localhost", "::1"):
664
767
  keyed = asyncio.run(_has_api_keys(path))
768
+ if not keyed and not allow_open:
769
+ console.print(
770
+ f"[bold red]refusing[/bold red] to serve on [bold]{host}[/bold] with no API keys — "
771
+ "create one ([bold]interlace apikey create <name> --scope admin[/bold]) or pass "
772
+ "[bold]--allow-open[/bold] (insecure)."
773
+ )
774
+ raise typer.Exit(1)
665
775
  if not keyed:
666
776
  console.print(
667
777
  f"[bold red]WARNING[/bold red] serving on [bold]{host}[/bold] with no API keys — "
668
- "the API is open to the network. Create one first: [bold]interlace apikey create <name> --scope admin[/bold]"
778
+ "the API is open to the network (--allow-open)."
669
779
  )
670
780
  console.print(f"UI at [bold cyan]http://{host}:{port}/ui[/bold cyan]")
671
781
  token = quack_token
@@ -819,7 +929,8 @@ async def _env_drop(name: str, path: Path, force: bool) -> None:
819
929
  if not await state.get_environment(name):
820
930
  console.print(f"No environment {name!r}.")
821
931
  raise typer.Exit(1)
822
- dropped = await drop_environment(state, engines=engines, environment=name)
932
+ async with hold_apply_lock(state, owner=f"cli:{os.getpid()}:env-drop"):
933
+ dropped = await drop_environment(state, engines=engines, environment=name)
823
934
  console.print(f"Dropped environment [bold]{name}[/bold] ({len(dropped)} view(s) removed).")
824
935
  console.print("[dim]Its snapshots are now unreferenced — `interlace gc` reclaims their tables.[/dim]")
825
936
  finally:
@@ -867,10 +978,14 @@ async def _env_rollback(name: str, path: Path, to: int | None, history: bool, as
867
978
  return
868
979
  engines = project.open_engines()
869
980
  try:
870
- result = await rollback_environment(state, engines=engines, environment=name, to_generation=to)
981
+ async with hold_apply_lock(state, owner=f"cli:{os.getpid()}:env-rollback"):
982
+ result = await rollback_environment(state, engines=engines, environment=name, to_generation=to)
871
983
  except PlanError as exc:
872
984
  console.print(f"[red]{escape(exc.message)}[/red]")
873
985
  raise typer.Exit(1) from exc
986
+ except LockError as exc:
987
+ console.print(f"[red]{escape(exc.message)}[/red]")
988
+ raise typer.Exit(1) from exc
874
989
  if as_json:
875
990
  _emit_json(result)
876
991
  return
@@ -1407,39 +1522,55 @@ async def _apikey_list(path: Path) -> None:
1407
1522
 
1408
1523
 
1409
1524
  def _render(plan: Plan, environment: str) -> None:
1410
- if plan.is_empty:
1525
+ if not plan.changes and not plan.physical and not plan.transfers and not plan.drift and not plan.warnings:
1411
1526
  console.print(f"No changes for [bold]{environment}[/bold].")
1412
1527
  return
1413
1528
  reused = {snapshot.name for snapshot in plan.reuses}
1414
- table = _table(f"Plan · {environment}")
1415
- table.add_column("Model")
1416
- table.add_column("Change")
1417
- table.add_column("Category")
1418
- table.add_column("Build")
1419
- change_colours = {"added": "green", "removed": "red", "modified": "yellow"}
1420
- category_colours = {"breaking": "red", "non_breaking": "green", "forward_only": "cyan"}
1421
- for change in plan.changes:
1422
- build = (
1423
- "[cyan]reuse[/]"
1424
- if change.name in reused
1425
- else ("[dim]—[/]" if change.change_type is ChangeType.REMOVED else "rebuild")
1426
- )
1427
- kind = change.change_type.value
1428
- category = change.category.value if change.category else None
1429
- table.add_row(
1430
- change.name,
1431
- f"[{change_colours.get(kind, 'white')}]{kind}[/]",
1432
- f"[{category_colours.get(category, 'white')}]{category}[/]" if category else "[dim]—[/]",
1433
- build,
1434
- )
1435
- console.print(table)
1529
+ if plan.changes:
1530
+ table = _table(f"Plan · {environment}")
1531
+ table.add_column("Model")
1532
+ table.add_column("Change")
1533
+ table.add_column("Category")
1534
+ table.add_column("Build")
1535
+ change_colours = {"added": "green", "removed": "red", "modified": "yellow"}
1536
+ category_colours = {"breaking": "red", "non_breaking": "green", "forward_only": "cyan"}
1537
+ for change in plan.changes:
1538
+ build = (
1539
+ "[cyan]reuse[/]"
1540
+ if change.name in reused
1541
+ else ("[dim]—[/]" if change.change_type is ChangeType.REMOVED else "rebuild")
1542
+ )
1543
+ kind = change.change_type.value
1544
+ category = change.category.value if change.category else None
1545
+ table.add_row(
1546
+ change.name,
1547
+ f"[{change_colours.get(kind, 'white')}]{kind}[/]",
1548
+ f"[{category_colours.get(category, 'white')}]{category}[/]" if category else "[dim]—[/]",
1549
+ build,
1550
+ )
1551
+ console.print(table)
1436
1552
  if reused:
1437
1553
  console.print(f"[dim]{len(reused)} model(s) have provably identical output — reusing existing tables.[/dim]")
1554
+ for action in plan.physical:
1555
+ for physical in action.changes:
1556
+ mark = "[green]+[/]" if physical.op == "add" else "[red]-[/]"
1557
+ console.print(f"{mark} {physical.kind} {physical.name} [dim]{action.name}[/]")
1558
+ for warning in action.warnings:
1559
+ console.print(f"[yellow]note:[/yellow] {warning}")
1438
1560
  for transfer in plan.transfers:
1439
1561
  console.print(
1440
1562
  f"[cyan]transfer[/cyan] {transfer.model}: {transfer.source.name} → {transfer.target.name} "
1441
1563
  f"({transfer.via} → {transfer.table.schema}.{transfer.table.name})"
1442
1564
  )
1565
+ for note in plan.drift:
1566
+ style = "red" if note.blocking else "yellow"
1567
+ console.print(f"[{style}]drift:[/{style}] {note.message}")
1568
+ for warning in plan.warnings:
1569
+ if any(warning == note.message for note in plan.drift):
1570
+ continue
1571
+ if any(warning in action.warnings for action in plan.physical):
1572
+ continue
1573
+ console.print(f"[yellow]note:[/yellow] {warning}")
1443
1574
 
1444
1575
 
1445
1576
  def _flatten_exceptions(exc: BaseException) -> list[BaseException]:
@@ -79,9 +79,9 @@ _TYPE_DIALECT = {
79
79
  class EngineConfig(BaseModel):
80
80
  """One named execution engine (warehouse gateway).
81
81
 
82
- DuckDB-family types (``duckdb`` / ``ducklake`` / ``quack``) are fully supported.
83
- Additional types are reserved for remote adapters (Postgres, Snowflake, …);
84
- declaring them fails at open until an adapter ships.
82
+ DuckDB-family types (``duckdb`` / ``ducklake`` / ``quack``) and ``postgres`` are
83
+ the tested set. ``spark`` is beta; ``motherduck`` / ``redshift`` / ``snowflake`` /
84
+ ``bigquery`` are alpha (wired and dialect-correct, not yet live-validated).
85
85
  """
86
86
 
87
87
  type: str = "duckdb"
@@ -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.
@@ -8,12 +8,22 @@ Compilation, planning, and execution happen later over the registry. UK spelling
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ import re
11
12
  from collections.abc import Callable, Sequence
12
13
  from dataclasses import dataclass, field
13
14
  from typing import Any
14
15
 
15
16
  from interlace.checks.spec import CheckSpec, parse_checks
16
17
  from interlace.exceptions import DefinitionError
18
+ from interlace.physical.spec import (
19
+ ConstraintSpec,
20
+ IndexSpec,
21
+ SchemaPolicy,
22
+ parse_constraints,
23
+ parse_indexes,
24
+ parse_schema_policy,
25
+ validate_physical_allowed,
26
+ )
17
27
  from interlace.sinks import FILE_FORMATS
18
28
 
19
29
  ModelFn = Callable[..., Any]
@@ -24,6 +34,8 @@ ModelFn = Callable[..., Any]
24
34
  _MATERIALISATIONS = frozenset({"virtual", "view", "ephemeral", "table", "file"})
25
35
  _KEYED_STRATEGIES = frozenset({"merge", "full_merge", "hash_merge", "scd"})
26
36
  _DRIFT_MODES = frozenset({"evolve", "reject", "quarantine"})
37
+ # Stream names become SQL identifiers / watermark keys — keep them safe to quote.
38
+ _STREAM_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
27
39
 
28
40
 
29
41
  def _as_tuple(value: str | Sequence[str]) -> tuple[str, ...]:
@@ -113,6 +125,9 @@ class ModelDef:
113
125
  environments: tuple[str, ...] = ("prod",) # which environments actually deliver a terminal model
114
126
  schedule: dict[str, str] | None = None # {"cron": "0 * * * *"} or {"every": "5m"} for `interlace serve`
115
127
  checks: tuple[CheckSpec, ...] = () # data-quality checks; error severity gates promotion
128
+ indexes: tuple[IndexSpec, ...] = () # physical indexes; not part of the data fingerprint
129
+ constraints: tuple[ConstraintSpec, ...] = () # physical constraints; engine-enforced, not checks
130
+ schema_policy: SchemaPolicy = SchemaPolicy() # external-table drift: columns / indexes / constraints
116
131
 
117
132
  def __post_init__(self) -> None:
118
133
  # `@model(checks=…)` normalises through parse_checks, but a ModelDef built
@@ -125,6 +140,10 @@ class ModelDef:
125
140
  # (TypeError on a CheckSpec; a dict silently degraded to its keys). Always run,
126
141
  # so `checks=[]` normalises to the declared tuple rather than staying a list.
127
142
  self.checks = parse_checks(self.checks, self.name)
143
+ self.indexes = parse_indexes(self.indexes, self.name)
144
+ self.constraints = parse_constraints(self.constraints, self.name)
145
+ self.schema_policy = parse_schema_policy(self.schema_policy, self.name)
146
+ validate_physical_allowed(self.name, self.materialise, self.indexes, self.constraints, self.schema_policy)
128
147
 
129
148
  @property
130
149
  def is_terminal(self) -> bool:
@@ -202,6 +221,9 @@ def model(
202
221
  columns: dict[str, str | None] | Sequence[str] | None = None,
203
222
  schedule: dict[str, str] | None = None,
204
223
  checks: Sequence[dict[str, Any] | CheckSpec] | None = None,
224
+ indexes: Sequence[dict[str, Any] | IndexSpec] | None = None,
225
+ constraints: Sequence[dict[str, Any] | ConstraintSpec] | None = None,
226
+ schema: dict[str, str] | None = None,
205
227
  export: Any = None, # removed in 2.0 — kept only to raise a migration error
206
228
  ) -> Callable[[ModelFn], ModelFn]:
207
229
  """Declare a Python model. The function returns a ``Relation`` (or composes one).
@@ -218,6 +240,10 @@ def model(
218
240
  spelling for both surfaces — e.g. ``checks=[{"not_null": "customer_id"},
219
241
  {"row_count": {"min": 1}}]``. A :class:`~interlace.CheckSpec` also works if you
220
242
  prefer the typed form.
243
+
244
+ ``indexes`` and ``constraints`` are physical DDL applied after the table
245
+ exists. They do not change the data fingerprint. ``schema`` is the external
246
+ table drift policy (``columns`` / ``indexes`` / ``constraints``).
221
247
  """
222
248
  model_name = name or "<model>"
223
249
  if export is not None:
@@ -260,6 +286,9 @@ def model(
260
286
  columns=_as_columns(columns),
261
287
  schedule=schedule,
262
288
  checks=parse_checks(checks, name or fn.__name__),
289
+ indexes=parse_indexes(indexes, name or fn.__name__),
290
+ constraints=parse_constraints(constraints, name or fn.__name__),
291
+ schema_policy=parse_schema_policy(schema, name or fn.__name__),
263
292
  )
264
293
  )
265
294
  return fn
@@ -276,6 +305,10 @@ def stream(
276
305
  on_schema_drift: str = "reject",
277
306
  ) -> Callable[[ModelFn], ModelFn]:
278
307
  """Declare a durable ingestion stream with an HTTP publish endpoint."""
308
+ if not _STREAM_NAME.fullmatch(name):
309
+ raise DefinitionError(
310
+ f"stream name {name!r} must match [A-Za-z_][A-Za-z0-9_]* (it becomes streams.<name> and a watermark key)"
311
+ )
279
312
  if on_schema_drift not in _DRIFT_MODES:
280
313
  raise DefinitionError(f"unknown on_schema_drift {on_schema_drift!r}; expected one of {sorted(_DRIFT_MODES)}")
281
314