interlaced 2.8.2__tar.gz → 2.8.4__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 (212) hide show
  1. {interlaced-2.8.2/src/interlaced.egg-info → interlaced-2.8.4}/PKG-INFO +6 -7
  2. {interlaced-2.8.2 → interlaced-2.8.4}/README.md +5 -6
  3. {interlaced-2.8.2 → interlaced-2.8.4}/pyproject.toml +5 -3
  4. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/checks/builtin.py +19 -11
  5. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/cli/build.py +2 -2
  6. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/cli/operate.py +16 -1
  7. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/cli/support.py +1 -1
  8. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/config/config.py +71 -2
  9. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/dsl/decorators.py +28 -3
  10. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/dsl/dynamic.py +1 -1
  11. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/engines/adbc.py +6 -4
  12. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/engines/base.py +20 -1
  13. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/engines/duckdb.py +5 -3
  14. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/engines/postgres.py +9 -5
  15. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/engines/registry.py +4 -0
  16. interlaced-2.8.4/src/interlace/engines/snowflake.py +89 -0
  17. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/graph/project.py +35 -8
  18. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/inputs.py +71 -0
  19. interlaced-2.8.4/src/interlace/ir/dialect.py +111 -0
  20. interlaced-2.8.4/src/interlace/ir/fingerprint.py +231 -0
  21. interlaced-2.8.4/src/interlace/ir/vars.py +88 -0
  22. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/mcp_server.py +1 -1
  23. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/physical/drift.py +1 -1
  24. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/physical/spec.py +1 -1
  25. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/plan/annotate.py +1 -1
  26. interlaced-2.8.4/src/interlace/plan/apply.py +168 -0
  27. interlaced-2.8.4/src/interlace/plan/backfill.py +441 -0
  28. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/plan/comment.py +1 -1
  29. interlaced-2.8.4/src/interlace/plan/delivery.py +249 -0
  30. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/plan/differ.py +3 -3
  31. interlaced-2.8.4/src/interlace/plan/files.py +79 -0
  32. interlaced-2.8.4/src/interlace/plan/fit.py +234 -0
  33. interlaced-2.8.4/src/interlace/plan/result.py +68 -0
  34. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/plan/run.py +64 -36
  35. interlaced-2.8.4/src/interlace/plan/schedule.py +185 -0
  36. interlaced-2.8.4/src/interlace/plan/transfer.py +89 -0
  37. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/project.py +30 -9
  38. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/runtime/python_model.py +3 -1
  39. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/scheduler/daemon.py +115 -3
  40. interlaced-2.8.4/src/interlace/scheduler/engine.py +254 -0
  41. interlaced-2.8.4/src/interlace/scheduler/triggers.py +237 -0
  42. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/scheduler/worker.py +49 -24
  43. interlaced-2.8.4/src/interlace/service/app.py +230 -0
  44. interlaced-2.8.2/src/interlace/service/app.py → interlaced-2.8.4/src/interlace/service/lifespan.py +27 -206
  45. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/present.py +3 -2
  46. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/routes/catalog.py +10 -3
  47. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/routes/runs.py +4 -3
  48. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/routes/streams.py +2 -2
  49. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/types.py +1 -1
  50. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/ui/js/views/system.js +1 -1
  51. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/sinks.py +39 -0
  52. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/state/advisory.py +6 -3
  53. interlaced-2.8.4/src/interlace/state/beat.py +43 -0
  54. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/state/interval.py +21 -4
  55. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/state/janitor.py +2 -2
  56. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/state/locks.py +9 -19
  57. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/state/queue.py +3 -2
  58. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/state/store.py +9 -0
  59. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/strategies/__init__.py +1 -1
  60. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/strategies/full_merge.py +1 -1
  61. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/streaming/materializer.py +9 -2
  62. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/streaming/schema.py +1 -1
  63. {interlaced-2.8.2 → interlaced-2.8.4/src/interlaced.egg-info}/PKG-INFO +6 -7
  64. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlaced.egg-info/SOURCES.txt +11 -0
  65. interlaced-2.8.2/src/interlace/engines/snowflake.py +0 -44
  66. interlaced-2.8.2/src/interlace/ir/fingerprint.py +0 -67
  67. interlaced-2.8.2/src/interlace/plan/apply.py +0 -1145
  68. interlaced-2.8.2/src/interlace/scheduler/engine.py +0 -110
  69. interlaced-2.8.2/src/interlace/scheduler/triggers.py +0 -127
  70. {interlaced-2.8.2 → interlaced-2.8.4}/LICENSE +0 -0
  71. {interlaced-2.8.2 → interlaced-2.8.4}/MANIFEST.in +0 -0
  72. {interlaced-2.8.2 → interlaced-2.8.4}/setup.cfg +0 -0
  73. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/__init__.py +0 -0
  74. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/cdc/__init__.py +0 -0
  75. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/cdc/decode.py +0 -0
  76. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/cdc/publish.py +0 -0
  77. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/cdc/slot.py +0 -0
  78. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/checks/__init__.py +0 -0
  79. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/checks/runner.py +0 -0
  80. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/checks/spec.py +0 -0
  81. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/cli/__init__.py +0 -0
  82. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/cli/inspect.py +0 -0
  83. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/cli/main.py +0 -0
  84. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/config/__init__.py +0 -0
  85. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/connections.py +0 -0
  86. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/contracts.py +0 -0
  87. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/dsl/__init__.py +0 -0
  88. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/dsl/discovery.py +0 -0
  89. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/dsl/sql_config.py +0 -0
  90. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/engines/__init__.py +0 -0
  91. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/engines/bigquery.py +0 -0
  92. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/engines/quack.py +0 -0
  93. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/engines/redshift.py +0 -0
  94. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/engines/spark.py +0 -0
  95. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/exceptions.py +0 -0
  96. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/graph/__init__.py +0 -0
  97. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/graph/column_lineage.py +0 -0
  98. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/graph/dag.py +0 -0
  99. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/graph/selectors.py +0 -0
  100. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/inspect.py +0 -0
  101. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/ir/__init__.py +0 -0
  102. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/ir/canonicalize.py +0 -0
  103. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/ir/layout.py +0 -0
  104. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/ir/macros.py +0 -0
  105. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/ir/relation.py +0 -0
  106. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/physical/__init__.py +0 -0
  107. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/physical/changes.py +0 -0
  108. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/physical/ddl.py +0 -0
  109. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/physical/reconcile.py +0 -0
  110. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/plan/__init__.py +0 -0
  111. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/plan/orchestrate.py +0 -0
  112. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/plan/payload.py +0 -0
  113. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/plan/plan.py +0 -0
  114. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/plan/resolve.py +0 -0
  115. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/plan/table_diff.py +0 -0
  116. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/py.typed +0 -0
  117. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/query.py +0 -0
  118. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/runtime/__init__.py +0 -0
  119. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/runtime/handles.py +0 -0
  120. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/scaffold.py +0 -0
  121. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/scheduler/__init__.py +0 -0
  122. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/__init__.py +0 -0
  123. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/auth.py +0 -0
  124. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/routes/__init__.py +0 -0
  125. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/routes/admin.py +0 -0
  126. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/routes/environments.py +0 -0
  127. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/routes/models.py +0 -0
  128. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/routes/plan.py +0 -0
  129. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/ui/app.css +0 -0
  130. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/ui/favicon.svg +0 -0
  131. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/ui/index.html +0 -0
  132. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/ui/js/api.js +0 -0
  133. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/ui/js/app.js +0 -0
  134. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/ui/js/dag.js +0 -0
  135. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/ui/js/timeline.js +0 -0
  136. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/ui/js/ui.js +0 -0
  137. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/ui/js/views/checks.js +0 -0
  138. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/ui/js/views/environments.js +0 -0
  139. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/ui/js/views/lineage.js +0 -0
  140. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/ui/js/views/models.js +0 -0
  141. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/ui/js/views/overview.js +0 -0
  142. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/ui/js/views/plan.js +0 -0
  143. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/ui/js/views/query.js +0 -0
  144. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/ui/js/views/runs.js +0 -0
  145. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/ui/js/views/streams.js +0 -0
  146. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/sources/__init__.py +0 -0
  147. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/sources/auth.py +0 -0
  148. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/sources/rest.py +0 -0
  149. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/state/__init__.py +0 -0
  150. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/state/cdc.py +0 -0
  151. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/state/checks.py +0 -0
  152. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/state/codec.py +0 -0
  153. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/state/conn.py +0 -0
  154. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/state/events.py +0 -0
  155. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/state/keys.py +0 -0
  156. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/state/schema.py +0 -0
  157. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/state/snapshot.py +0 -0
  158. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/state/snapshots.py +0 -0
  159. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/state/triggers.py +0 -0
  160. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/strategies/append.py +0 -0
  161. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/strategies/base.py +0 -0
  162. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/strategies/hash_merge.py +0 -0
  163. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/strategies/incremental.py +0 -0
  164. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/strategies/merge.py +0 -0
  165. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/strategies/replace.py +0 -0
  166. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/strategies/replace_in_place.py +0 -0
  167. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/strategies/scd.py +0 -0
  168. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/strategies/view.py +0 -0
  169. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/streaming/__init__.py +0 -0
  170. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/streaming/log.py +0 -0
  171. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/cdc/README.md +0 -0
  172. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/cdc/docker-compose.yml +0 -0
  173. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/cdc/init/seed.sql +0 -0
  174. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/cdc/interlace.yaml +0 -0
  175. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/cdc/models/orders.sql +0 -0
  176. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/cdc/models/orders_by_status.sql +0 -0
  177. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/cdc/models/orders_stream.py +0 -0
  178. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/cdc/push.py +0 -0
  179. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/cdc/template.yaml +0 -0
  180. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/events/README.md +0 -0
  181. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/events/generate.py +0 -0
  182. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/events/interlace.yaml +0 -0
  183. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/events/models/events.py +0 -0
  184. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/events/models/events_by_minute.sql +0 -0
  185. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/events/models/events_by_type.sql +0 -0
  186. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/events/models/top_users.sql +0 -0
  187. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/events/models/user_spend.sql +0 -0
  188. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/events/template.yaml +0 -0
  189. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/github/README.md +0 -0
  190. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/github/interlace.yaml +0 -0
  191. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/github/models/github_issues.py +0 -0
  192. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/github/models/issues_by_state.sql +0 -0
  193. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/github/template.yaml +0 -0
  194. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/postgres/README.md +0 -0
  195. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/postgres/docker-compose.yml +0 -0
  196. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/postgres/init/seed.sql +0 -0
  197. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/postgres/interlace.yaml +0 -0
  198. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/postgres/models/orders.py +0 -0
  199. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/postgres/models/orders_by_status.sql +0 -0
  200. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/postgres/template.yaml +0 -0
  201. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/quickstart/README.md +0 -0
  202. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/quickstart/interlace.yaml +0 -0
  203. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/quickstart/models/enriched_events.py +0 -0
  204. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/quickstart/models/event_summary.sql +0 -0
  205. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/quickstart/models/raw_events.sql +0 -0
  206. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/quickstart/template.yaml +0 -0
  207. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/testing/__init__.py +0 -0
  208. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/testing/golden.py +0 -0
  209. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlaced.egg-info/dependency_links.txt +0 -0
  210. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlaced.egg-info/entry_points.txt +0 -0
  211. {interlaced-2.8.2 → interlaced-2.8.4}/src/interlaced.egg-info/requires.txt +0 -0
  212. {interlaced-2.8.2 → interlaced-2.8.4}/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.2
