interlaced 2.8.1__tar.gz → 2.8.2__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 (200) hide show
  1. {interlaced-2.8.1/src/interlaced.egg-info → interlaced-2.8.2}/PKG-INFO +1 -1
  2. {interlaced-2.8.1 → interlaced-2.8.2}/pyproject.toml +1 -1
  3. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/cdc/publish.py +2 -2
  4. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/checks/runner.py +47 -2
  5. interlaced-2.8.2/src/interlace/cli/build.py +496 -0
  6. interlaced-2.8.2/src/interlace/cli/inspect.py +522 -0
  7. interlaced-2.8.2/src/interlace/cli/main.py +29 -0
  8. interlaced-2.8.2/src/interlace/cli/operate.py +578 -0
  9. interlaced-2.8.2/src/interlace/cli/support.py +310 -0
  10. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/dsl/decorators.py +3 -2
  11. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/engines/base.py +25 -0
  12. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/engines/duckdb.py +8 -1
  13. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/engines/quack.py +5 -1
  14. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/engines/spark.py +2 -0
  15. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/inspect.py +38 -0
  16. interlaced-2.8.2/src/interlace/ir/layout.py +30 -0
  17. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/mcp_server.py +10 -14
  18. interlaced-2.8.2/src/interlace/physical/changes.py +45 -0
  19. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/physical/drift.py +8 -3
  20. {interlaced-2.8.1/src/interlace/physical → interlaced-2.8.2/src/interlace/plan}/annotate.py +3 -2
  21. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/plan/apply.py +237 -174
  22. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/plan/differ.py +9 -11
  23. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/plan/orchestrate.py +1 -1
  24. interlaced-2.8.2/src/interlace/plan/payload.py +86 -0
  25. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/plan/plan.py +41 -64
  26. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/plan/run.py +11 -24
  27. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/project.py +24 -2
  28. interlaced-2.8.2/src/interlace/scheduler/daemon.py +217 -0
  29. interlaced-2.8.2/src/interlace/service/app.py +383 -0
  30. interlaced-2.8.2/src/interlace/service/present.py +235 -0
  31. interlaced-2.8.2/src/interlace/service/routes/__init__.py +1 -0
  32. interlaced-2.8.2/src/interlace/service/routes/admin.py +259 -0
  33. interlaced-2.8.2/src/interlace/service/routes/catalog.py +243 -0
  34. interlaced-2.8.2/src/interlace/service/routes/environments.py +75 -0
  35. interlaced-2.8.2/src/interlace/service/routes/models.py +183 -0
  36. interlaced-2.8.2/src/interlace/service/routes/plan.py +187 -0
  37. interlaced-2.8.2/src/interlace/service/routes/runs.py +149 -0
  38. interlaced-2.8.2/src/interlace/service/routes/streams.py +242 -0
  39. interlaced-2.8.2/src/interlace/state/advisory.py +84 -0
  40. interlaced-2.8.2/src/interlace/state/cdc.py +70 -0
  41. interlaced-2.8.2/src/interlace/state/checks.py +62 -0
  42. interlaced-2.8.2/src/interlace/state/codec.py +127 -0
  43. interlaced-2.8.2/src/interlace/state/conn.py +40 -0
  44. interlaced-2.8.2/src/interlace/state/events.py +129 -0
  45. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/state/interval.py +13 -0
  46. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/state/janitor.py +31 -6
  47. interlaced-2.8.2/src/interlace/state/keys.py +92 -0
  48. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/state/locks.py +4 -4
  49. interlaced-2.8.2/src/interlace/state/queue.py +291 -0
  50. interlaced-2.8.2/src/interlace/state/schema.py +182 -0
  51. interlaced-2.8.2/src/interlace/state/snapshots.py +353 -0
  52. interlaced-2.8.2/src/interlace/state/store.py +377 -0
  53. interlaced-2.8.2/src/interlace/state/triggers.py +35 -0
  54. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/strategies/__init__.py +19 -0
  55. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/strategies/append.py +3 -7
  56. interlaced-2.8.2/src/interlace/strategies/base.py +141 -0
  57. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/strategies/full_merge.py +43 -27
  58. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/strategies/hash_merge.py +5 -7
  59. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/strategies/incremental.py +7 -9
  60. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/strategies/merge.py +10 -14
  61. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/strategies/replace.py +8 -11
  62. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/strategies/replace_in_place.py +3 -7
  63. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/strategies/scd.py +10 -10
  64. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/strategies/view.py +7 -7
  65. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/streaming/materializer.py +43 -27
  66. {interlaced-2.8.1 → interlaced-2.8.2/src/interlaced.egg-info}/PKG-INFO +1 -1
  67. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlaced.egg-info/SOURCES.txt +29 -1
  68. interlaced-2.8.1/src/interlace/cli/main.py +0 -1867
  69. interlaced-2.8.1/src/interlace/service/app.py +0 -1988
  70. interlaced-2.8.1/src/interlace/state/store.py +0 -1413
  71. interlaced-2.8.1/src/interlace/strategies/base.py +0 -89
  72. {interlaced-2.8.1 → interlaced-2.8.2}/LICENSE +0 -0
  73. {interlaced-2.8.1 → interlaced-2.8.2}/MANIFEST.in +0 -0
  74. {interlaced-2.8.1 → interlaced-2.8.2}/README.md +0 -0
  75. {interlaced-2.8.1 → interlaced-2.8.2}/setup.cfg +0 -0
  76. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/__init__.py +0 -0
  77. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/cdc/__init__.py +0 -0
  78. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/cdc/decode.py +0 -0
  79. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/cdc/slot.py +0 -0
  80. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/checks/__init__.py +0 -0
  81. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/checks/builtin.py +0 -0
  82. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/checks/spec.py +0 -0
  83. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/cli/__init__.py +0 -0
  84. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/config/__init__.py +0 -0
  85. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/config/config.py +0 -0
  86. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/connections.py +0 -0
  87. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/contracts.py +0 -0
  88. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/dsl/__init__.py +0 -0
  89. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/dsl/discovery.py +0 -0
  90. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/dsl/dynamic.py +0 -0
  91. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/dsl/sql_config.py +0 -0
  92. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/engines/__init__.py +0 -0
  93. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/engines/adbc.py +0 -0
  94. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/engines/bigquery.py +0 -0
  95. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/engines/postgres.py +0 -0
  96. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/engines/redshift.py +0 -0
  97. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/engines/registry.py +0 -0
  98. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/engines/snowflake.py +0 -0
  99. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/exceptions.py +0 -0
  100. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/graph/__init__.py +0 -0
  101. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/graph/column_lineage.py +0 -0
  102. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/graph/dag.py +0 -0
  103. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/graph/project.py +0 -0
  104. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/graph/selectors.py +0 -0
  105. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/inputs.py +0 -0
  106. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/ir/__init__.py +0 -0
  107. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/ir/canonicalize.py +0 -0
  108. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/ir/fingerprint.py +0 -0
  109. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/ir/macros.py +0 -0
  110. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/ir/relation.py +0 -0
  111. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/physical/__init__.py +0 -0
  112. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/physical/ddl.py +0 -0
  113. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/physical/reconcile.py +0 -0
  114. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/physical/spec.py +0 -0
  115. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/plan/__init__.py +0 -0
  116. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/plan/comment.py +0 -0
  117. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/plan/resolve.py +0 -0
  118. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/plan/table_diff.py +0 -0
  119. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/py.typed +0 -0
  120. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/query.py +0 -0
  121. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/runtime/__init__.py +0 -0
  122. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/runtime/handles.py +0 -0
  123. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/runtime/python_model.py +0 -0
  124. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/scaffold.py +0 -0
  125. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/scheduler/__init__.py +0 -0
  126. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/scheduler/engine.py +0 -0
  127. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/scheduler/triggers.py +0 -0
  128. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/scheduler/worker.py +0 -0
  129. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/service/__init__.py +0 -0
  130. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/service/auth.py +0 -0
  131. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/service/types.py +0 -0
  132. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/service/ui/app.css +0 -0
  133. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/service/ui/favicon.svg +0 -0
  134. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/service/ui/index.html +0 -0
  135. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/service/ui/js/api.js +0 -0
  136. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/service/ui/js/app.js +0 -0
  137. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/service/ui/js/dag.js +0 -0
  138. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/service/ui/js/timeline.js +0 -0
  139. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/service/ui/js/ui.js +0 -0
  140. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/service/ui/js/views/checks.js +0 -0
  141. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/service/ui/js/views/environments.js +0 -0
  142. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/service/ui/js/views/lineage.js +0 -0
  143. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/service/ui/js/views/models.js +0 -0
  144. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/service/ui/js/views/overview.js +0 -0
  145. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/service/ui/js/views/plan.js +0 -0
  146. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/service/ui/js/views/query.js +0 -0
  147. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/service/ui/js/views/runs.js +0 -0
  148. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/service/ui/js/views/streams.js +0 -0
  149. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/service/ui/js/views/system.js +0 -0
  150. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/sinks.py +0 -0
  151. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/sources/__init__.py +0 -0
  152. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/sources/auth.py +0 -0
  153. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/sources/rest.py +0 -0
  154. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/state/__init__.py +0 -0
  155. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/state/snapshot.py +0 -0
  156. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/streaming/__init__.py +0 -0
  157. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/streaming/log.py +0 -0
  158. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/streaming/schema.py +0 -0
  159. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/templates/cdc/README.md +0 -0
  160. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/templates/cdc/docker-compose.yml +0 -0
  161. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/templates/cdc/init/seed.sql +0 -0
  162. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/templates/cdc/interlace.yaml +0 -0
  163. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/templates/cdc/models/orders.sql +0 -0
  164. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/templates/cdc/models/orders_by_status.sql +0 -0
  165. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/templates/cdc/models/orders_stream.py +0 -0
  166. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/templates/cdc/push.py +0 -0
  167. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/templates/cdc/template.yaml +0 -0
  168. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/templates/events/README.md +0 -0
  169. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/templates/events/generate.py +0 -0
  170. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/templates/events/interlace.yaml +0 -0
  171. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/templates/events/models/events.py +0 -0
  172. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/templates/events/models/events_by_minute.sql +0 -0
  173. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/templates/events/models/events_by_type.sql +0 -0
  174. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/templates/events/models/top_users.sql +0 -0
  175. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/templates/events/models/user_spend.sql +0 -0
  176. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/templates/events/template.yaml +0 -0
  177. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/templates/github/README.md +0 -0
  178. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/templates/github/interlace.yaml +0 -0
  179. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/templates/github/models/github_issues.py +0 -0
  180. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/templates/github/models/issues_by_state.sql +0 -0
  181. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/templates/github/template.yaml +0 -0
  182. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/templates/postgres/README.md +0 -0
  183. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/templates/postgres/docker-compose.yml +0 -0
  184. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/templates/postgres/init/seed.sql +0 -0
  185. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/templates/postgres/interlace.yaml +0 -0
  186. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/templates/postgres/models/orders.py +0 -0
  187. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/templates/postgres/models/orders_by_status.sql +0 -0
  188. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/templates/postgres/template.yaml +0 -0
  189. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/templates/quickstart/README.md +0 -0
  190. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/templates/quickstart/interlace.yaml +0 -0
  191. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/templates/quickstart/models/enriched_events.py +0 -0
  192. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/templates/quickstart/models/event_summary.sql +0 -0
  193. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/templates/quickstart/models/raw_events.sql +0 -0
  194. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/templates/quickstart/template.yaml +0 -0
  195. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/testing/__init__.py +0 -0
  196. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlace/testing/golden.py +0 -0
  197. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlaced.egg-info/dependency_links.txt +0 -0
  198. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlaced.egg-info/entry_points.txt +0 -0
  199. {interlaced-2.8.1 → interlaced-2.8.2}/src/interlaced.egg-info/requires.txt +0 -0
  200. {interlaced-2.8.1 → interlaced-2.8.2}/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.8.1
