interlaced 2.6.0__tar.gz → 2.7.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 (159) hide show
  1. {interlaced-2.6.0/src/interlaced.egg-info → interlaced-2.7.0}/PKG-INFO +1 -1
  2. {interlaced-2.6.0 → interlaced-2.7.0}/pyproject.toml +1 -1
  3. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/cli/main.py +256 -76
  4. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/exceptions.py +12 -0
  5. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/inspect.py +15 -2
  6. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/mcp_server.py +24 -35
  7. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/plan/__init__.py +4 -0
  8. interlaced-2.7.0/src/interlace/plan/comment.py +62 -0
  9. interlaced-2.7.0/src/interlace/plan/orchestrate.py +180 -0
  10. interlaced-2.7.0/src/interlace/plan/table_diff.py +302 -0
  11. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/service/app.py +177 -574
  12. interlaced-2.7.0/src/interlace/service/types.py +425 -0
  13. {interlaced-2.6.0 → interlaced-2.7.0/src/interlaced.egg-info}/PKG-INFO +1 -1
  14. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlaced.egg-info/SOURCES.txt +4 -0
  15. {interlaced-2.6.0 → interlaced-2.7.0}/LICENSE +0 -0
  16. {interlaced-2.6.0 → interlaced-2.7.0}/MANIFEST.in +0 -0
  17. {interlaced-2.6.0 → interlaced-2.7.0}/README.md +0 -0
  18. {interlaced-2.6.0 → interlaced-2.7.0}/setup.cfg +0 -0
  19. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/__init__.py +0 -0
  20. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/cdc/__init__.py +0 -0
  21. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/cdc/decode.py +0 -0
  22. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/cdc/publish.py +0 -0
  23. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/cdc/slot.py +0 -0
  24. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/checks/__init__.py +0 -0
  25. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/checks/builtin.py +0 -0
  26. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/checks/runner.py +0 -0
  27. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/checks/spec.py +0 -0
  28. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/cli/__init__.py +0 -0
  29. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/config/__init__.py +0 -0
  30. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/config/config.py +0 -0
  31. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/connections.py +0 -0
  32. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/contracts.py +0 -0
  33. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/dsl/__init__.py +0 -0
  34. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/dsl/decorators.py +0 -0
  35. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/dsl/discovery.py +0 -0
  36. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/dsl/dynamic.py +0 -0
  37. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/dsl/sql_config.py +0 -0
  38. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/engines/__init__.py +0 -0
  39. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/engines/adbc.py +0 -0
  40. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/engines/base.py +0 -0
  41. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/engines/bigquery.py +0 -0
  42. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/engines/duckdb.py +0 -0
  43. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/engines/postgres.py +0 -0
  44. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/engines/quack.py +0 -0
  45. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/engines/redshift.py +0 -0
  46. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/engines/registry.py +0 -0
  47. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/engines/snowflake.py +0 -0
  48. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/engines/spark.py +0 -0
  49. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/graph/__init__.py +0 -0
  50. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/graph/column_lineage.py +0 -0
  51. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/graph/dag.py +0 -0
  52. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/graph/project.py +0 -0
  53. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/graph/selectors.py +0 -0
  54. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/inputs.py +0 -0
  55. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/ir/__init__.py +0 -0
  56. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/ir/canonicalize.py +0 -0
  57. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/ir/fingerprint.py +0 -0
  58. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/ir/macros.py +0 -0
  59. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/ir/relation.py +0 -0
  60. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/physical/__init__.py +0 -0
  61. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/physical/annotate.py +0 -0
  62. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/physical/ddl.py +0 -0
  63. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/physical/drift.py +0 -0
  64. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/physical/reconcile.py +0 -0
  65. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/physical/spec.py +0 -0
  66. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/plan/apply.py +0 -0
  67. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/plan/differ.py +0 -0
  68. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/plan/plan.py +0 -0
  69. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/plan/resolve.py +0 -0
  70. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/plan/run.py +0 -0
  71. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/project.py +0 -0
  72. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/py.typed +0 -0
  73. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/query.py +0 -0
  74. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/runtime/__init__.py +0 -0
  75. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/runtime/handles.py +0 -0
  76. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/runtime/python_model.py +0 -0
  77. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/scaffold.py +0 -0
  78. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/scheduler/__init__.py +0 -0
  79. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/scheduler/engine.py +0 -0
  80. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/scheduler/triggers.py +0 -0
  81. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/scheduler/worker.py +0 -0
  82. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/service/__init__.py +0 -0
  83. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/service/auth.py +0 -0
  84. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/service/ui/app.css +0 -0
  85. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/service/ui/favicon.svg +0 -0
  86. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/service/ui/index.html +0 -0
  87. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/service/ui/js/api.js +0 -0
  88. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/service/ui/js/app.js +0 -0
  89. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/service/ui/js/dag.js +0 -0
  90. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/service/ui/js/timeline.js +0 -0
  91. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/service/ui/js/ui.js +0 -0
  92. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/service/ui/js/views/checks.js +0 -0
  93. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/service/ui/js/views/environments.js +0 -0
  94. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/service/ui/js/views/lineage.js +0 -0
  95. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/service/ui/js/views/models.js +0 -0
  96. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/service/ui/js/views/overview.js +0 -0
  97. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/service/ui/js/views/plan.js +0 -0
  98. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/service/ui/js/views/query.js +0 -0
  99. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/service/ui/js/views/runs.js +0 -0
  100. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/service/ui/js/views/streams.js +0 -0
  101. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/service/ui/js/views/system.js +0 -0
  102. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/sinks.py +0 -0
  103. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/sources/__init__.py +0 -0
  104. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/sources/auth.py +0 -0
  105. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/sources/rest.py +0 -0
  106. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/state/__init__.py +0 -0
  107. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/state/interval.py +0 -0
  108. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/state/janitor.py +0 -0
  109. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/state/locks.py +0 -0
  110. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/state/snapshot.py +0 -0
  111. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/state/store.py +0 -0
  112. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/strategies/__init__.py +0 -0
  113. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/strategies/append.py +0 -0
  114. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/strategies/base.py +0 -0
  115. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/strategies/full_merge.py +0 -0
  116. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/strategies/hash_merge.py +0 -0
  117. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/strategies/incremental.py +0 -0
  118. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/strategies/merge.py +0 -0
  119. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/strategies/replace.py +0 -0
  120. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/strategies/replace_in_place.py +0 -0
  121. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/strategies/scd.py +0 -0
  122. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/strategies/view.py +0 -0
  123. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/streaming/__init__.py +0 -0
  124. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/streaming/log.py +0 -0
  125. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/streaming/materializer.py +0 -0
  126. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/streaming/schema.py +0 -0
  127. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/templates/events/README.md +0 -0
  128. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/templates/events/generate.py +0 -0
  129. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/templates/events/interlace.yaml +0 -0
  130. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/templates/events/models/events.py +0 -0
  131. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/templates/events/models/events_by_minute.sql +0 -0
  132. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/templates/events/models/events_by_type.sql +0 -0
  133. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/templates/events/models/top_users.sql +0 -0
  134. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/templates/events/models/user_spend.sql +0 -0
  135. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/templates/events/template.yaml +0 -0
  136. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/templates/github/README.md +0 -0
  137. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/templates/github/interlace.yaml +0 -0
  138. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/templates/github/models/github_issues.py +0 -0
  139. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/templates/github/models/issues_by_state.sql +0 -0
  140. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/templates/github/template.yaml +0 -0
  141. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/templates/postgres/README.md +0 -0
  142. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/templates/postgres/docker-compose.yml +0 -0
  143. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/templates/postgres/init/seed.sql +0 -0
  144. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/templates/postgres/interlace.yaml +0 -0
  145. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/templates/postgres/models/orders.py +0 -0
  146. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/templates/postgres/models/orders_by_status.sql +0 -0
  147. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/templates/postgres/template.yaml +0 -0
  148. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/templates/quickstart/README.md +0 -0
  149. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/templates/quickstart/interlace.yaml +0 -0
  150. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/templates/quickstart/models/enriched_events.py +0 -0
  151. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/templates/quickstart/models/event_summary.sql +0 -0
  152. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/templates/quickstart/models/raw_events.sql +0 -0
  153. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/templates/quickstart/template.yaml +0 -0
  154. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/testing/__init__.py +0 -0
  155. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlace/testing/golden.py +0 -0
  156. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlaced.egg-info/dependency_links.txt +0 -0
  157. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlaced.egg-info/entry_points.txt +0 -0
  158. {interlaced-2.6.0 → interlaced-2.7.0}/src/interlaced.egg-info/requires.txt +0 -0
  159. {interlaced-2.6.0 → interlaced-2.7.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.6.0
3
+ Version: 2.7.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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "interlaced"
7
- version = "2.6.0"
7
+ version = "2.7.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"
@@ -17,16 +17,23 @@ from rich.markup import escape
17
17
  from rich.progress import Progress, SpinnerColumn, TaskID, TextColumn, TimeElapsedColumn
18
18
  from rich.table import Table
19
19
 
20
- from interlace.dsl.dynamic import apply_with_registrations
21
- from interlace.exceptions import CheckError, ConfigurationError, InterlaceError, LockError, QueryError, SelectionError
20
+ from interlace.exceptions import (
21
+ CheckError,
22
+ ConfigurationError,
23
+ InterlaceError,
24
+ LockError,
25
+ PlanError,
26
+ QueryError,
27
+ SelectionError,
28
+ )
22
29
  from interlace.graph.column_lineage import column_impact, column_lineage, split_target
23
30
  from interlace.graph.project import CompiledProject
24
- from interlace.graph.selectors import select_models, wants_state
25
- from interlace.physical.annotate import annotate_plan
31
+ from interlace.graph.selectors import select_models
26
32
  from interlace.plan.apply import ApplyResult
27
- from interlace.plan.differ import diff
33
+ from interlace.plan.comment import plan_markdown
34
+ from interlace.plan.orchestrate import compute_plan, plan_and_apply, resolve_selection, run_and_apply
28
35
  from interlace.plan.plan import ChangeType, Plan
29
- from interlace.plan.run import run_plan
36
+ from interlace.plan.table_diff import TableDiff
30
37
  from interlace.project import Project
31
38
  from interlace.scaffold import list_templates, scaffold_project
32
39
  from interlace.scheduler.engine import TriggerEngine, build_triggers
@@ -243,11 +250,6 @@ def _selection(
243
250
  raise typer.Exit(1) from exc
244
251
 
245
252
 
246
- async def _promoted_if_needed(state: Any, environment: str, selectors: list[str]) -> dict[str, str] | None:
247
- """state:modified compares against the target environment's fingerprints."""
248
- return await state.get_environment(environment) if wants_state(selectors) else None
249
-
250
-
251
253
  @app.command()
252
254
  def init(
253
255
  path: Path = typer.Argument(Path("."), help="Directory to initialise."),
@@ -287,9 +289,10 @@ def plan(
287
289
  select: list[str] = _SELECT,
288
290
  forward_only: bool = _FORWARD_ONLY,
289
291
  as_json: bool = _JSON,
292
+ markdown: bool = typer.Option(False, "--markdown", help="Emit a GitHub-flavoured plan comment (CI)."),
290
293
  ) -> None:
291
294
  """Show what apply would change in an environment."""
292
- asyncio.run(_plan(environment, path, select, forward_only, as_json))
295
+ asyncio.run(_plan(environment, path, select, forward_only, as_json, markdown))
293
296
 
294
297
 
295
298
  @app.command()
@@ -305,6 +308,22 @@ def apply(
305
308
  asyncio.run(_apply(environment, path, select, forward_only, force, parallelism))
306
309
 
307
310
 
311
+ @app.command("diff")
312
+ def table_diff_cmd(
313
+ environment: str = _ENV,
314
+ path: Path = _PATH,
315
+ select: list[str] = _SELECT,
316
+ against: str = typer.Option("", "--against", help="Other environment (env mode)."),
317
+ source: str = typer.Option("", "--source", help="Left table, schema.table (table mode)."),
318
+ target: str = typer.Option("", "--target", help="Right table, schema.table (table mode)."),
319
+ on: list[str] = typer.Option([], "--on", help="Join key columns. Default: the model's key, else common columns."),
320
+ limit: int = typer.Option(20, "--limit", "-n", help="Sample rows per category."),
321
+ as_json: bool = _JSON,
322
+ ) -> None:
323
+ """Compare a model across two environments, or two tables on the warehouse."""
324
+ asyncio.run(_table_diff(environment, path, select, against, source, target, on, limit, as_json))
325
+
326
+
308
327
  @app.command()
309
328
  def test(
310
329
  path: Path = _PATH,
@@ -333,24 +352,31 @@ def test(
333
352
 
334
353
 
335
354
  async def _plan(
336
- environment: str, path: Path, select: list[str], forward_only: bool = False, as_json: bool = False
355
+ environment: str,
356
+ path: Path,
357
+ select: list[str],
358
+ forward_only: bool = False,
359
+ as_json: bool = False,
360
+ markdown: bool = False,
337
361
  ) -> None:
338
362
  project = Project.load(path)
339
363
  compiled = project.compile()
340
364
  state = await project.open_state()
341
365
  engines = project.open_engines()
342
366
  try:
343
- promoted = await _promoted_if_needed(state, environment, select)
344
- result = await diff(
345
- compiled, environment, state, select=_selection(compiled, select, promoted), forward_only=forward_only
346
- )
347
- await annotate_plan(result, compiled, engines)
348
- if as_json:
367
+ selected = await resolve_selection(compiled, state, environment, select)
368
+ result = await compute_plan(compiled, environment, state, engines, select=selected, forward_only=forward_only)
369
+ if markdown:
370
+ typer.echo(plan_markdown(result, environment))
371
+ elif as_json:
349
372
  _emit_json(_plan_dict(result, environment))
350
373
  else:
351
374
  _render(result, environment)
352
375
  if result.blocking:
353
376
  raise typer.Exit(1)
377
+ except SelectionError as exc:
378
+ console.print(f"[red]{escape(exc.message)}[/red]")
379
+ raise typer.Exit(1) from exc
354
380
  finally:
355
381
  await state.close()
356
382
  engines.close()
@@ -385,6 +411,132 @@ def _plan_dict(plan: Plan, environment: str) -> dict:
385
411
  }
386
412
 
387
413
 
414
+ def _diff_dict(result: TableDiff) -> dict[str, object]:
415
+ schema = {
416
+ "added": [{"name": name, "type": dtype} for name, dtype in result.schema.added],
417
+ "removed": [{"name": name, "type": dtype} for name, dtype in result.schema.removed],
418
+ "type_changed": [
419
+ {"name": name, "left": left, "right": right} for name, left, right in result.schema.type_changed
420
+ ],
421
+ }
422
+ rows = None
423
+ if result.rows is not None:
424
+ rows = {
425
+ "left_count": result.rows.left_count,
426
+ "right_count": result.rows.right_count,
427
+ "left_only": result.rows.left_only,
428
+ "right_only": result.rows.right_only,
429
+ "changed": result.rows.changed,
430
+ "matched": result.rows.matched,
431
+ "keys": result.rows.keys,
432
+ "left_only_sample": result.rows.left_only_sample,
433
+ "right_only_sample": result.rows.right_only_sample,
434
+ "changed_sample": result.rows.changed_sample,
435
+ }
436
+ return {
437
+ "left": result.left,
438
+ "right": result.right,
439
+ "model": result.model,
440
+ "schema": schema,
441
+ "rows": rows,
442
+ "message": result.message,
443
+ "differs": result.differs,
444
+ }
445
+
446
+
447
+ def _render_diffs(results: list[TableDiff]) -> None:
448
+ for result in results:
449
+ title = result.model or f"{result.left} vs {result.right}"
450
+ if result.message and result.rows is None and result.schema.empty:
451
+ console.print(f"[yellow]{title}[/yellow] {result.message}")
452
+ continue
453
+ table = _table(title)
454
+ table.add_column("Side")
455
+ table.add_column("Relation", style="dim")
456
+ table.add_row("left", result.left)
457
+ table.add_row("right", result.right)
458
+ console.print(table)
459
+ if result.schema.added or result.schema.removed or result.schema.type_changed:
460
+ for name, dtype in result.schema.added:
461
+ console.print(f" [green]+[/] column {name} {dtype}")
462
+ for name, dtype in result.schema.removed:
463
+ console.print(f" [red]-[/] column {name} {dtype}")
464
+ for name, left, right in result.schema.type_changed:
465
+ console.print(f" [yellow]~[/] column {name} {left} → {right}")
466
+ if result.message:
467
+ console.print(f" [yellow]{result.message}[/yellow]")
468
+ if result.rows is None:
469
+ continue
470
+ rows = result.rows
471
+ console.print(
472
+ f" rows left={rows.left_count} right={rows.right_count} "
473
+ f"only-left={rows.left_only} only-right={rows.right_only} changed={rows.changed} "
474
+ f"matched={rows.matched} keys={','.join(rows.keys) or '—'}"
475
+ )
476
+
477
+
478
+ async def _table_diff(
479
+ environment: str,
480
+ path: Path,
481
+ select: list[str],
482
+ against: str,
483
+ source: str,
484
+ target: str,
485
+ on: list[str],
486
+ limit: int,
487
+ as_json: bool,
488
+ ) -> None:
489
+ from interlace.plan.table_diff import diff_environments, diff_tables, parse_table_ref
490
+
491
+ if bool(source) != bool(target):
492
+ console.print("[red]table mode needs both --source and --target[/red]")
493
+ raise typer.Exit(2)
494
+ if not source and not against:
495
+ console.print("[red]env mode needs --against ENV (or pass --source/--target)[/red]")
496
+ raise typer.Exit(2)
497
+ project = Project.load(path)
498
+ compiled = project.compile()
499
+ engines = project.open_engines()
500
+ state = await project.open_state()
501
+ try:
502
+ if source:
503
+ result = await diff_tables(
504
+ engines.get(),
505
+ parse_table_ref(source),
506
+ parse_table_ref(target),
507
+ keys=on or None,
508
+ sample=limit,
509
+ )
510
+ results = [result]
511
+ else:
512
+ selected = await resolve_selection(compiled, state, environment, select)
513
+ results = await diff_environments(
514
+ compiled,
515
+ left_env=environment,
516
+ right_env=against,
517
+ store=state,
518
+ engines=engines,
519
+ select=selected,
520
+ keys=on or None,
521
+ sample=limit,
522
+ )
523
+ if as_json:
524
+ _emit_json([_diff_dict(item) for item in results])
525
+ else:
526
+ if not results:
527
+ console.print("No comparable models (ephemeral/file outputs are skipped).")
528
+ else:
529
+ _render_diffs(results)
530
+ if any(item.differs for item in results):
531
+ raise typer.Exit(1)
532
+ except PlanError as exc:
533
+ console.print(f"[red]{escape(exc.message)}[/red]")
534
+ raise typer.Exit(1) from exc
535
+ finally:
536
+ await state.close()
537
+ engines.close()
538
+
539
+
388
540
  async def _apply(
389
541
  environment: str,
390
542
  path: Path,
@@ -393,50 +545,64 @@ async def _apply(
393
545
  force: bool = False,
394
546
  parallelism: int = 0,
395
547
  ) -> None:
548
+ from interlace.exceptions import BreakingPlanError
549
+
396
550
  project = Project.load(path)
397
551
  compiled = project.compile()
398
552
  engines = project.open_engines()
399
553
  state = await project.open_state()
554
+ tracker: _BuildProgress | None = _BuildProgress() if console.is_terminal else None
555
+ started = False
400
556
  try:
401
- # Stream-fed projects must build without the daemon ever having run:
402
- # declared stream tables are ensured (empty) so models reading them work.
403
- if project.streams:
404
- await ensure_stream_tables(project.streams, engines.get())
405
- promoted = await _promoted_if_needed(state, environment, select)
406
- plan_result = await diff(
407
- compiled, environment, state, select=_selection(compiled, select, promoted), forward_only=forward_only
408
- )
409
- await annotate_plan(plan_result, compiled, engines)
410
- _render(plan_result, environment)
411
- if plan_result.is_empty:
412
- return
413
- if plan_result.has_breaking_changes and not force: # same guard the HTTP API enforces
414
- breaking = ", ".join(
415
- c.name for c in plan_result.changes if c.category is not None and c.category.value == "breaking"
416
- )
417
- console.print(f"[red]plan has breaking changes ({breaking}); re-run with --force to proceed[/red]")
418
- raise typer.Exit(1)
419
- progress = _build_progress(plan_result)
557
+
558
+ async def prepare() -> None:
559
+ # Stream-fed projects must build without the daemon ever having run:
560
+ # declared stream tables are ensured (empty) so models reading them work.
561
+ if project.streams:
562
+ await ensure_stream_tables(project.streams, engines.get())
563
+
564
+ def on_plan(plan: Plan) -> None:
565
+ nonlocal started, tracker
566
+ _render(plan, environment)
567
+ if tracker is not None and not plan.backfills:
568
+ tracker = None
569
+ if tracker is not None:
570
+ tracker.progress.start()
571
+ started = True
572
+
573
+ def on_progress(model: str, event: str, detail: dict[str, Any]) -> None:
574
+ if tracker is not None:
575
+ tracker(model, event, detail)
576
+
420
577
  try:
421
- with progress.progress if progress else contextlib.nullcontext():
422
- async with hold_apply_lock(state, owner=f"cli:{os.getpid()}:apply"):
423
- result = await apply_with_registrations(
424
- plan_result,
425
- compiled=compiled,
426
- engines=engines,
427
- state=state,
428
- base_path=project.root,
429
- on_progress=progress,
430
- connections=project.config.connections,
431
- parallelism=parallelism or project.config.parallelism, # --parallelism wins over config
432
- project=project,
433
- )
578
+ _plan_result, result = await plan_and_apply(
579
+ compiled,
580
+ environment=environment,
581
+ project=project,
582
+ engines=engines,
583
+ state=state,
584
+ lock_owner=f"cli:{os.getpid()}:apply",
585
+ selectors=select,
586
+ forward_only=forward_only,
587
+ force=force,
588
+ parallelism=parallelism or None,
589
+ on_progress=on_progress,
590
+ on_plan=on_plan,
591
+ prepare=prepare,
592
+ )
593
+ except BreakingPlanError as exc:
594
+ console.print(
595
+ f"[red]plan has breaking changes ({', '.join(exc.names)}); re-run with --force to proceed[/red]"
596
+ )
597
+ raise typer.Exit(1) from exc
434
598
  except CheckError as exc:
435
599
  console.print(f"[red]{escape(exc.message)}[/red]")
436
600
  raise typer.Exit(1) from exc
437
601
  except LockError as exc:
438
602
  console.print(f"[red]{escape(exc.message)}[/red]")
439
603
  raise typer.Exit(1) from exc
604
+ if result is None:
605
+ return
440
606
  _render_build_results(result, compiled)
441
607
  _render_checks(result)
442
608
  await _render_empty_incrementals(result, compiled, engines)
@@ -444,6 +610,8 @@ async def _apply(
444
610
  f"[green]Built {len(set(result.built))} model(s); promoted {result.promoted} to '{environment}'.[/green]"
445
611
  )
446
612
  finally:
613
+ if started and tracker is not None:
614
+ tracker.progress.stop()
447
615
  await state.close()
448
616
  engines.close()
449
617
 
@@ -509,34 +677,44 @@ async def _execute(
509
677
  compiled = project.compile()
510
678
  engines = project.open_engines()
511
679
  state = await project.open_state()
680
+ tracker: _BuildProgress | None = _BuildProgress() if console.is_terminal else None
681
+ started_bar = False
512
682
  try:
513
- if project.streams: # as in _apply: stream tables must exist daemon or not
514
- await ensure_stream_tables(project.streams, engines.get())
515
- plan_result = await run_plan(
516
- compiled,
517
- environment,
518
- state,
519
- start=window_start,
520
- end=window_end,
521
- select=_selection(compiled, select, await _promoted_if_needed(state, environment, select)),
522
- restate=restate,
523
- )
524
- progress = _build_progress(plan_result)
683
+
684
+ async def prepare() -> None:
685
+ if project.streams: # as in _apply: stream tables must exist daemon or not
686
+ await ensure_stream_tables(project.streams, engines.get())
687
+
688
+ def on_plan(plan: Plan) -> None:
689
+ nonlocal started_bar, tracker
690
+ if tracker is not None and not plan.backfills:
691
+ tracker = None
692
+ if tracker is not None:
693
+ tracker.progress.start()
694
+ started_bar = True
695
+
696
+ def on_progress(model: str, event: str, detail: dict[str, Any]) -> None:
697
+ if tracker is not None:
698
+ tracker(model, event, detail)
699
+
525
700
  started = time.perf_counter()
526
701
  try:
527
- with progress.progress if progress else contextlib.nullcontext():
528
- async with hold_apply_lock(state, owner=f"cli:{os.getpid()}:run"):
529
- result = await apply_with_registrations(
530
- plan_result,
531
- compiled=compiled,
532
- engines=engines,
533
- state=state,
534
- base_path=project.root,
535
- on_progress=progress,
536
- connections=project.config.connections,
537
- parallelism=parallelism or project.config.parallelism, # --parallelism wins over config
538
- project=project,
539
- )
702
+ plan_result, result = await run_and_apply(
703
+ compiled,
704
+ environment=environment,
705
+ project=project,
706
+ engines=engines,
707
+ state=state,
708
+ lock_owner=f"cli:{os.getpid()}:run",
709
+ selectors=select,
710
+ start=window_start,
711
+ end=window_end,
712
+ restate=restate,
713
+ parallelism=parallelism or None,
714
+ on_progress=on_progress,
715
+ on_plan=on_plan,
716
+ prepare=prepare,
717
+ )
540
718
  except CheckError as exc:
541
719
  console.print(f"[red]{escape(exc.message)}[/red]")
542
720
  raise typer.Exit(1) from exc
@@ -553,6 +731,8 @@ async def _execute(
553
731
  f"promoted {result.promoted} to '{environment}'.[/green]"
554
732
  )
555
733
  finally:
734
+ if started_bar and tracker is not None:
735
+ tracker.progress.stop()
556
736
  await state.close()
557
737
  engines.close()
558
738
 
@@ -42,6 +42,18 @@ class PlanError(InterlaceError):
42
42
  """A plan could not be computed or applied."""
43
43
 
44
44
 
45
+ class BreakingPlanError(PlanError):
46
+ """The plan has breaking changes and ``force`` was not set."""
47
+
48
+ def __init__(self, names: list[str]) -> None:
49
+ listed = ", ".join(names)
50
+ super().__init__(
51
+ f"plan has breaking changes ({listed}); pass force to proceed",
52
+ details={"kind": "breaking", "names": names},
53
+ )
54
+ self.names = names
55
+
56
+
45
57
  class ExecutionError(InterlaceError):
46
58
  """A model failed while building (its query, its Python function, or the load)."""
47
59
 
@@ -23,7 +23,12 @@ from interlace.sinks import target_ref
23
23
  from interlace.state.snapshot import Snapshot
24
24
 
25
25
 
26
- class _InspectStore(Protocol):
26
+ class _RelationStore(Protocol):
27
+ async def get_environment(self, environment: str) -> dict[str, str]: ...
28
+ async def get_snapshot(self, name: str, fingerprint: str) -> Snapshot | None: ...
29
+
30
+
31
+ class _InspectStore(_RelationStore, Protocol):
27
32
  """The control-plane reads a preview needs, beyond the plan/apply store slice."""
28
33
 
29
34
  async def latest_model_build(self, model: str) -> dict[str, object] | None: ...
@@ -118,7 +123,7 @@ async def last_build(store: _InspectStore, name: str) -> LastBuild | None:
118
123
 
119
124
 
120
125
  async def _built_table(
121
- model: CompiledModel, store: _InspectStore, engine: EngineAdapter, environment: str
126
+ model: CompiledModel, store: _RelationStore, engine: EngineAdapter, environment: str
122
127
  ) -> tuple[TableRef, str] | None:
123
128
  """The relation a preview should read, and why (environment view or snapshot)."""
124
129
  if model.materialise == "ephemeral" or model.materialise == "file":
@@ -144,6 +149,14 @@ async def _built_table(
144
149
  return None
145
150
 
146
151
 
152
+ async def model_relation(
153
+ model: CompiledModel, store: _RelationStore, engine: EngineAdapter, environment: str
154
+ ) -> TableRef | None:
155
+ """The table a preview or table-diff should read for ``model`` in ``environment``."""
156
+ found = await _built_table(model, store, engine, environment)
157
+ return None if found is None else found[0]
158
+
159
+
147
160
  def _unavailable(model: CompiledModel) -> str:
148
161
  if model.materialise == "ephemeral":
149
162
  return "ephemeral models are inlined into the models that read them"
@@ -17,18 +17,13 @@ from pathlib import Path
17
17
  from typing import Any, cast
18
18
 
19
19
  from interlace import __version__
20
- from interlace.dsl.dynamic import apply_with_registrations
21
20
  from interlace.exceptions import InterlaceError
22
21
  from interlace.graph.column_lineage import column_lineage
23
22
  from interlace.graph.project import CompiledProject
24
- from interlace.graph.selectors import select_models, wants_state
25
23
  from interlace.inspect import failing_rows, preview_model
26
- from interlace.physical.annotate import annotate_plan
27
- from interlace.plan.differ import diff
24
+ from interlace.plan.orchestrate import compute_plan, plan_and_apply, resolve_selection
28
25
  from interlace.project import Project
29
26
  from interlace.query import prepare_readonly
30
- from interlace.state.locks import hold_apply_lock
31
- from interlace.state.snapshot import ChangeCategory
32
27
  from interlace.streaming import ensure_stream_tables
33
28
 
34
29
  _PROTOCOL = "2024-11-05"
@@ -158,10 +153,10 @@ async def _plan(path: Path, args: dict[str, Any]) -> Any:
158
153
  try:
159
154
  environment = _env(project, args)
160
155
  selectors = [str(item) for item in args.get("selectors") or []]
161
- promoted = await state.get_environment(environment) if wants_state(selectors) else None
162
- selected = select_models(selectors, compiled, promoted=promoted) if selectors else None
163
- plan = await diff(compiled, environment, state, select=selected, forward_only=bool(args.get("forward_only")))
164
- await annotate_plan(plan, compiled, engines)
156
+ selected = await resolve_selection(compiled, state, environment, selectors)
157
+ plan = await compute_plan(
158
+ compiled, environment, state, engines, select=selected, forward_only=bool(args.get("forward_only"))
159
+ )
165
160
  finally:
166
161
  await state.close()
167
162
  engines.close()
@@ -187,34 +182,28 @@ async def _apply(path: Path, args: dict[str, Any]) -> Any:
187
182
  project, compiled, state, engines = await _open(path)
188
183
  try:
189
184
  environment = _env(project, args)
190
- selectors = [str(item) for item in args.get("selectors") or []]
191
- if project.streams:
192
- await ensure_stream_tables(project.streams, engines.get())
193
- promoted = await state.get_environment(environment) if wants_state(selectors) else None
194
- selected = select_models(selectors, compiled, promoted=promoted) if selectors else None
195
- plan = await diff(compiled, environment, state, select=selected, forward_only=bool(args.get("forward_only")))
196
- await annotate_plan(plan, compiled, engines)
197
- if plan.blocking:
198
- raise InterlaceError("schema drift blocks apply: " + "; ".join(plan.blocking))
199
- if plan.is_empty:
200
- return {"environment": environment, "built": [], "promoted": 0}
201
- if plan.has_breaking_changes and not args.get("force"):
202
- names = ", ".join(change.name for change in plan.changes if change.category is ChangeCategory.BREAKING)
203
- raise InterlaceError(f"plan has breaking changes ({names}); resubmit with force true")
204
- async with hold_apply_lock(state, owner=f"mcp:{os.getpid()}"):
205
- result = await apply_with_registrations(
206
- plan,
207
- compiled=compiled,
208
- engines=engines,
209
- state=state,
210
- base_path=project.root,
211
- parallelism=project.config.parallelism,
212
- connections=project.config.connections,
213
- project=project,
214
- )
185
+
186
+ async def prepare() -> None:
187
+ if project.streams:
188
+ await ensure_stream_tables(project.streams, engines.get())
189
+
190
+ plan, result = await plan_and_apply(
191
+ compiled,
192
+ environment=environment,
193
+ project=project,
194
+ engines=engines,
195
+ state=state,
196
+ lock_owner=f"mcp:{os.getpid()}",
197
+ selectors=[str(item) for item in args.get("selectors") or []],
198
+ forward_only=bool(args.get("forward_only")),
199
+ force=bool(args.get("force")),
200
+ prepare=prepare,
201
+ )
215
202
  finally:
216
203
  await state.close()
217
204
  engines.close()
205
+ if result is None:
206
+ return {"environment": environment, "built": [], "promoted": 0}
218
207
  return {
219
208
  "environment": environment,
220
209
  "built": result.built,
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  from interlace.plan.apply import ApplyResult, apply
6
6
  from interlace.plan.differ import diff, snapshot_of
7
+ from interlace.plan.orchestrate import compute_plan, plan_and_apply, run_and_apply
7
8
  from interlace.plan.plan import BackfillTask, ChangeType, ModelChange, Plan, TransferEdge, ViewSwap, env_view
8
9
  from interlace.plan.run import run_plan
9
10
 
@@ -16,8 +17,11 @@ __all__ = [
16
17
  "TransferEdge",
17
18
  "ViewSwap",
18
19
  "apply",
20
+ "compute_plan",
19
21
  "diff",
20
22
  "env_view",
23
+ "plan_and_apply",
24
+ "run_and_apply",
21
25
  "run_plan",
22
26
  "snapshot_of",
23
27
  ]