3
+ Version: 2.8.4
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
@@ -110,12 +110,11 @@ pip install 'interlaced[service]' # the CLI + daemon; core CLI only: pip insta
110
110
  # more extras: [adbc] postgres/redshift · [spark] · [polars] · [all]
111
111
  ```
112
112
 
113
- > **Platforms.** Developed on Linux; CI runs Linux only. Nothing in the codebase is
114
- > platform-specific — no `fork`, no signal handling, no POSIX-only calls, no shelling out — and
115
- > every dependency ships macOS and Windows wheels, so both are expected to work. But
116
- > **neither is tested**, so treat them as unverified rather than supported. If you run interlace
117
- > on macOS or Windows, please open an issue either way; that is the fastest route to changing
118
- > this paragraph.
113
+ > **Platforms.** Developed on Linux. CI runs the full suite on Linux, and a macOS job runs
114
+ > `interlace init` plus `apply` on the quickstart. Nothing in the codebase is platform-specific —
115
+ > no `fork`, no signal handling, no POSIX-only calls, no shelling out — and every dependency
116
+ > ships macOS and Windows wheels. Windows is still unverified. If you run interlace on Windows,
117
+ > please open an issue either way; that is the fastest route to changing this paragraph.
119
118
 
120
119
  ## Sixty seconds
121
120
 
@@ -42,12 +42,11 @@ pip install 'interlaced[service]' # the CLI + daemon; core CLI only: pip insta
42
42
  # more extras: [adbc] postgres/redshift · [spark] · [polars] · [all]
43
43
  ```
