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.
- {interlaced-2.8.2/src/interlaced.egg-info → interlaced-2.8.4}/PKG-INFO +6 -7
- {interlaced-2.8.2 → interlaced-2.8.4}/README.md +5 -6
- {interlaced-2.8.2 → interlaced-2.8.4}/pyproject.toml +5 -3
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/checks/builtin.py +19 -11
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/cli/build.py +2 -2
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/cli/operate.py +16 -1
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/cli/support.py +1 -1
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/config/config.py +71 -2
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/dsl/decorators.py +28 -3
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/dsl/dynamic.py +1 -1
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/engines/adbc.py +6 -4
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/engines/base.py +20 -1
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/engines/duckdb.py +5 -3
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/engines/postgres.py +9 -5
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/engines/registry.py +4 -0
- interlaced-2.8.4/src/interlace/engines/snowflake.py +89 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/graph/project.py +35 -8
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/inputs.py +71 -0
- interlaced-2.8.4/src/interlace/ir/dialect.py +111 -0
- interlaced-2.8.4/src/interlace/ir/fingerprint.py +231 -0
- interlaced-2.8.4/src/interlace/ir/vars.py +88 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/mcp_server.py +1 -1
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/physical/drift.py +1 -1
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/physical/spec.py +1 -1
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/plan/annotate.py +1 -1
- interlaced-2.8.4/src/interlace/plan/apply.py +168 -0
- interlaced-2.8.4/src/interlace/plan/backfill.py +441 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/plan/comment.py +1 -1
- interlaced-2.8.4/src/interlace/plan/delivery.py +249 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/plan/differ.py +3 -3
- interlaced-2.8.4/src/interlace/plan/files.py +79 -0
- interlaced-2.8.4/src/interlace/plan/fit.py +234 -0
- interlaced-2.8.4/src/interlace/plan/result.py +68 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/plan/run.py +64 -36
- interlaced-2.8.4/src/interlace/plan/schedule.py +185 -0
- interlaced-2.8.4/src/interlace/plan/transfer.py +89 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/project.py +30 -9
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/runtime/python_model.py +3 -1
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/scheduler/daemon.py +115 -3
- interlaced-2.8.4/src/interlace/scheduler/engine.py +254 -0
- interlaced-2.8.4/src/interlace/scheduler/triggers.py +237 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/scheduler/worker.py +49 -24
- interlaced-2.8.4/src/interlace/service/app.py +230 -0
- interlaced-2.8.2/src/interlace/service/app.py → interlaced-2.8.4/src/interlace/service/lifespan.py +27 -206
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/present.py +3 -2
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/routes/catalog.py +10 -3
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/routes/runs.py +4 -3
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/routes/streams.py +2 -2
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/types.py +1 -1
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/ui/js/views/system.js +1 -1
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/sinks.py +39 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/state/advisory.py +6 -3
- interlaced-2.8.4/src/interlace/state/beat.py +43 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/state/interval.py +21 -4
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/state/janitor.py +2 -2
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/state/locks.py +9 -19
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/state/queue.py +3 -2
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/state/store.py +9 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/strategies/__init__.py +1 -1
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/strategies/full_merge.py +1 -1
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/streaming/materializer.py +9 -2
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/streaming/schema.py +1 -1
- {interlaced-2.8.2 → interlaced-2.8.4/src/interlaced.egg-info}/PKG-INFO +6 -7
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlaced.egg-info/SOURCES.txt +11 -0
- interlaced-2.8.2/src/interlace/engines/snowflake.py +0 -44
- interlaced-2.8.2/src/interlace/ir/fingerprint.py +0 -67
- interlaced-2.8.2/src/interlace/plan/apply.py +0 -1145
- interlaced-2.8.2/src/interlace/scheduler/engine.py +0 -110
- interlaced-2.8.2/src/interlace/scheduler/triggers.py +0 -127
- {interlaced-2.8.2 → interlaced-2.8.4}/LICENSE +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/MANIFEST.in +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/setup.cfg +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/__init__.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/cdc/__init__.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/cdc/decode.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/cdc/publish.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/cdc/slot.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/checks/__init__.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/checks/runner.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/checks/spec.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/cli/__init__.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/cli/inspect.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/cli/main.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/config/__init__.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/connections.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/contracts.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/dsl/__init__.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/dsl/discovery.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/dsl/sql_config.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/engines/__init__.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/engines/bigquery.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/engines/quack.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/engines/redshift.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/engines/spark.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/exceptions.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/graph/__init__.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/graph/column_lineage.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/graph/dag.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/graph/selectors.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/inspect.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/ir/__init__.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/ir/canonicalize.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/ir/layout.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/ir/macros.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/ir/relation.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/physical/__init__.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/physical/changes.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/physical/ddl.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/physical/reconcile.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/plan/__init__.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/plan/orchestrate.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/plan/payload.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/plan/plan.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/plan/resolve.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/plan/table_diff.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/py.typed +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/query.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/runtime/__init__.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/runtime/handles.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/scaffold.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/scheduler/__init__.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/__init__.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/auth.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/routes/__init__.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/routes/admin.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/routes/environments.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/routes/models.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/routes/plan.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/ui/app.css +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/ui/favicon.svg +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/ui/index.html +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/ui/js/api.js +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/ui/js/app.js +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/ui/js/dag.js +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/ui/js/timeline.js +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/ui/js/ui.js +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/ui/js/views/checks.js +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/ui/js/views/environments.js +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/ui/js/views/lineage.js +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/ui/js/views/models.js +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/ui/js/views/overview.js +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/ui/js/views/plan.js +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/ui/js/views/query.js +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/ui/js/views/runs.js +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/service/ui/js/views/streams.js +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/sources/__init__.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/sources/auth.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/sources/rest.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/state/__init__.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/state/cdc.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/state/checks.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/state/codec.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/state/conn.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/state/events.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/state/keys.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/state/schema.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/state/snapshot.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/state/snapshots.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/state/triggers.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/strategies/append.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/strategies/base.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/strategies/hash_merge.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/strategies/incremental.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/strategies/merge.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/strategies/replace.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/strategies/replace_in_place.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/strategies/scd.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/strategies/view.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/streaming/__init__.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/streaming/log.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/cdc/README.md +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/cdc/docker-compose.yml +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/cdc/init/seed.sql +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/cdc/interlace.yaml +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/cdc/models/orders.sql +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/cdc/models/orders_by_status.sql +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/cdc/models/orders_stream.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/cdc/push.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/cdc/template.yaml +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/events/README.md +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/events/generate.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/events/interlace.yaml +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/events/models/events.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/events/models/events_by_minute.sql +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/events/models/events_by_type.sql +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/events/models/top_users.sql +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/events/models/user_spend.sql +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/events/template.yaml +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/github/README.md +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/github/interlace.yaml +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/github/models/github_issues.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/github/models/issues_by_state.sql +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/github/template.yaml +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/postgres/README.md +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/postgres/docker-compose.yml +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/postgres/init/seed.sql +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/postgres/interlace.yaml +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/postgres/models/orders.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/postgres/models/orders_by_status.sql +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/postgres/template.yaml +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/quickstart/README.md +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/quickstart/interlace.yaml +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/quickstart/models/enriched_events.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/quickstart/models/event_summary.sql +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/quickstart/models/raw_events.sql +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/templates/quickstart/template.yaml +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/testing/__init__.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlace/testing/golden.py +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlaced.egg-info/dependency_links.txt +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlaced.egg-info/entry_points.txt +0 -0
- {interlaced-2.8.2 → interlaced-2.8.4}/src/interlaced.egg-info/requires.txt +0 -0
- {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.
|
|
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
|
|
114
|
-
>
|
|
115
|
-
>
|
|
116
|
-
>
|
|
117
|
-
>
|
|
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
|
|
46
|
-
>
|
|
47
|
-
>
|
|
48
|
-
>
|
|
49
|
-
>
|
|
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.
|
|
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
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
158
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
166
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|