3
+ Version: 2.8.2
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.8.1"
7
+ version = "2.8.2"
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"
@@ -18,10 +18,10 @@ async def publish_changes(log: StreamLog, store: SqliteStateStore, stream: str,
18
18
  stream,
19
19
  [Event(payload=payload_of(change), idempotency_key=f"cdc:{change.lsn}") for change in changes],
20
20
  )
21
- await store.cdc_note_pending(stream, list(zip(result.offsets, (change.lsn for change in changes), strict=True)))
21
+ await store.cdc.cdc_note_pending(stream, list(zip(result.offsets, (change.lsn for change in changes), strict=True)))
22
22
  return result.offsets
23
23
 
24
24
 
25
25
  async def confirm_flushed(store: SqliteStateStore, stream: str, watermark: int) -> str | None:
26
26
  """Advance the stored LSN up to the offset ``flush_streams`` has committed."""
27
- return await store.cdc_advance(stream, watermark)
27
+ return await store.cdc.cdc_advance(stream, watermark)
@@ -11,7 +11,7 @@ from __future__ import annotations
11
11
 
12
12
  import asyncio
13
13
  import inspect
14
- from collections.abc import Mapping
14
+ from collections.abc import Mapping, Sequence
15
15
  from dataclasses import dataclass
16
16
 