44
44
 
45
- > **Platforms.** Developed on Linux; CI runs Linux only. Nothing in the codebase is
46
- > platform-specific — no `fork`, no signal handling, no POSIX-only calls, no shelling out — and
47
- > every dependency ships macOS and Windows wheels, so both are expected to work. But
48
- > **neither is tested**, so treat them as unverified rather than supported. If you run interlace
49
- > on macOS or Windows, please open an issue either way; that is the fastest route to changing
50
- > this paragraph.
45
+ > **Platforms.** Developed on Linux. CI runs the full suite on Linux, and a macOS job runs
46
+ > `interlace init` plus `apply` on the quickstart. Nothing in the codebase is platform-specific —
47
+ > no `fork`, no signal handling, no POSIX-only calls, no shelling out — and every dependency
48
+ > ships macOS and Windows wheels. Windows is still unverified. If you run interlace on Windows,
49
+ > please open an issue either way; that is the fastest route to changing this paragraph.
51
50
 
52
51
  ## Sixty seconds
53
52
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "interlaced"
7
- version = "2.8.2"
7
+ version = "2.8.4"
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"
@@ -61,7 +61,8 @@ service = [
61
61
  "msgspec>=0.18,<1.0", # the wire types (litestar serialises msgspec structs natively)
62
62
  ]
63
63
  # Remote engines via Arrow-native ADBC transfer. postgres (+ redshift, same wire)
64
- # are the tested transport; snowflake/bigquery are ALPHA (wired, not run live).
64
+ # are the tested transport. snowflake and bigquery are ALPHA: snowflake has been
65
+ # exercised against one account, outside CI; bigquery has not been run live.
65
66
  adbc = [
66
67
  "adbc-driver-manager>=1.2,<2.0",
67
68
  "adbc-driver-postgresql>=1.2,<2.0",
@@ -113,11 +114,11 @@ select = [
113
114
  "B", # flake8-bugbear
114
115
  "C4", # flake8-comprehensions
115
116
  "UP", # pyupgrade
117
+ "C901", # mccabe; existing hotspots are allow-listed with noqa
116
118
  ]
117
119
  ignore = [
118
120
  "E501", # line too long (handled by formatter)
119
121
  "B008", # function calls in argument defaults
120
- "C901", # too complex
121
122
  ]
122
123
 
123
124
  [tool.ruff.lint.per-file-ignores]
@@ -171,6 +172,7 @@ exclude_lines = [
171
172
 
172
173
  [tool.pytest.ini_options]
173
174
  testpaths = ["tests"]
175
+ pythonpath = ["tests"]
174
176
  python_files = ["test_*.py", "*_test.py"]
175
177
  python_classes = ["Test*"]
176
178
  python_functions = ["test_*"]
@@ -14,7 +14,6 @@ NULLs as pattern/range failures, which conflated the two).
14
14
 
15
15
  from __future__ import annotations
16
16
 
17
- import re
18
17
  from collections.abc import Callable
19
18
  from typing import Any, cast
20
19
 
@@ -24,10 +23,9 @@ from interlace.checks.spec import CheckSpec
24
23
  from interlace.exceptions import DefinitionError
25
24
  from interlace.ir.canonicalize import as_query
26
25
  from interlace.ir.relation import TableRef
26
+ from interlace.state.interval import as_grain
27
27
 
28
28
  _FAILURES = "failures"
29
- _AGE_RE = re.compile(r"^\s*(\d+)\s*([smhdw])\s*$")
30
- _AGE_UNITS = {"s": "SECOND", "m": "MINUTE", "h": "HOUR", "d": "DAY", "w": "WEEK"}
31
29
 
32
30
  ResolveTable = Callable[[str], TableRef]
33
31
  """Maps an upstream model name to its physical table (for ``relationships``)."""
@@ -59,11 +57,24 @@ def _parse_expr(sql: str, dialect: str) -> exp.Expr:
59
57
  return parse_one(sql, read=dialect)
60
58
 
61
59
 
60
+ def sql_interval(amount: int, unit: str) -> exp.Interval:
61
+ """``amount`` of a SQL unit (``HOUR``, ``DAY``, …) as an interval expression."""
62
+ return exp.Interval(this=exp.Literal.string(str(amount)), unit=exp.Var(this=unit))
63
+
64
+
65
+ def max_is_stale(column: exp.Expr, interval: exp.Expr) -> exp.Expr:
66
+ """``max(column)`` is null or older than ``interval``. An empty table counts."""
67
+ newest = exp.Max(this=column)
68
+ threshold = exp.Sub(this=exp.CurrentTimestamp(), expression=interval)
69
+ return exp.or_(exp.LT(this=newest, expression=threshold), newest.copy().is_(exp.null()))
70
+
71
+
62
72
  def _interval(max_age: str) -> exp.Interval:
63
- match = _AGE_RE.fullmatch(str(max_age))
64
- if match is None:
65
- raise DefinitionError(f"invalid max_age {max_age!r}; expected like '2h', '30m', '1d'")
66
- return exp.Interval(this=exp.Literal.string(match.group(1)), unit=exp.Var(this=_AGE_UNITS[match.group(2)]))
73
+ try:
74
+ parsed = as_grain(str(max_age))
75
+ except ValueError as exc:
76
+ raise DefinitionError(f"invalid max_age {max_age!r}; expected like '2h', '30m', '1d'") from exc
77
+ return sql_interval(parsed.amount, parsed.sql_unit)
67
78
 
68
79
 
69
80
  def _row_predicate(spec: CheckSpec, model: str, dialect: str, resolve: ResolveTable) -> exp.Expr | None:
@@ -147,10 +158,7 @@ def build_check_query(spec: CheckSpec, table: TableRef, model: str, dialect: str
147
158
 
148
159
  if spec.type == "freshness":
149
160
  _require(spec, model, columns=1, params=("max_age",))
150
- threshold = exp.Sub(this=exp.CurrentTimestamp(), expression=_interval(str(params["max_age"])))
151
- newest = exp.Max(this=cols[0])
152
- stale = exp.or_(exp.LT(this=newest, expression=threshold), newest.copy().is_(exp.null())) # no rows = stale
153
- return _flag(table, stale)
161
+ return _flag(table, max_is_stale(cols[0], _interval(str(params["max_age"]))))
154
162
 
155
163
  if spec.type == "sql":
156
164
  _require(spec, model, params=("query",))
@@ -311,7 +311,7 @@ async def _table_diff(
311
311
  engines.close()
312
312
 
313
313
 
314
- async def _apply(
314
+ async def _apply( # noqa: C901
315
315
  environment: str,
316
316
  path: Path,
317
317
  select: list[str],
@@ -420,7 +420,7 @@ def restate(
420
420
  asyncio.run(_execute(environment, path, select, start, end, restate=True, parallelism=parallelism))
421
421
 
422
422
 
423
- async def _execute(
423
+ async def _execute( # noqa: C901
424
424
  environment: str,
425
425
  path: Path,
426
426
  select: list[str],
@@ -170,7 +170,14 @@ async def _scheduler(environment: str, path: Path, interval: float, once: bool)
170
170
  """Same loops as ``interlace serve``: reload, tick, trim, drain, flush, CDC."""
171
171
  from types import SimpleNamespace
172
172
 
173
- from interlace.scheduler.daemon import cdc_loop, flush_once, flusher_loop, scheduler_loop, source_mtime
173
+ from interlace.scheduler.daemon import (
174
+ cdc_loop,
175
+ flush_once,
176
+ flusher_loop,
177
+ remember_runtime,
178
+ scheduler_loop,
179
+ source_mtime,
180
+ )
174
181
  from interlace.streaming.materializer import quarantine_stream, stream_consumers
175
182
 
176
183
  project = Project.load(path)
@@ -209,6 +216,7 @@ async def _scheduler(environment: str, path: Path, interval: float, once: bool)
209
216
  stream_consumer_map={name: sorted(stream_consumers(compiled, name)) for name in streams},
210
217
  )
211
218
  host.flushed_heads = dict(host.log_heads)
219
+ remember_runtime(host, project.config)
212
220
  flusher: asyncio.Task[None] | None = None
213
221
  cdc: asyncio.Task[None] | None = None
214
222
  try:
@@ -277,6 +285,11 @@ def serve(
277
285
  True, "--scheduler/--no-scheduler", help="Run the scheduler loop in this process (combined daemon)."
278
286
  ),
279
287
  interval: float = typer.Option(60.0, "--interval", help="Seconds between scheduler ticks."),
288
+ apply_on_start: bool = typer.Option(
289
+ True,
290
+ "--apply/--no-apply",
291
+ help="Apply the project once at startup. --no-apply serves the warehouse as it is.",
292
+ ),
280
293
  allow_open: bool = typer.Option(
281
294
  False,
282
295
  "--allow-open",
@@ -286,6 +299,7 @@ def serve(
286
299
  """Run the interlace daemon: HTTP API + scheduler in one process (requires the `service` extra).
287
300
 
288
301
  Use --no-scheduler for an API-only process (run `interlace scheduler` separately).
302
+ The project is applied once before the loops start; pass --no-apply to skip that.
289
303
  A `cdc:` block in the project config is read here: each Postgres slot appends into its stream.
290
304
  """
291
305
  try:
@@ -331,6 +345,7 @@ def serve(
331
345
  quack_token=token or None,
332
346
  scheduler=scheduler,
333
347
  scheduler_interval=interval,
348
+ apply_on_start=apply_on_start,
334
349
  )
335
350
  config = uvicorn.Config(
336
351
  app,
@@ -243,7 +243,7 @@ def _window(value: str, flag: str) -> datetime | None:
243
243
  raise typer.Exit(2) from exc
244
244
 
245
245
 
246
- def _render(plan: Plan, environment: str) -> None:
246
+ def _render(plan: Plan, environment: str) -> None: # noqa: C901
247
247
  if not plan.changes and not plan.physical and not plan.transfers and not plan.drift and not plan.warnings:
248
248
  console.print(f"No changes for [bold]{environment}[/bold].")
249
249
  return
@@ -13,6 +13,7 @@ left literal so a missing one surfaces as an obvious ``${VAR}`` in errors.
13
13
 
14
14
  from __future__ import annotations
15
15
 
16
+ import datetime
16
17
  import os
17
18
  import re
18
19
  from pathlib import Path
@@ -143,6 +144,9 @@ class InputConfig(BaseModel):
143
144
  format: Literal["parquet", "csv", "json", "delta", "iceberg"]
144
145
  path: str
145
146
  connection: str | None = None # http connection or an engine secret, for remote paths
147
+ # Hash the local file bytes into every model that reads this input, so an edit
148
+ # is a plan change. ``schedule: {watch:}`` only re-runs; it does not do this.
149
+ watch: bool = False
146
150
 
147
151
  @model_validator(mode="after")
148
152
  def _env_resolved(self) -> InputConfig:
@@ -154,8 +158,9 @@ class EngineConfig(BaseModel):
154
158
  """One named execution engine (warehouse gateway).
155
159
 
156
160
  DuckDB-family types (``duckdb`` / ``ducklake`` / ``quack``) and ``postgres`` are
157
- the tested set. ``spark`` is beta; ``motherduck`` / ``redshift`` / ``snowflake`` /
158
- ``bigquery`` are alpha (wired and dialect-correct, not yet live-validated).
161
+ the tested set. ``spark`` is beta. ``snowflake`` is alpha and has been exercised
162
+ against one account (not in CI). ``motherduck`` / ``redshift`` / ``bigquery`` are
163
+ alpha (wired and dialect-correct, not yet live-validated).
159
164
  """
160
165
 
161
166
  type: str = "duckdb"
@@ -181,6 +186,60 @@ class EngineConfig(BaseModel):
181
186
  return _TYPE_DIALECT.get(self.type, "duckdb")
182
187
 
183
188
 
189
+ class VarConfig(BaseModel):
190
+ """A typed value a SQL model reads with ``var('name')``.
191
+
192
+ The call is replaced in the AST before fingerprinting, so editing ``value``
193
+ rebuilds every model that references the name. ``${VAR}`` in the YAML is
194
+ already expanded by the time this is parsed.
195
+ """
196
+
197
+ type: Literal["string", "int", "float", "bool", "date", "timestamp"]
198
+ value: str | int | float | bool
199
+
200
+ @model_validator(mode="after")
201
+ def _value_matches_type(self) -> VarConfig:
202
+ kind = self.type
203
+ value = self.value
204
+ if kind == "string":
205
+ if not isinstance(value, str):
206
+ raise ValueError("string var value must be a string")
207
+ elif kind == "int":
208
+ if type(value) is not int:
209
+ raise ValueError("int var value must be an integer")
210
+ elif kind == "float":
211
+ if type(value) not in (int, float):
212
+ raise ValueError("float var value must be a number")
213
+ elif kind == "bool":
214
+ if type(value) is not bool:
215
+ raise ValueError("bool var value must be true or false")
216
+ elif kind == "date":
217
+ self.value = _as_date(value)
218
+ else:
219
+ self.value = _as_timestamp(value)
220
+ return self
221
+
222
+
223
+ def _as_date(value: object) -> str:
224
+ if isinstance(value, str):
225
+ try:
226
+ datetime.date.fromisoformat(value)
227
+ except ValueError as exc:
228
+ raise ValueError(f"date var value must be YYYY-MM-DD, got {value!r}") from exc
229
+ return value
230
+ raise ValueError("date var value must be a YYYY-MM-DD string")
231
+
232
+
233
+ def _as_timestamp(value: object) -> str:
234
+ if isinstance(value, str):
235
+ try:
236
+ datetime.datetime.fromisoformat(value)
237
+ except ValueError as exc:
238
+ raise ValueError(f"timestamp var value must be ISO-8601, got {value!r}") from exc
239
+ return value
240
+ raise ValueError("timestamp var value must be an ISO-8601 string")
241
+
242
+
184
243
  class ProjectConfig(BaseModel):
185
244
  """Top-level project settings."""
186
245
 
@@ -234,10 +293,20 @@ class ProjectConfig(BaseModel):
234
293
  connections: dict[str, ConnectionConfig] = Field(default_factory=dict)
235
294
  # External files DuckDB scans as relations models can FROM. Other engines reject them.
236
295
  inputs: dict[str, InputConfig] = Field(default_factory=dict)
296
+ # Typed literals SQL models read with var('name'). Expanded into the AST at
297
+ # compile time, so a changed value is a plan change for the models that use it.
298
+ vars: dict[str, VarConfig] = Field(default_factory=dict)
237
299
  # Postgres logical replication into a declared @stream. Empty means the daemon
238
300
  # does not open a replication connection.
239
301
  cdc: dict[str, CdcConfig] = Field(default_factory=dict)
240
302
 
303
+ @model_validator(mode="after")
304
+ def _var_names_are_identifiers(self) -> ProjectConfig:
305
+ for name in self.vars:
306
+ if not _ENV_KEY.fullmatch(name):
307
+ raise ValueError(f"var {name!r} must be an identifier")
308
+ return self
309
+
241
310
  @model_validator(mode="after")
242
311
  def _cdc_uses_postgres(self) -> ProjectConfig:
243
312
  for name, source in self.cdc.items():
@@ -9,7 +9,7 @@ Compilation, planning, and execution happen later over the registry. UK spelling
9
9
  from __future__ import annotations
10
10
 
11
11
  import re
12
- from collections.abc import Callable, Sequence
12
+ from collections.abc import Callable, Mapping, Sequence
13
13
  from contextvars import ContextVar
14
14
  from dataclasses import dataclass, field
15
15
  from typing import Any
@@ -30,6 +30,28 @@ from interlace.strategies import named_strategy
30
30
 
31
31
  ModelFn = Callable[..., Any]
32
32
 
33
+
34
+ def normalize_schedule(schedule: Mapping[str, Any] | None, *, model: str) -> dict[str, str] | None:
35
+ """Schedules are string values. ``fresh: {column, within}`` becomes ``"column within"``."""
36
+ if not schedule:
37
+ return None
38
+ normalised: dict[str, str] = {}
39
+ for key, value in schedule.items():
40
+ if key == "fresh" and isinstance(value, dict):
41
+ column = value.get("column")
42
+ window = value.get("within")
43
+ if not isinstance(column, str) or not column.strip():
44
+ raise DefinitionError(f"model {model!r}: fresh.column must name a timestamp column")
45
+ if not isinstance(window, str) or not window.strip():
46
+ raise DefinitionError(f"model {model!r}: fresh.within must be a window like '2h'")
47
+ normalised[key] = f"{column.strip()} {window.strip()}"
48
+ continue
49
+ if not isinstance(value, str):
50
+ raise DefinitionError(f"model {model!r}: schedule {key!r} must be a string")
51
+ normalised[key] = value
52
+ return normalised
53
+
54
+
33
55
  # Set while a Python model function is running. Registrations then replace a
34
56
  # model this run already created, and are recorded for the apply that builds them.
35
57
  dynamic_owner: ContextVar[str | None] = ContextVar("interlace_dynamic_owner", default=None)
@@ -130,7 +152,9 @@ class ModelDef:
130
152
  path: str | None = None # output path for materialise: file
131
153
  format: str | None = None # csv | parquet | json for materialise: file
132
154
  environments: tuple[str, ...] = ("prod",) # which environments actually deliver a terminal model
133
- schedule: dict[str, str] | None = None # cron, every, watch (glob), or webhook name
155
+ # cron, every, watch, on_change, fresh, or webhook. ``fresh`` may be the
156
+ # mapping ``{column, within}`` at construction; __post_init__ stores "column within".
157
+ schedule: dict[str, Any] | None = None
134
158
  checks: tuple[CheckSpec, ...] = () # data-quality checks; error severity gates promotion
135
159
  indexes: tuple[IndexSpec, ...] = () # physical indexes; not part of the data fingerprint
136
160
  constraints: tuple[ConstraintSpec, ...] = () # physical constraints; engine-enforced, not checks
@@ -151,6 +175,7 @@ class ModelDef:
151
175
  self.constraints = parse_constraints(self.constraints, self.name)
152
176
  self.schema_policy = parse_schema_policy(self.schema_policy, self.name)
153
177
  validate_physical_allowed(self.name, self.materialise, self.indexes, self.constraints, self.schema_policy)
178
+ self.schedule = normalize_schedule(self.schedule, model=self.name)
154
179
 
155
180
  @property
156
181
  def is_terminal(self) -> bool:
@@ -242,7 +267,7 @@ def model(
242
267
  owner: str | None = None,
243
268
  description: str | None = None,
244
269
  columns: dict[str, str | None] | Sequence[str] | None = None,
245
- schedule: dict[str, str] | None = None,
270
+ schedule: dict[str, Any] | None = None,
246
271
  checks: Sequence[dict[str, Any] | CheckSpec] | None = None,
247
272
  indexes: Sequence[dict[str, Any] | IndexSpec] | None = None,
248
273
  constraints: Sequence[dict[str, Any] | ConstraintSpec] | None = None,
@@ -95,7 +95,7 @@ def render_model_sql(definition: ModelDef) -> str:
95
95
  return f"/*\n{header}\n*/\n{definition.sql.strip()}\n"
96
96
 
97
97
 
98
- def model_to_config(definition: ModelDef) -> dict[str, Any]:
98
+ def model_to_config(definition: ModelDef) -> dict[str, Any]: # noqa: C901
99
99
  """The ``interlace:`` mapping ``_sql_model`` reads. Defaults are omitted."""
100
100
  config: dict[str, Any] = {"name": definition.name}
101
101
  _keep(config, "materialise", definition.materialise, MATERIALISE_DEFAULT)
@@ -22,11 +22,11 @@ from typing import Any
22
22
  import pyarrow as pa
23
23
  from sqlglot import exp
24
24
 
25
- from interlace.engines.base import EngineAdapter, EngineCaps, LoadMode, note_statement
25
+ from interlace.engines.base import EngineAdapter, EngineCaps, LoadMode, note_statement, relation_is_absent
26
26
  from interlace.ir.relation import TableRef
27
27
 
28
28
 
29
- def arrow_type_name(dtype: pa.DataType) -> str:
29
+ def arrow_type_name(dtype: pa.DataType) -> str: # noqa: C901
30
30
  """A canonical SQL type name for an Arrow type, in the vocabulary the planner's
31
31
  alignment/widening logic understands (see ``plan.apply``)."""
32
32
  if pa.types.is_boolean(dtype):
@@ -162,6 +162,8 @@ class AdbcAdapter(EngineAdapter):
162
162
  with self._lock:
163
163
  try:
164
164
  schema = self._conn.adbc_get_table_schema(table.name, db_schema_filter=table.schema)
165
- except Exception: # driver raises when the table is absent -> treat as "no columns"
166
- return {}
165
+ except Exception as exc:
166
+ if relation_is_absent(exc):
167
+ return {}
168
+ raise
167
169
  return {field.name: arrow_type_name(field.type) for field in schema}
@@ -42,6 +42,19 @@ def note_statement(exc: BaseException, sql: str) -> None:
42
42
  return
43
43
 
44
44
 
45
+ def relation_is_absent(exc: BaseException) -> bool:
46
+ """Whether ``exc`` means the relation is missing, not that the probe itself failed.
47
+
48
+ Catalog helpers treat a missing table as an empty description. Any other
49
+ error has to surface: swallowing it makes a broken catalog look like no columns.
50
+ """
51
+ message = str(exc).casefold()
52
+ if "table function" in message:
53
+ return False
54
+ markers = ("does not exist", "doesn't exist", "not found", "no such table", "undefined table", "unknown table")
55
+ return any(marker in message for marker in markers)
56
+
57
+
45
58
  def statement_of(exc: BaseException) -> str | None:
46
59
  """The SQL attached to ``exc`` or to the error it was raised from."""
47
60
  seen: set[int] = set()
@@ -163,7 +176,9 @@ class EngineAdapter(ABC):
163
176
 
164
177
  def transpile(self, ast: exp.Expr) -> str:
165
178
  """Canonical AST -> this engine's SQL. The one place dialect leaks back in."""
166
- return ast.sql(dialect=self.dialect)
179
+ from interlace.ir.dialect import render_sql
180
+
181
+ return render_sql(ast, self.dialect)
167
182
 
168
183
  def refresh_inputs(self) -> None:
169
184
  """Re-expand input views whose paths contain date tokens.
@@ -178,3 +193,7 @@ class EngineAdapter(ABC):
178
193
  def attach(self, alias: str, uri: str) -> None:
179
194
  """ATTACH another database under ``alias``. Only engines with ``supports_attach``."""
180
195
  raise NotImplementedError(f"{type(self).__name__} cannot ATTACH another database")
196
+
197
+ @abstractmethod
198
+ def close(self) -> None:
199
+ """Release the connection this adapter holds."""
@@ -39,7 +39,7 @@ import pyarrow as pa
39
39
  import tenacity
40
40
  from sqlglot import exp
41
41
 
42
- from interlace.engines.base import EngineAdapter, EngineCaps, LoadMode, note_statement
42
+ from interlace.engines.base import EngineAdapter, EngineCaps, LoadMode, note_statement, relation_is_absent
43
43
  from interlace.exceptions import ConfigurationError
44
44
  from interlace.ir.relation import TableRef
45
45
 
@@ -307,8 +307,10 @@ class DuckDBAdapter(EngineAdapter):
307
307
  )
308
308
  try:
309
309
  reader = await self.fetch_sql(sql)
310
- except Exception:
311
- return []
310
+ except Exception as exc:
311
+ if relation_is_absent(exc):
312
+ return []
313
+ raise
312
314
  return [str(row[column]) for row in reader.read_all().to_pylist() if row.get(column)]
313
315
 
314
316
  # --- sync workers (run in a thread) -------------------------------------
@@ -20,7 +20,7 @@ from __future__ import annotations
20
20
  from sqlglot import exp
21
21
 
22
22
  from interlace.engines.adbc import AdbcAdapter
23
- from interlace.engines.base import EngineCaps
23
+ from interlace.engines.base import EngineCaps, relation_is_absent
24
24
  from interlace.exceptions import ConfigurationError
25
25
  from interlace.ir.relation import TableRef
26
26
 
@@ -83,8 +83,10 @@ class PostgresAdapter(AdbcAdapter):
83
83
  reader = await self.fetch_sql(
84
84
  f"SELECT indexname FROM pg_indexes WHERE schemaname = {schema} AND tablename = {name}"
85
85
  )
86
- except Exception:
87
- return []
86
+ except Exception as exc:
87
+ if relation_is_absent(exc):
88
+ return []
89
+ raise
88
90
  return [str(row["indexname"]) for row in reader.read_all().to_pylist() if row.get("indexname")]
89
91
 
90
92
  async def list_constraints(self, table: TableRef) -> list[str]:
@@ -97,6 +99,8 @@ class PostgresAdapter(AdbcAdapter):
97
99
  "JOIN pg_namespace nsp ON nsp.oid = rel.relnamespace "
98
100
  f"WHERE nsp.nspname = {schema} AND rel.relname = {name}"
99
101
  )
100
- except Exception:
101
- return []
102
+ except Exception as exc:
103
+ if relation_is_absent(exc):
104
+ return []
105
+ raise
102
106
  return [str(row["name"]) for row in reader.read_all().to_pylist() if row.get("name")]
@@ -33,6 +33,7 @@ class EngineRegistry(Mapping[str, EngineAdapter]):
33
33
  *,
34
34
  default: str = "default",
35
35
  attach_uris: Mapping[str, str] | None = None,
36
+ sinks: Mapping[str, str] | None = None,
36
37
  ) -> None:
37
38
  self._names = frozenset(names)
38
39
  self._opener = opener
@@ -41,6 +42,9 @@ class EngineRegistry(Mapping[str, EngineAdapter]):
41
42
  # engine name -> URI another DuckDB-family engine could ATTACH (transfer
42
43
  # fast lane); absent/None = only reachable through its own adapter.
43
44
  self.attach_uris: dict[str, str] = dict(attach_uris or {})
45
+ # reverse-ETL alias -> database URI. A DuckDB warehouse ATTACHes these.
46
+ # An engine that cannot ATTACH opens the URI and writes the delivery there.
47
+ self.sinks: dict[str, str] = dict(sinks or {})
44
48
  if default not in self._names:
45
49
  raise ConfigurationError(
46
50
  f"default_engine {default!r} is not a configured engine",
@@ -0,0 +1,89 @@
1
+ """Snowflake engine adapter (ALPHA — exercised against one account, not in CI).
2
+
3
+ An ADBC-transport engine: it inherits all of :class:`~interlace.engines.adbc.AdbcAdapter`
4
+ (execute / fetch → Arrow / ``adbc_ingest`` bulk load / metadata ``describe``) and
5
+ only sets the dialect and capabilities. Snowflake supports the full strategy set:
6
+ ``CREATE OR REPLACE TABLE``, ``SELECT * EXCLUDE`` (so scd works with ``SELECT *``),
7
+ and a native ``MERGE``.
8
+
9
+ Snowflake folds unquoted identifiers to uppercase and returns those names. Fetch
10
+ and describe fold them back, so contracts and Python models keep the names the
11
+ SQL was written with. ``adbc_ingest`` on this driver rejects a target schema, so
12
+ loads ``USE SCHEMA`` first.
13
+
14
+ Requires the ``adbc-snowflake`` extra. The connection string is the Snowflake ADBC
15
+ URI (``user[:password]@account/database/schema?warehouse=WH&role=R``); key-pair and
16
+ external-browser auth go through ``db_kwargs``.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import pyarrow as pa
22
+ from sqlglot import exp
23
+
24
+ from interlace.engines.adbc import AdbcAdapter
25
+ from interlace.engines.base import EngineCaps, LoadMode
26
+ from interlace.exceptions import ConfigurationError
27
+ from interlace.ir.relation import TableRef
28
+
29
+ _SNOWFLAKE_CAPS = EngineCaps(
30
+ supports_create_or_replace=True,
31
+ supports_star_exclude=True, # SELECT * EXCLUDE (...)
32
+ supports_merge=True,
33
+ supports_transactions=True,
34
+ # PRIMARY KEY / UNIQUE / FOREIGN KEY are informational. NOT NULL is enforced.
35
+ enforced_constraints=frozenset({"not_null"}),
36
+ )
37
+
38
+
39
+ class SnowflakeAdapter(AdbcAdapter):
40
+ """Executes canonical ASTs inside Snowflake; Arrow in and out via ADBC."""
41
+
42
+ dialect = "snowflake"
43
+ caps = _SNOWFLAKE_CAPS
44
+
45
+ @classmethod
46
+ def connect(cls, dsn: str) -> SnowflakeAdapter:
47
+ try:
48
+ import adbc_driver_snowflake.dbapi as dbapi
49
+ except ImportError as exc: # pragma: no cover - import guard
50
+ raise ConfigurationError(
51
+ "the snowflake engine needs the 'adbc-snowflake' extra: pip install 'interlaced[adbc-snowflake]'"
52
+ ) from exc
53
+ return cls(dbapi.connect(dsn))
54
+
55
+ def _fetch_sync(self, sql: str) -> pa.RecordBatchReader:
56
+ table = super()._fetch_sync(sql).read_all()
57
+ return table.rename_columns([name.casefold() for name in table.column_names]).to_reader()
58
+
59
+ def _describe_sync(self, table: TableRef) -> dict[str, str]:
60
+ # The driver quotes the name it is given. Unquoted SQL folds to uppercase,
61
+ # so a lowercase lookup looks for a different table and comes back empty.
62
+ folded = TableRef(
63
+ schema=table.schema.upper(),
64
+ name=table.name.upper(),
65
+ catalog=table.catalog.upper() if table.catalog else None,
66
+ )
67
+ described = super()._describe_sync(folded)
68
+ return {name.casefold(): kind for name, kind in described.items()}
69
+
70
+ def _load_sync(self, table: TableRef, reader: pa.RecordBatchReader, mode: LoadMode) -> int:
71
+ # This driver rejects adbc.ingest.target_db_schema. The session schema is
72
+ # the ingest target; unquoted names fold the same way the model's SQL does.
73
+ ingest_mode = "replace" if mode == "create" else "append"
74
+ schema = exp.to_identifier(table.schema).sql(dialect=self.dialect) if table.schema else ""
75
+ # Arrow names are the SQL as written (lowercase). The driver quotes them,
76
+ # so they must be the uppercase form unquoted SQL folds to.
77
+ loaded_table = reader.read_all()
78
+ batch = loaded_table.rename_columns([name.upper() for name in loaded_table.column_names])
79
+ with self._lock:
80
+ try:
81
+ with self._conn.cursor() as cur:
82
+ if schema:
83
+ cur.execute(f"USE SCHEMA {schema}")
84
+ loaded = cur.adbc_ingest(table.name.upper(), batch, mode=ingest_mode)
85
+ self._conn.commit()
86
+ except Exception:
87
+ self._conn.rollback()
88
+ raise
89
+ return int(loaded) if isinstance(loaded, int) and loaded > 0 else 0