17
17
  import pyarrow as pa
@@ -21,10 +21,13 @@ from interlace.checks.builtin import build_check_query
21
21
  from interlace.checks.spec import CheckSpec
22
22
  from interlace.dsl.decorators import CheckDef
23
23
  from interlace.engines.base import EngineAdapter
24
- from interlace.exceptions import DefinitionError
24
+ from interlace.engines.registry import EngineRegistry
25
+ from interlace.exceptions import DefinitionError, PlanError
25
26
  from interlace.graph.project import CompiledModel, CompiledProject
26
27
  from interlace.ir.relation import TableRef
27
28
  from interlace.runtime.handles import RelationHandle
29
+ from interlace.sinks import target_ref
30
+ from interlace.state.store import SqliteStateStore
28
31
 
29
32
 
30
33
  @dataclass(frozen=True)
@@ -101,3 +104,45 @@ async def run_checks(
101
104
  outcomes = [await _run_declared(spec, model, compiled, engine, table, physical) for spec in model.checks]
102
105
  outcomes += [await _run_python(check, engine, table, model.name) for check in python_checks]
103
106
  return outcomes
107
+
108
+
109
+ async def run_promoted_checks(
110
+ compiled: CompiledProject,
111
+ store: SqliteStateStore,
112
+ engines: EngineRegistry,
113
+ environment: str,
114
+ selectors: Sequence[str] = (),
115
+ ) -> tuple[list[CheckOutcome], list[str]]:
116
+ """Re-check an environment's promoted tables. ``state:`` selectors see the ledger.
117
+
118
+ Returns ``(outcomes, skipped)``. Models that were never promoted in this
119
+ environment are skipped. A missing environment is a plan error.
120
+ """
121
+ from interlace.plan.orchestrate import resolve_selection
122
+
123
+ chosen = await resolve_selection(compiled, store, environment, selectors)
124
+ promoted = await store.get_environment(environment)
125
+ if not promoted:
126
+ raise PlanError(f"no environment {environment!r} — run `interlace apply` first")
127
+ snapshots = await store.get_snapshots(promoted.items())
128
+ physical = {name: snapshot.physical_table for (name, _), snapshot in snapshots.items()}
129
+ outcomes: list[CheckOutcome] = []
130
+ skipped: list[str] = []
131
+ for name, model in compiled.models.items():
132
+ if chosen is not None and name not in chosen:
133
+ continue
134
+ if not model.checks and not compiled.python_checks.get(name):
135
+ continue
136
+ if model.materialise == "file" or (model.is_terminal and environment not in model.environments):
137
+ continue
138
+ snapshot = snapshots.get((name, promoted.get(name, "")))
139
+ if snapshot is None:
140
+ skipped.append(name)
141
+ continue
142
+ engine = engines.require(snapshot.engine, model=name)
143
+ check_table = target_ref(model.target or "") if model.materialise == "table" else snapshot.physical_table
144
+ results = await run_checks(model, compiled, engine, check_table, compiled.python_checks.get(name, ()), physical)
145
+ if results:
146
+ await store.record_check_results(environment, snapshot.fingerprint, results)
147
+ outcomes.extend(results)
148
+ return outcomes, skipped
@@ -0,0 +1,496 @@
1
+ """Plan, apply, run, diff, test, and init."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import typer
12
+ from rich.markup import escape
13
+
14
+ from interlace.cli.support import (
15
+ _END,
16
+ _ENV,
17
+ _FORWARD_ONLY,
18
+ _JSON,
19
+ _PARALLELISM,
20
+ _PATH,
21
+ _SELECT,
22
+ _START,
23
+ _BuildProgress,
24
+ _emit_json,
25
+ _render,
26
+ _render_build_results,
27
+ _render_checks,
28
+ _render_empty_incrementals,
29
+ _render_warnings,
30
+ _selection,
31
+ _table,
32
+ _window,
33
+ app,
34
+ console,
35
+ )
36
+ from interlace.exceptions import (
37
+ CheckError,
38
+ ConfigurationError,
39
+ InterlaceError,
40
+ LockError,
41
+ PlanError,
42
+ SelectionError,
43
+ )
44
+ from interlace.plan.comment import plan_markdown
45
+ from interlace.plan.orchestrate import compute_plan, plan_and_apply, resolve_selection, run_and_apply
46
+ from interlace.plan.plan import Plan
47
+ from interlace.plan.table_diff import TableDiff
48
+ from interlace.project import Project, open_session
49
+ from interlace.scaffold import list_templates, scaffold_project
50
+ from interlace.streaming import ensure_stream_tables
51
+
52
+
53
+ @app.command()
54
+ def init(
55
+ path: Path = typer.Argument(Path("."), help="Directory to initialise."),
56
+ name: str = typer.Option("", "--name", "-n", help="Project name (defaults to the directory name)."),
57
+ template: str = typer.Option("quickstart", "--template", "-t", help="Which starter to scaffold (see --list)."),
58
+ show_list: bool = typer.Option(False, "--list", help="List available templates and exit."),
59
+ ) -> None:
60
+ """Scaffold a new interlace project from a template."""
61
+ if show_list:
62
+ table = _table("Templates")
63
+ table.add_column("Template")
64
+ table.add_column("Description", style="dim")
65
+ table.add_column("Needs", style="dim")
66
+ for info in list_templates():
67
+ # escape: a description may contain [sources]-style brackets Rich would eat as markup
68
+ table.add_row(info.name, escape(info.description), ", ".join(info.requires_env) or "—")
69
+ console.print(table)
70
+ return
71
+ try:
72
+ written = scaffold_project(path, name or None, template)
73
+ except ConfigurationError as exc:
74
+ console.print(f"[red]{escape(exc.message)}[/red] ({exc.details.get('path', '')})")
75
+ raise typer.Exit(1) from exc
76
+ console.print(f"[green]Initialised interlace project in {path}[/green] [dim](template: {template})[/dim]")
77
+ for written_path in written:
78
+ console.print(f" + {written_path}")
79
+ needs = next((t.requires_env for t in list_templates() if t.name == template), ())
80
+ if needs:
81
+ console.print(f"\n[yellow]Set before applying:[/yellow] {', '.join(needs)}")
82
+ console.print("\nNext: [bold]interlace apply[/bold] (or --env dev for a sandbox)")
83
+
84
+
85
+ @app.command()
86
+ def plan(
87
+ environment: str = _ENV,
88
+ path: Path = _PATH,
89
+ select: list[str] = _SELECT,
90
+ forward_only: bool = _FORWARD_ONLY,
91
+ as_json: bool = _JSON,
92
+ markdown: bool = typer.Option(False, "--markdown", help="Emit a GitHub-flavoured plan comment (CI)."),
93
+ ) -> None:
94
+ """Show what apply would change in an environment."""
95
+ asyncio.run(_plan(environment, path, select, forward_only, as_json, markdown))
96
+
97
+
98
+ @app.command()
99
+ def apply(
100
+ environment: str = _ENV,
101
+ path: Path = _PATH,
102
+ select: list[str] = _SELECT,
103
+ forward_only: bool = _FORWARD_ONLY,
104
+ force: bool = typer.Option(False, "--force", help="Proceed even when the plan contains breaking changes."),
105
+ parallelism: int = _PARALLELISM,
106
+ ) -> None:
107
+ """Build changed models and promote the environment."""
108
+ asyncio.run(_apply(environment, path, select, forward_only, force, parallelism))
109
+
110
+
111
+ @app.command("diff")
112
+ def table_diff_cmd(
113
+ environment: str = _ENV,
114
+ path: Path = _PATH,
115
+ select: list[str] = _SELECT,
116
+ against: str = typer.Option("", "--against", help="Other environment (env mode)."),
117
+ source: str = typer.Option("", "--source", help="Left table, schema.table (table mode)."),
118
+ target: str = typer.Option("", "--target", help="Right table, schema.table (table mode)."),
119
+ on: list[str] = typer.Option([], "--on", help="Join key columns. Default: the model's key, else common columns."),
120
+ limit: int = typer.Option(20, "--limit", "-n", help="Sample rows per category."),
121
+ as_json: bool = _JSON,
122
+ ) -> None:
123
+ """Compare a model across two environments, or two tables on the warehouse."""
124
+ asyncio.run(_table_diff(environment, path, select, against, source, target, on, limit, as_json))
125
+
126
+
127
+ @app.command()
128
+ def test(
129
+ path: Path = _PATH,
130
+ select: list[str] = _SELECT,
131
+ update_golden: bool = typer.Option(False, "--update-golden", help="Rewrite tests/golden from the actual result."),
132
+ ) -> None:
133
+ """Build selected models in an ephemeral DuckDB and diff tests/golden."""
134
+ project = Project.load(path)
135
+ compiled = project.compile()
136
+ from interlace.testing.golden import run_fixture_tests
137
+
138
+ chosen = _selection(compiled, select) if select else None
139
+ try:
140
+ report = run_fixture_tests(compiled, project.root, select=chosen, update=update_golden)
141
+ except InterlaceError as exc:
142
+ console.print(f"[red]{escape(exc.message)}[/red]")
143
+ raise typer.Exit(1) from exc
144
+ if update_golden:
145
+ console.print(f"[green]updated {len(report.passed)} golden file(s)[/green]")
146
+ return
147
+ for message in report.messages:
148
+ console.print(f"[red]{escape(message)}[/red]")
149
+ if report.messages:
150
+ raise typer.Exit(1)
151
+ console.print(f"[green]{len(report.passed)} golden test(s) passed[/green]")
152
+
153
+
154
+ async def _plan(
155
+ environment: str,
156
+ path: Path,
157
+ select: list[str],
158
+ forward_only: bool = False,
159
+ as_json: bool = False,
160
+ markdown: bool = False,
161
+ ) -> None:
162
+ async with open_session(path) as (_project, compiled, state, engines):
163
+ try:
164
+ selected = await resolve_selection(compiled, state, environment, select)
165
+ result = await compute_plan(
166
+ compiled, environment, state, engines, select=selected, forward_only=forward_only
167
+ )
168
+ if markdown:
169
+ typer.echo(plan_markdown(result, environment))
170
+ elif as_json:
171
+ previous = await state.get_snapshots(
172
+ (change.name, change.previous_fingerprint)
173
+ for change in result.changes
174
+ if change.previous_fingerprint is not None
175
+ )
176
+ from interlace.plan.payload import plan_document
177
+
178
+ _emit_json(plan_document(result, compiled, previous, environment).as_dict())
179
+ else:
180
+ _render(result, environment)
181
+ if result.blocking:
182
+ raise typer.Exit(1)
183
+ except SelectionError as exc:
184
+ console.print(f"[red]{escape(exc.message)}[/red]")
185
+ raise typer.Exit(1) from exc
186
+
187
+
188
+ def _diff_dict(result: TableDiff) -> dict[str, object]:
189
+ schema = {
190
+ "added": [{"name": name, "type": dtype} for name, dtype in result.schema.added],
191
+ "removed": [{"name": name, "type": dtype} for name, dtype in result.schema.removed],
192
+ "type_changed": [
193
+ {"name": name, "left": left, "right": right} for name, left, right in result.schema.type_changed
194
+ ],
195
+ }
196
+ rows = None
197
+ if result.rows is not None:
198
+ rows = {
199
+ "left_count": result.rows.left_count,
200
+ "right_count": result.rows.right_count,
201
+ "left_only": result.rows.left_only,
202
+ "right_only": result.rows.right_only,
203
+ "changed": result.rows.changed,
204
+ "matched": result.rows.matched,
205
+ "keys": result.rows.keys,
206
+ "left_only_sample": result.rows.left_only_sample,
207
+ "right_only_sample": result.rows.right_only_sample,
208
+ "changed_sample": result.rows.changed_sample,
209
+ }
210
+ return {
211
+ "left": result.left,
212
+ "right": result.right,
213
+ "model": result.model,
214
+ "schema": schema,
215
+ "rows": rows,
216
+ "message": result.message,
217
+ "differs": result.differs,
218
+ }
219
+
220
+
221
+ def _render_diffs(results: list[TableDiff]) -> None:
222
+ for result in results:
223
+ title = result.model or f"{result.left} vs {result.right}"
224
+ if result.message and result.rows is None and result.schema.empty:
225
+ console.print(f"[yellow]{title}[/yellow] {result.message}")
226
+ continue
227
+ table = _table(title)
228
+ table.add_column("Side")
229
+ table.add_column("Relation", style="dim")
230
+ table.add_row("left", result.left)
231
+ table.add_row("right", result.right)
232
+ console.print(table)
233
+ if result.schema.added or result.schema.removed or result.schema.type_changed:
234
+ for name, dtype in result.schema.added:
235
+ console.print(f" [green]+[/] column {name} {dtype}")
236
+ for name, dtype in result.schema.removed:
237
+ console.print(f" [red]-[/] column {name} {dtype}")
238
+ for name, left, right in result.schema.type_changed:
239
+ console.print(f" [yellow]~[/] column {name} {left} → {right}")
240
+ if result.message:
241
+ console.print(f" [yellow]{result.message}[/yellow]")
242
+ if result.rows is None:
243
+ continue
244
+ rows = result.rows
245
+ console.print(
246
+ f" rows left={rows.left_count} right={rows.right_count} "
247
+ f"only-left={rows.left_only} only-right={rows.right_only} changed={rows.changed} "
248
+ f"matched={rows.matched} keys={','.join(rows.keys) or '—'}"
249
+ )
250
+
251
+
252
+ async def _table_diff(
253
+ environment: str,
254
+ path: Path,
255
+ select: list[str],
256
+ against: str,
257
+ source: str,
258
+ target: str,
259
+ on: list[str],
260
+ limit: int,
261
+ as_json: bool,
262
+ ) -> None:
263
+ from interlace.plan.table_diff import diff_environments, diff_tables, parse_table_ref
264
+
265
+ if bool(source) != bool(target):
266
+ console.print("[red]table mode needs both --source and --target[/red]")
267
+ raise typer.Exit(2)
268
+ if not source and not against:
269
+ console.print("[red]env mode needs --against ENV (or pass --source/--target)[/red]")
270
+ raise typer.Exit(2)
271
+ project = Project.load(path)
272
+ compiled = project.compile()
273
+ engines = project.open_engines()
274
+ state = await project.open_state()
275
+ try:
276
+ if source:
277
+ result = await diff_tables(
278
+ engines.get(),
279
+ parse_table_ref(source),
280
+ parse_table_ref(target),
281
+ keys=on or None,
282
+ sample=limit,
283
+ )
284
+ results = [result]
285
+ else:
286
+ selected = await resolve_selection(compiled, state, environment, select)
287
+ results = await diff_environments(
288
+ compiled,
289
+ left_env=environment,
290
+ right_env=against,
291
+ store=state,
292
+ engines=engines,
293
+ select=selected,
294
+ keys=on or None,
295
+ sample=limit,
296
+ )
297
+ if as_json:
298
+ _emit_json([_diff_dict(item) for item in results])
299
+ else:
300
+ if not results:
301
+ console.print("No comparable models (ephemeral/file outputs are skipped).")
302
+ else:
303
+ _render_diffs(results)
304
+ if any(item.differs for item in results):
305
+ raise typer.Exit(1)
306
+ except PlanError as exc:
307
+ console.print(f"[red]{escape(exc.message)}[/red]")
308
+ raise typer.Exit(1) from exc
309
+ finally:
310
+ await state.close()
311
+ engines.close()
312
+
313
+
314
+ async def _apply(
315
+ environment: str,
316
+ path: Path,
317
+ select: list[str],
318
+ forward_only: bool = False,
319
+ force: bool = False,
320
+ parallelism: int = 0,
321
+ ) -> None:
322
+ from interlace.exceptions import BreakingPlanError
323
+
324
+ project = Project.load(path)
325
+ compiled = project.compile()
326
+ engines = project.open_engines()
327
+ state = await project.open_state()
328
+ tracker: _BuildProgress | None = _BuildProgress() if console.is_terminal else None
329
+ started = False
330
+ try:
331
+
332
+ async def prepare() -> None:
333
+ # Stream-fed projects must build without the daemon ever having run:
334
+ # declared stream tables are ensured (empty) so models reading them work.
335
+ if project.streams:
336
+ await ensure_stream_tables(project.streams, engines.get())
337
+
338
+ def on_plan(plan: Plan) -> None:
339
+ nonlocal started, tracker
340
+ _render(plan, environment)
341
+ if tracker is not None and not plan.backfills:
342
+ tracker = None
343
+ if tracker is not None:
344
+ tracker.progress.start()
345
+ started = True
346
+
347
+ def on_progress(model: str, event: str, detail: dict[str, Any]) -> None:
348
+ if tracker is not None:
349
+ tracker(model, event, detail)
350
+
351
+ try:
352
+ _plan_result, result = await plan_and_apply(
353
+ compiled,
354
+ environment=environment,
355
+ project=project,
356
+ engines=engines,
357
+ state=state,
358
+ lock_owner=f"cli:{os.getpid()}:apply",
359
+ selectors=select,
360
+ forward_only=forward_only,
361
+ force=force,
362
+ parallelism=parallelism or None,
363
+ on_progress=on_progress,
364
+ on_plan=on_plan,
365
+ prepare=prepare,
366
+ )
367
+ except BreakingPlanError as exc:
368
+ console.print(
369
+ f"[red]plan has breaking changes ({', '.join(exc.names)}); re-run with --force to proceed[/red]"
370
+ )
371
+ raise typer.Exit(1) from exc
372
+ except CheckError as exc:
373
+ console.print(f"[red]{escape(exc.message)}[/red]")
374
+ raise typer.Exit(1) from exc
375
+ except LockError as exc:
376
+ console.print(f"[red]{escape(exc.message)}[/red]")
377
+ raise typer.Exit(1) from exc
378
+ if result is None:
379
+ return
380
+ _render_build_results(result, compiled)
381
+ _render_checks(result)
382
+ await _render_empty_incrementals(result, compiled, engines)
383
+ console.print(
384
+ f"[green]Built {len(set(result.built))} model(s); promoted {result.promoted} to '{environment}'.[/green]"
385
+ )
386
+ finally:
387
+ if started and tracker is not None:
388
+ tracker.progress.stop()
389
+ await state.close()
390
+ engines.close()
391
+
392
+
393
+ @app.command()
394
+ def run(
395
+ environment: str = _ENV,
396
+ path: Path = _PATH,
397
+ select: list[str] = _SELECT,
398
+ start: str = _START,
399
+ end: str = _END,
400
+ parallelism: int = _PARALLELISM,
401
+ ) -> None:
402
+ """Force-build models and promote, ignoring change detection.
403
+
404
+ For incremental models, --start/--end set the catchup window
405
+ (default: the latest grain interval).
406
+ """
407
+ asyncio.run(_execute(environment, path, select, start, end, restate=False, parallelism=parallelism))
408
+
409
+
410
+ @app.command()
411
+ def restate(
412
+ environment: str = _ENV,
413
+ path: Path = _PATH,
414
+ select: list[str] = _SELECT,
415
+ start: str = _START,
416
+ end: str = _END,
417
+ parallelism: int = _PARALLELISM,
418
+ ) -> None:
419
+ """Reprocess incremental models over a window, ignoring the ledger (vs run, which skips filled)."""
420
+ asyncio.run(_execute(environment, path, select, start, end, restate=True, parallelism=parallelism))
421
+
422
+
423
+ async def _execute(
424
+ environment: str,
425
+ path: Path,
426
+ select: list[str],
427
+ start: str,
428
+ end: str,
429
+ *,
430
+ restate: bool,
431
+ parallelism: int = 0,
432
+ ) -> None:
433
+ window_start = _window(start, "--start")
434
+ window_end = _window(end, "--end")
435
+ project = Project.load(path)
436
+ compiled = project.compile()
437
+ engines = project.open_engines()
438
+ state = await project.open_state()
439
+ tracker: _BuildProgress | None = _BuildProgress() if console.is_terminal else None
440
+ started_bar = False
441
+ try:
442
+
443
+ async def prepare() -> None:
444
+ if project.streams: # as in _apply: stream tables must exist daemon or not
445
+ await ensure_stream_tables(project.streams, engines.get())
446
+
447
+ def on_plan(plan: Plan) -> None:
448
+ nonlocal started_bar, tracker
449
+ if tracker is not None and not plan.backfills:
450
+ tracker = None
451
+ if tracker is not None:
452
+ tracker.progress.start()
453
+ started_bar = True
454
+
455
+ def on_progress(model: str, event: str, detail: dict[str, Any]) -> None:
456
+ if tracker is not None:
457
+ tracker(model, event, detail)
458
+
459
+ started = time.perf_counter()
460
+ try:
461
+ plan_result, result = await run_and_apply(
462
+ compiled,
463
+ environment=environment,
464
+ project=project,
465
+ engines=engines,
466
+ state=state,
467
+ lock_owner=f"cli:{os.getpid()}:run",
468
+ selectors=select,
469
+ start=window_start,
470
+ end=window_end,
471
+ restate=restate,
472
+ parallelism=parallelism or None,
473
+ on_progress=on_progress,
474
+ on_plan=on_plan,
475
+ prepare=prepare,
476
+ )
477
+ except CheckError as exc:
478
+ console.print(f"[red]{escape(exc.message)}[/red]")
479
+ raise typer.Exit(1) from exc
480
+ except LockError as exc:
481
+ console.print(f"[red]{escape(exc.message)}[/red]")
482
+ raise typer.Exit(1) from exc
483
+ _render_build_results(result, compiled)
484
+ _render_checks(result)
485
+ _render_warnings(plan_result)
486
+ await _render_empty_incrementals(result, compiled, engines)
487
+ verb = "Restated" if restate else "Ran"
488
+ console.print(
489
+ f"[green]{verb} {len(set(result.built))} model(s) in {time.perf_counter() - started:.2f}s; "
490
+ f"promoted {result.promoted} to '{environment}'.[/green]"
491
+ )
492
+ finally:
493
+ if started_bar and tracker is not None:
494
+ tracker.progress.stop()
495
+ await state.close()
496
+ engines.close()