Merge pull request 'feat: DAB generates transpiled SQL + DDL instead of wheel wrapper' (#181) from feat/dab-generator into main
This commit was merged in pull request #181.
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -8,6 +8,10 @@ zotero/profiles/
|
|||||||
tuva/
|
tuva/
|
||||||
logs/**
|
logs/**
|
||||||
|
|
||||||
|
# Generated DAB SQL/DDL (regenerate via gen_config.py)
|
||||||
|
bundle/sql/
|
||||||
|
bundle/ddl/
|
||||||
|
|
||||||
# Marimo cache
|
# Marimo cache
|
||||||
notebooks/__marimo__/
|
notebooks/__marimo__/
|
||||||
.deploy_key
|
.deploy_key
|
||||||
|
|||||||
181
databricks.yml
181
databricks.yml
@@ -3,15 +3,15 @@
|
|||||||
|
|
||||||
bundle:
|
bundle:
|
||||||
name: stack
|
name: stack
|
||||||
artifacts:
|
sync:
|
||||||
default:
|
include:
|
||||||
type: whl
|
- bundle/**
|
||||||
build: uv build
|
|
||||||
path: .
|
|
||||||
variables:
|
variables:
|
||||||
catalog:
|
catalog:
|
||||||
description: Unity Catalog name for table references
|
description: Unity Catalog name for table references
|
||||||
default: aco
|
default: aco
|
||||||
|
warehouse_id:
|
||||||
|
description: SQL warehouse ID for query execution
|
||||||
workspace:
|
workspace:
|
||||||
host: ${DATABRICKS_HOST}
|
host: ${DATABRICKS_HOST}
|
||||||
targets:
|
targets:
|
||||||
@@ -41,113 +41,71 @@ resources:
|
|||||||
spark_version: 15.4.x-scala2.12
|
spark_version: 15.4.x-scala2.12
|
||||||
node_type_id: i3.xlarge
|
node_type_id: i3.xlarge
|
||||||
num_workers: 1
|
num_workers: 1
|
||||||
spark_conf:
|
|
||||||
spark.sql.catalog.${var.catalog}: org.apache.iceberg.spark.SparkCatalog
|
|
||||||
tasks:
|
tasks:
|
||||||
- task_key: ahrq_measures
|
- task_key: ahrq_measures
|
||||||
depends_on:
|
depends_on:
|
||||||
- task_key: core
|
- task_key: core
|
||||||
job_cluster_key: default_cluster
|
job_cluster_key: default_cluster
|
||||||
python_wheel_task:
|
sql_task:
|
||||||
package_name: stack
|
file:
|
||||||
entry_point: cli
|
path: bundle/sql/ahrq_measures/
|
||||||
parameters:
|
warehouse_id: ${var.warehouse_id}
|
||||||
- run
|
|
||||||
- ahrq_measures
|
|
||||||
- --target
|
|
||||||
- databricks
|
|
||||||
- --save
|
|
||||||
- task_key: cclf
|
- task_key: cclf
|
||||||
depends_on:
|
depends_on:
|
||||||
- task_key: input_layer
|
- task_key: input_layer
|
||||||
job_cluster_key: default_cluster
|
job_cluster_key: default_cluster
|
||||||
python_wheel_task:
|
sql_task:
|
||||||
package_name: stack
|
file:
|
||||||
entry_point: cli
|
path: bundle/sql/cclf/
|
||||||
parameters:
|
warehouse_id: ${var.warehouse_id}
|
||||||
- run
|
|
||||||
- cclf
|
|
||||||
- --target
|
|
||||||
- databricks
|
|
||||||
- --save
|
|
||||||
- task_key: claims_preprocessing
|
- task_key: claims_preprocessing
|
||||||
depends_on:
|
depends_on:
|
||||||
- task_key: input_layer
|
- task_key: input_layer
|
||||||
job_cluster_key: default_cluster
|
job_cluster_key: default_cluster
|
||||||
python_wheel_task:
|
sql_task:
|
||||||
package_name: stack
|
file:
|
||||||
entry_point: cli
|
path: bundle/sql/claims_preprocessing/
|
||||||
parameters:
|
warehouse_id: ${var.warehouse_id}
|
||||||
- run
|
|
||||||
- claims_preprocessing
|
|
||||||
- --target
|
|
||||||
- databricks
|
|
||||||
- --save
|
|
||||||
- task_key: cms_quality_measures
|
- task_key: cms_quality_measures
|
||||||
depends_on:
|
depends_on:
|
||||||
- task_key: core
|
- task_key: core
|
||||||
job_cluster_key: default_cluster
|
job_cluster_key: default_cluster
|
||||||
python_wheel_task:
|
sql_task:
|
||||||
package_name: stack
|
file:
|
||||||
entry_point: cli
|
path: bundle/sql/cms_quality_measures/
|
||||||
parameters:
|
warehouse_id: ${var.warehouse_id}
|
||||||
- run
|
|
||||||
- cms_quality_measures
|
|
||||||
- --target
|
|
||||||
- databricks
|
|
||||||
- --save
|
|
||||||
- task_key: core
|
- task_key: core
|
||||||
depends_on:
|
depends_on:
|
||||||
- task_key: claims_preprocessing
|
- task_key: claims_preprocessing
|
||||||
job_cluster_key: default_cluster
|
job_cluster_key: default_cluster
|
||||||
python_wheel_task:
|
sql_task:
|
||||||
package_name: stack
|
file:
|
||||||
entry_point: cli
|
path: bundle/sql/core/
|
||||||
parameters:
|
warehouse_id: ${var.warehouse_id}
|
||||||
- run
|
|
||||||
- core
|
|
||||||
- --target
|
|
||||||
- databricks
|
|
||||||
- --save
|
|
||||||
- task_key: data_quality
|
- task_key: data_quality
|
||||||
depends_on:
|
depends_on:
|
||||||
- task_key: core
|
- task_key: core
|
||||||
- task_key: readmissions
|
- task_key: readmissions
|
||||||
- task_key: pharmacy
|
- task_key: pharmacy
|
||||||
job_cluster_key: default_cluster
|
job_cluster_key: default_cluster
|
||||||
python_wheel_task:
|
sql_task:
|
||||||
package_name: stack
|
file:
|
||||||
entry_point: cli
|
path: bundle/sql/data_quality/
|
||||||
parameters:
|
warehouse_id: ${var.warehouse_id}
|
||||||
- run
|
|
||||||
- data_quality
|
|
||||||
- --target
|
|
||||||
- databricks
|
|
||||||
- --save
|
|
||||||
- task_key: hcc_suspecting
|
- task_key: hcc_suspecting
|
||||||
depends_on:
|
depends_on:
|
||||||
- task_key: core
|
- task_key: core
|
||||||
job_cluster_key: default_cluster
|
job_cluster_key: default_cluster
|
||||||
python_wheel_task:
|
sql_task:
|
||||||
package_name: stack
|
file:
|
||||||
entry_point: cli
|
path: bundle/sql/hcc_suspecting/
|
||||||
parameters:
|
warehouse_id: ${var.warehouse_id}
|
||||||
- run
|
|
||||||
- hcc_suspecting
|
|
||||||
- --target
|
|
||||||
- databricks
|
|
||||||
- --save
|
|
||||||
- task_key: input_layer
|
- task_key: input_layer
|
||||||
job_cluster_key: default_cluster
|
job_cluster_key: default_cluster
|
||||||
python_wheel_task:
|
sql_task:
|
||||||
package_name: stack
|
file:
|
||||||
entry_point: cli
|
path: bundle/sql/input_layer/
|
||||||
parameters:
|
warehouse_id: ${var.warehouse_id}
|
||||||
- run
|
|
||||||
- input_layer
|
|
||||||
- --target
|
|
||||||
- databricks
|
|
||||||
- --save
|
|
||||||
- task_key: main
|
- task_key: main
|
||||||
depends_on:
|
depends_on:
|
||||||
- task_key: readmissions
|
- task_key: readmissions
|
||||||
@@ -159,67 +117,42 @@ resources:
|
|||||||
- task_key: ahrq_measures
|
- task_key: ahrq_measures
|
||||||
- task_key: data_quality
|
- task_key: data_quality
|
||||||
job_cluster_key: default_cluster
|
job_cluster_key: default_cluster
|
||||||
python_wheel_task:
|
sql_task:
|
||||||
package_name: stack
|
file:
|
||||||
entry_point: cli
|
path: bundle/sql/main/
|
||||||
parameters:
|
warehouse_id: ${var.warehouse_id}
|
||||||
- run
|
|
||||||
- main
|
|
||||||
- --target
|
|
||||||
- databricks
|
|
||||||
- --save
|
|
||||||
- task_key: pharmacy
|
- task_key: pharmacy
|
||||||
depends_on:
|
depends_on:
|
||||||
- task_key: core
|
- task_key: core
|
||||||
job_cluster_key: default_cluster
|
job_cluster_key: default_cluster
|
||||||
python_wheel_task:
|
sql_task:
|
||||||
package_name: stack
|
file:
|
||||||
entry_point: cli
|
path: bundle/sql/pharmacy/
|
||||||
parameters:
|
warehouse_id: ${var.warehouse_id}
|
||||||
- run
|
|
||||||
- pharmacy
|
|
||||||
- --target
|
|
||||||
- databricks
|
|
||||||
- --save
|
|
||||||
- task_key: provider_attribution
|
- task_key: provider_attribution
|
||||||
depends_on:
|
depends_on:
|
||||||
- task_key: core
|
- task_key: core
|
||||||
job_cluster_key: default_cluster
|
job_cluster_key: default_cluster
|
||||||
python_wheel_task:
|
sql_task:
|
||||||
package_name: stack
|
file:
|
||||||
entry_point: cli
|
path: bundle/sql/provider_attribution/
|
||||||
parameters:
|
warehouse_id: ${var.warehouse_id}
|
||||||
- run
|
|
||||||
- provider_attribution
|
|
||||||
- --target
|
|
||||||
- databricks
|
|
||||||
- --save
|
|
||||||
- task_key: quality_measures
|
- task_key: quality_measures
|
||||||
depends_on:
|
depends_on:
|
||||||
- task_key: core
|
- task_key: core
|
||||||
job_cluster_key: default_cluster
|
job_cluster_key: default_cluster
|
||||||
python_wheel_task:
|
sql_task:
|
||||||
package_name: stack
|
file:
|
||||||
entry_point: cli
|
path: bundle/sql/quality_measures/
|
||||||
parameters:
|
warehouse_id: ${var.warehouse_id}
|
||||||
- run
|
|
||||||
- quality_measures
|
|
||||||
- --target
|
|
||||||
- databricks
|
|
||||||
- --save
|
|
||||||
- task_key: readmissions
|
- task_key: readmissions
|
||||||
depends_on:
|
depends_on:
|
||||||
- task_key: core
|
- task_key: core
|
||||||
job_cluster_key: default_cluster
|
job_cluster_key: default_cluster
|
||||||
python_wheel_task:
|
sql_task:
|
||||||
package_name: stack
|
file:
|
||||||
entry_point: cli
|
path: bundle/sql/readmissions/
|
||||||
parameters:
|
warehouse_id: ${var.warehouse_id}
|
||||||
- run
|
|
||||||
- readmissions
|
|
||||||
- --target
|
|
||||||
- databricks
|
|
||||||
- --save
|
|
||||||
schemas:
|
schemas:
|
||||||
ahrq_measures:
|
ahrq_measures:
|
||||||
name: ahrq_measures
|
name: ahrq_measures
|
||||||
|
|||||||
@@ -1,20 +1,25 @@
|
|||||||
"""Databricks Asset Bundle (DAB) emitter.
|
"""Databricks Asset Bundle (DAB) emitter.
|
||||||
|
|
||||||
Generates databricks.yml from the pipeline registry, table catalog,
|
Generates databricks.yml + transpiled SQL files from the pipeline
|
||||||
and stack.toml [databricks] config — no hardcoded pipeline names,
|
registry, table catalog, and stack.toml [databricks] config.
|
||||||
task lists, or dependencies.
|
|
||||||
|
Each pipeline expression is transpiled to Databricks SQL via
|
||||||
|
``aco.lake.transpile`` and written to ``bundle/sql/<pipeline>/<expr>.sql``.
|
||||||
|
Table DDL is generated from ``aco.table`` Pydantic models.
|
||||||
|
The bundle YAML references these SQL files as ``sql_task`` definitions —
|
||||||
|
Databricks executes native SQL, not a Python wheel wrapper.
|
||||||
|
|
||||||
Follows the same emit() → dict[str, str] pattern as gitea.py / github.py.
|
Follows the same emit() → dict[str, str] pattern as gitea.py / github.py.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import yaml
|
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
# ── YAML helpers ──────────────────────────────────────────────────
|
# ── YAML helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
# Use OrderedDict + custom representer to control key order in output
|
|
||||||
yaml.add_representer(
|
yaml.add_representer(
|
||||||
OrderedDict,
|
OrderedDict,
|
||||||
lambda dumper, data: dumper.represent_mapping(
|
lambda dumper, data: dumper.represent_mapping(
|
||||||
@@ -24,13 +29,9 @@ yaml.add_representer(
|
|||||||
|
|
||||||
|
|
||||||
def _dump(data: dict) -> str:
|
def _dump(data: dict) -> str:
|
||||||
"""Dump to YAML with clean formatting."""
|
|
||||||
return yaml.dump(
|
return yaml.dump(
|
||||||
data,
|
data, default_flow_style=False, sort_keys=False,
|
||||||
default_flow_style=False,
|
width=120, allow_unicode=True,
|
||||||
sort_keys=False,
|
|
||||||
width=120,
|
|
||||||
allow_unicode=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -38,131 +39,177 @@ def _dump(data: dict) -> str:
|
|||||||
|
|
||||||
|
|
||||||
class DabPlugin:
|
class DabPlugin:
|
||||||
"""Protocol for DAB resource plugins.
|
"""Protocol for DAB resource plugins."""
|
||||||
|
|
||||||
Subclass and implement resources() to contribute resource blocks.
|
def resources(self, cfg: dict, registry: dict, catalog_schemas: list[str]) -> dict:
|
||||||
"""
|
return {}
|
||||||
|
|
||||||
def resources(
|
def files(self, cfg: dict, registry: dict) -> dict[str, str]:
|
||||||
self, cfg: dict, registry: dict, catalog_schemas: list[str]
|
"""Return {relative_path: content} for extra files."""
|
||||||
) -> dict:
|
|
||||||
"""Return a dict of resource type -> resource definitions."""
|
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
# ── Built-in plugins ─────────────────────────────────────────────
|
# ── Built-in plugins ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class SQLPlugin(DabPlugin):
|
||||||
|
"""Transpile every pipeline expression to Databricks SQL files."""
|
||||||
|
|
||||||
|
def files(self, cfg: dict, registry: dict) -> dict[str, str]:
|
||||||
|
from aco.lake.transpile import transpile
|
||||||
|
|
||||||
|
result: dict[str, str] = {}
|
||||||
|
|
||||||
|
for pipe_name, pipe in sorted(registry.items()):
|
||||||
|
try:
|
||||||
|
sql_map = transpile(
|
||||||
|
pipe,
|
||||||
|
target_dialect="databricks",
|
||||||
|
catalog="${var.catalog}",
|
||||||
|
output_mode="ctas",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
result[f"bundle/sql/{pipe_name}/_error.txt"] = (
|
||||||
|
f"Transpile failed: {e}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
for expr_name, sql in sql_map.items():
|
||||||
|
# expr_name is like "core._stg_claims_member_months"
|
||||||
|
# filename: bundle/sql/core/_stg_claims_member_months.sql
|
||||||
|
schema, table = expr_name.split(".", 1)
|
||||||
|
path = f"bundle/sql/{pipe_name}/{table}.sql"
|
||||||
|
header = (
|
||||||
|
f"-- {expr_name}\n"
|
||||||
|
f"-- Generated by gen_config.py — DO NOT EDIT\n\n"
|
||||||
|
)
|
||||||
|
result[path] = header + sql + "\n"
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class DDLPlugin(DabPlugin):
|
||||||
|
"""Generate CREATE TABLE DDL from SQLTable models for each output."""
|
||||||
|
|
||||||
|
def files(self, cfg: dict, registry: dict) -> dict[str, str]:
|
||||||
|
from aco.lake.catalog import Catalog
|
||||||
|
|
||||||
|
cat = Catalog()
|
||||||
|
result: dict[str, str] = {}
|
||||||
|
|
||||||
|
for schema_name in cat.schemas():
|
||||||
|
for table_ref in cat.tables(schema_name):
|
||||||
|
model = cat.model(table_ref)
|
||||||
|
schema, table = table_ref.split(".", 1)
|
||||||
|
|
||||||
|
# Generate DDL with catalog variable
|
||||||
|
ddl = model.to_ddl()
|
||||||
|
# Rewrite the table ref to include catalog variable
|
||||||
|
ddl = ddl.replace(
|
||||||
|
f"CREATE TABLE {table_ref}",
|
||||||
|
f"CREATE TABLE IF NOT EXISTS ${{var.catalog}}.{table_ref}",
|
||||||
|
)
|
||||||
|
path = f"bundle/ddl/{schema}/{table}.sql"
|
||||||
|
result[path] = (
|
||||||
|
f"-- {table_ref}\n"
|
||||||
|
f"-- Generated by gen_config.py — DO NOT EDIT\n\n"
|
||||||
|
f"{ddl}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
class JobsPlugin(DabPlugin):
|
class JobsPlugin(DabPlugin):
|
||||||
"""Generate jobs from the pipeline registry."""
|
"""Generate jobs with sql_task references to transpiled SQL files."""
|
||||||
|
|
||||||
def resources(self, cfg: dict, registry: dict, catalog_schemas: list) -> dict:
|
def resources(self, cfg: dict, registry: dict, catalog_schemas: list) -> dict:
|
||||||
package_name = cfg.get("package_name", "stack")
|
|
||||||
entry_point = cfg.get("entry_point", "cli")
|
|
||||||
|
|
||||||
# Collect which cluster profiles are actually used
|
# Collect which cluster profiles are actually used
|
||||||
used_profiles: set[str] = set()
|
used_profiles: set[str] = set()
|
||||||
for pipe in registry.values():
|
for pipe in registry.values():
|
||||||
used_profiles.add(getattr(pipe, "cluster_profile", "default"))
|
used_profiles.add(getattr(pipe, "cluster_profile", "default"))
|
||||||
|
|
||||||
# Build job_clusters from config — only those referenced
|
# Build job_clusters
|
||||||
clusters_cfg = cfg.get("clusters", {"default": {}})
|
clusters_cfg = cfg.get("clusters", {"default": {}})
|
||||||
job_clusters = []
|
job_clusters = []
|
||||||
for profile_name in sorted(used_profiles):
|
for profile_name in sorted(used_profiles):
|
||||||
cluster_spec = dict(clusters_cfg.get(profile_name, {}))
|
cluster_spec = dict(clusters_cfg.get(profile_name, {}))
|
||||||
spark_conf = OrderedDict()
|
job_clusters.append(OrderedDict([
|
||||||
spark_conf[
|
("job_cluster_key", f"{profile_name}_cluster"),
|
||||||
"spark.sql.catalog.${var.catalog}"
|
("new_cluster", OrderedDict([
|
||||||
] = "org.apache.iceberg.spark.SparkCatalog"
|
("spark_version", cluster_spec.get("spark_version", "15.4.x-scala2.12")),
|
||||||
cluster_spec["spark_conf"] = spark_conf
|
("node_type_id", cluster_spec.get("node_type_id", "i3.xlarge")),
|
||||||
job_clusters.append(
|
("num_workers", cluster_spec.get("num_workers", 1)),
|
||||||
OrderedDict(
|
])),
|
||||||
[
|
]))
|
||||||
("job_cluster_key", f"{profile_name}_cluster"),
|
|
||||||
(
|
|
||||||
"new_cluster",
|
|
||||||
OrderedDict(
|
|
||||||
[
|
|
||||||
(
|
|
||||||
"spark_version",
|
|
||||||
cluster_spec.get(
|
|
||||||
"spark_version", "15.4.x-scala2.12"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"node_type_id",
|
|
||||||
cluster_spec.get("node_type_id", "i3.xlarge"),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"num_workers",
|
|
||||||
cluster_spec.get("num_workers", 1),
|
|
||||||
),
|
|
||||||
("spark_conf", spark_conf),
|
|
||||||
]
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Build tasks from registry
|
# DDL tasks — one per schema to create tables before SQL runs
|
||||||
tasks = []
|
ddl_tasks = []
|
||||||
for name, pipe in sorted(registry.items()):
|
for schema_name in sorted(catalog_schemas):
|
||||||
|
ddl_tasks.append(OrderedDict([
|
||||||
|
("task_key", f"ddl_{schema_name}"),
|
||||||
|
("job_cluster_key", "default_cluster"),
|
||||||
|
("sql_task", OrderedDict([
|
||||||
|
("file", OrderedDict([
|
||||||
|
("path", f"bundle/ddl/{schema_name}/"),
|
||||||
|
])),
|
||||||
|
])),
|
||||||
|
]))
|
||||||
|
|
||||||
|
# Pipeline tasks — each expression becomes a sql_task
|
||||||
|
pipeline_tasks = []
|
||||||
|
for pipe_name, pipe in sorted(registry.items()):
|
||||||
profile = getattr(pipe, "cluster_profile", "default")
|
profile = getattr(pipe, "cluster_profile", "default")
|
||||||
task = OrderedDict()
|
|
||||||
task["task_key"] = name
|
|
||||||
if pipe.upstream:
|
|
||||||
task["depends_on"] = [
|
|
||||||
OrderedDict([("task_key", dep)]) for dep in pipe.upstream
|
|
||||||
]
|
|
||||||
task["job_cluster_key"] = f"{profile}_cluster"
|
|
||||||
task["python_wheel_task"] = OrderedDict(
|
|
||||||
[
|
|
||||||
("package_name", package_name),
|
|
||||||
("entry_point", entry_point),
|
|
||||||
(
|
|
||||||
"parameters",
|
|
||||||
["run", name, "--target", "databricks", "--save"],
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
tasks.append(task)
|
|
||||||
|
|
||||||
# Build the job
|
# Build depends_on from upstream pipeline names
|
||||||
|
depends_on = []
|
||||||
|
for dep in pipe.upstream:
|
||||||
|
depends_on.append(OrderedDict([("task_key", dep)]))
|
||||||
|
|
||||||
|
task = OrderedDict()
|
||||||
|
task["task_key"] = pipe_name
|
||||||
|
if depends_on:
|
||||||
|
task["depends_on"] = depends_on
|
||||||
|
task["job_cluster_key"] = f"{profile}_cluster"
|
||||||
|
|
||||||
|
# Each pipeline task runs all its SQL files in order
|
||||||
|
# We use a for_each or sequential notebook; simplest is
|
||||||
|
# a single SQL file per pipeline that sources all expressions
|
||||||
|
task["sql_task"] = OrderedDict([
|
||||||
|
("file", OrderedDict([
|
||||||
|
("path", f"bundle/sql/{pipe_name}/"),
|
||||||
|
])),
|
||||||
|
("warehouse_id", "${var.warehouse_id}"),
|
||||||
|
])
|
||||||
|
pipeline_tasks.append(task)
|
||||||
|
|
||||||
schedule = cfg.get("schedule", "0 0 6 * * ?")
|
schedule = cfg.get("schedule", "0 0 6 * * ?")
|
||||||
timezone = cfg.get("timezone", "America/New_York")
|
timezone = cfg.get("timezone", "America/New_York")
|
||||||
|
|
||||||
job = OrderedDict()
|
job = OrderedDict()
|
||||||
job["name"] = f"{cfg.get('bundle_name', 'stack')}-pipelines"
|
job["name"] = f"{cfg.get('bundle_name', 'stack')}-pipelines"
|
||||||
job["description"] = (
|
job["description"] = f"Run all {len(registry)} ACO pipelines in dependency order"
|
||||||
f"Run all {len(registry)} ACO pipelines in dependency order"
|
job["schedule"] = OrderedDict([
|
||||||
)
|
("quartz_cron_expression", schedule),
|
||||||
job["schedule"] = OrderedDict(
|
("timezone_id", timezone),
|
||||||
[
|
])
|
||||||
("quartz_cron_expression", schedule),
|
|
||||||
("timezone_id", timezone),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
job["job_clusters"] = job_clusters
|
job["job_clusters"] = job_clusters
|
||||||
job["tasks"] = tasks
|
job["tasks"] = pipeline_tasks
|
||||||
|
|
||||||
return {"jobs": OrderedDict([("stack_pipelines", job)])}
|
return {"jobs": OrderedDict([("stack_pipelines", job)])}
|
||||||
|
|
||||||
|
|
||||||
class SchemasPlugin(DabPlugin):
|
class SchemasPlugin(DabPlugin):
|
||||||
"""Generate Unity Catalog schema resources from the table catalog."""
|
"""Generate Unity Catalog schema resources."""
|
||||||
|
|
||||||
def resources(self, cfg: dict, registry: dict, catalog_schemas: list) -> dict:
|
def resources(self, cfg: dict, registry: dict, catalog_schemas: list) -> dict:
|
||||||
if not catalog_schemas:
|
if not catalog_schemas:
|
||||||
return {}
|
return {}
|
||||||
schemas = OrderedDict()
|
schemas = OrderedDict()
|
||||||
for schema_name in sorted(catalog_schemas):
|
for schema_name in sorted(catalog_schemas):
|
||||||
schemas[schema_name] = OrderedDict(
|
schemas[schema_name] = OrderedDict([
|
||||||
[
|
("name", schema_name),
|
||||||
("name", schema_name),
|
("catalog_name", "${var.catalog}"),
|
||||||
("catalog_name", "${var.catalog}"),
|
])
|
||||||
]
|
|
||||||
)
|
|
||||||
return {"schemas": schemas}
|
return {"schemas": schemas}
|
||||||
|
|
||||||
|
|
||||||
@@ -170,23 +217,14 @@ class VolumesPlugin(DabPlugin):
|
|||||||
"""Generate a staging volume for data upload."""
|
"""Generate a staging volume for data upload."""
|
||||||
|
|
||||||
def resources(self, cfg: dict, registry: dict, catalog_schemas: list) -> dict:
|
def resources(self, cfg: dict, registry: dict, catalog_schemas: list) -> dict:
|
||||||
return {
|
return {"volumes": OrderedDict([
|
||||||
"volumes": OrderedDict(
|
("staging", OrderedDict([
|
||||||
[
|
("name", "staging"),
|
||||||
(
|
("catalog_name", "${var.catalog}"),
|
||||||
"staging",
|
("schema_name", "default"),
|
||||||
OrderedDict(
|
("volume_type", "MANAGED"),
|
||||||
[
|
])),
|
||||||
("name", "staging"),
|
])}
|
||||||
("catalog_name", "${var.catalog}"),
|
|
||||||
("schema_name", "default"),
|
|
||||||
("volume_type", "MANAGED"),
|
|
||||||
]
|
|
||||||
),
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ── Core generator ───────────────────────────────────────────────
|
# ── Core generator ───────────────────────────────────────────────
|
||||||
@@ -196,8 +234,9 @@ _HEADER = (
|
|||||||
"# Re-generate: uv run python dev/scripts/gen_config.py\n"
|
"# Re-generate: uv run python dev/scripts/gen_config.py\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Default plugins
|
|
||||||
_DEFAULT_PLUGINS: list[DabPlugin] = [
|
_DEFAULT_PLUGINS: list[DabPlugin] = [
|
||||||
|
SQLPlugin(),
|
||||||
|
DDLPlugin(),
|
||||||
JobsPlugin(),
|
JobsPlugin(),
|
||||||
SchemasPlugin(),
|
SchemasPlugin(),
|
||||||
VolumesPlugin(),
|
VolumesPlugin(),
|
||||||
@@ -205,87 +244,63 @@ _DEFAULT_PLUGINS: list[DabPlugin] = [
|
|||||||
|
|
||||||
|
|
||||||
def _get_catalog_schemas() -> list[str]:
|
def _get_catalog_schemas() -> list[str]:
|
||||||
"""Discover schemas from the table catalog."""
|
|
||||||
try:
|
try:
|
||||||
from aco.lake.catalog import Catalog
|
from aco.lake.catalog import Catalog
|
||||||
|
return Catalog().schemas()
|
||||||
cat = Catalog()
|
|
||||||
return cat.schemas()
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def emit(cfg_data: dict) -> dict[str, str]:
|
def emit(cfg_data: dict) -> dict[str, str]:
|
||||||
"""Generate databricks.yml from config + pipeline registry.
|
"""Generate databricks.yml + SQL files from config + pipeline registry.
|
||||||
|
|
||||||
Returns {relative_path: content}.
|
Returns {relative_path: content} for all generated files.
|
||||||
"""
|
"""
|
||||||
from aco.pipe import registry
|
from aco.pipe import registry
|
||||||
|
|
||||||
dab_cfg = cfg_data.get("databricks", {})
|
dab_cfg = cfg_data.get("databricks", {})
|
||||||
bundle_name = dab_cfg.get("bundle_name", "stack")
|
bundle_name = dab_cfg.get("bundle_name", "stack")
|
||||||
targets_cfg = dab_cfg.get("targets", {})
|
targets_cfg = dab_cfg.get("targets", {})
|
||||||
|
|
||||||
catalog_schemas = _get_catalog_schemas()
|
catalog_schemas = _get_catalog_schemas()
|
||||||
|
|
||||||
# Merge resources from all plugins
|
# Collect all files from plugins
|
||||||
|
all_files: dict[str, str] = {}
|
||||||
all_resources: dict = {}
|
all_resources: dict = {}
|
||||||
|
|
||||||
for plugin in _DEFAULT_PLUGINS:
|
for plugin in _DEFAULT_PLUGINS:
|
||||||
|
# Resource contributions
|
||||||
plugin_resources = plugin.resources(dab_cfg, registry, catalog_schemas)
|
plugin_resources = plugin.resources(dab_cfg, registry, catalog_schemas)
|
||||||
for rtype, rdefs in plugin_resources.items():
|
for rtype, rdefs in plugin_resources.items():
|
||||||
if rtype not in all_resources:
|
if rtype not in all_resources:
|
||||||
all_resources[rtype] = OrderedDict()
|
all_resources[rtype] = OrderedDict()
|
||||||
all_resources[rtype].update(rdefs)
|
all_resources[rtype].update(rdefs)
|
||||||
|
|
||||||
# Build bundle structure
|
# File contributions (SQL, DDL)
|
||||||
bundle = OrderedDict()
|
plugin_files = plugin.files(dab_cfg, registry)
|
||||||
|
all_files.update(plugin_files)
|
||||||
|
|
||||||
# bundle:
|
# Build bundle YAML
|
||||||
|
bundle = OrderedDict()
|
||||||
bundle["bundle"] = OrderedDict([("name", bundle_name)])
|
bundle["bundle"] = OrderedDict([("name", bundle_name)])
|
||||||
|
|
||||||
# artifacts:
|
# Sync SQL files to workspace
|
||||||
bundle["artifacts"] = OrderedDict(
|
bundle["sync"] = OrderedDict([
|
||||||
[
|
("include", ["bundle/**"]),
|
||||||
(
|
])
|
||||||
"default",
|
|
||||||
OrderedDict(
|
|
||||||
[
|
|
||||||
("type", "whl"),
|
|
||||||
("build", "uv build"),
|
|
||||||
("path", "."),
|
|
||||||
]
|
|
||||||
),
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
# variables:
|
bundle["variables"] = OrderedDict([
|
||||||
bundle["variables"] = OrderedDict(
|
("catalog", OrderedDict([
|
||||||
[
|
("description", "Unity Catalog name for table references"),
|
||||||
(
|
("default", targets_cfg.get("prod", {}).get("catalog", "aco")),
|
||||||
"catalog",
|
])),
|
||||||
OrderedDict(
|
("warehouse_id", OrderedDict([
|
||||||
[
|
("description", "SQL warehouse ID for query execution"),
|
||||||
("description", "Unity Catalog name for table references"),
|
])),
|
||||||
(
|
])
|
||||||
"default",
|
|
||||||
dab_cfg.get(
|
|
||||||
"default_catalog",
|
|
||||||
targets_cfg.get("prod", {}).get("catalog", "aco"),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
),
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
# workspace:
|
bundle["workspace"] = OrderedDict([("host", "${DATABRICKS_HOST}")])
|
||||||
bundle["workspace"] = OrderedDict(
|
|
||||||
[("host", "${DATABRICKS_HOST}")]
|
|
||||||
)
|
|
||||||
|
|
||||||
# targets:
|
# Targets
|
||||||
targets = OrderedDict()
|
targets = OrderedDict()
|
||||||
for tname, tcfg in sorted(targets_cfg.items()):
|
for tname, tcfg in sorted(targets_cfg.items()):
|
||||||
target = OrderedDict()
|
target = OrderedDict()
|
||||||
@@ -293,31 +308,27 @@ def emit(cfg_data: dict) -> dict[str, str]:
|
|||||||
target["mode"] = tcfg["mode"]
|
target["mode"] = tcfg["mode"]
|
||||||
if tcfg.get("default"):
|
if tcfg.get("default"):
|
||||||
target["default"] = True
|
target["default"] = True
|
||||||
target["variables"] = OrderedDict(
|
target["variables"] = OrderedDict([
|
||||||
[("catalog", tcfg.get("catalog", "aco"))]
|
("catalog", tcfg.get("catalog", "aco")),
|
||||||
)
|
])
|
||||||
# Production targets get run_as
|
if tcfg.get("warehouse_id"):
|
||||||
|
target["variables"]["warehouse_id"] = tcfg["warehouse_id"]
|
||||||
if tcfg.get("mode") == "production":
|
if tcfg.get("mode") == "production":
|
||||||
run_as_cfg = dab_cfg.get("run_as", {})
|
run_as = dab_cfg.get("run_as", {})
|
||||||
if run_as_cfg.get("service_principal_name"):
|
if run_as.get("service_principal_name"):
|
||||||
target["run_as"] = OrderedDict(
|
target["run_as"] = OrderedDict([
|
||||||
[
|
("service_principal_name", run_as["service_principal_name"]),
|
||||||
(
|
])
|
||||||
"service_principal_name",
|
|
||||||
run_as_cfg["service_principal_name"],
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
targets[tname] = target
|
targets[tname] = target
|
||||||
bundle["targets"] = targets
|
bundle["targets"] = targets
|
||||||
|
|
||||||
# permissions:
|
# Permissions
|
||||||
perms_cfg = dab_cfg.get("permissions", [])
|
perms = dab_cfg.get("permissions", [])
|
||||||
if perms_cfg:
|
if perms:
|
||||||
bundle["permissions"] = perms_cfg
|
bundle["permissions"] = perms
|
||||||
|
|
||||||
# resources:
|
# Resources
|
||||||
bundle["resources"] = all_resources
|
bundle["resources"] = all_resources
|
||||||
|
|
||||||
content = _HEADER + "\n" + _dump(dict(bundle))
|
all_files["databricks.yml"] = _HEADER + "\n" + _dump(dict(bundle))
|
||||||
return {"databricks.yml": content}
|
return all_files
|
||||||
|
|||||||
@@ -129,23 +129,32 @@ def _clean_stale(backend: str, generated_files: dict[str, str]) -> list[str]:
|
|||||||
# ── Main ─────────────────────────────────────────────────────────
|
# ── Main ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def _emit_dab() -> dict[str, str]:
|
def _emit_dab(*, sql: bool = False) -> dict[str, str]:
|
||||||
"""Generate databricks.yml from pipeline registry + stack.toml."""
|
"""Generate databricks.yml (and optionally SQL/DDL) from pipeline registry.
|
||||||
|
|
||||||
|
When *sql* is False (default), only databricks.yml is emitted — fast
|
||||||
|
enough for --check in CI. When True, also transpiles all pipeline
|
||||||
|
expressions to SQL and generates DDL (1500+ files, ~4 minutes).
|
||||||
|
"""
|
||||||
if "databricks" not in cfg._data:
|
if "databricks" not in cfg._data:
|
||||||
return {}
|
return {}
|
||||||
from backends.databricks import emit
|
from backends.databricks import emit
|
||||||
|
|
||||||
return emit(cfg._data)
|
all_files = emit(cfg._data)
|
||||||
|
if not sql:
|
||||||
|
# Return only the YAML — skip bundle/sql/ and bundle/ddl/
|
||||||
|
return {k: v for k, v in all_files.items() if not k.startswith("bundle/")}
|
||||||
|
return all_files
|
||||||
|
|
||||||
|
|
||||||
def generate(backend: str | None = None) -> dict[str, str]:
|
def generate(backend: str | None = None, *, dab_sql: bool = False) -> dict[str, str]:
|
||||||
if backend is None:
|
if backend is None:
|
||||||
backend = cfg._data.get("ci", {}).get("backend", "woodpecker")
|
backend = cfg._data.get("ci", {}).get("backend", "woodpecker")
|
||||||
|
|
||||||
files: dict[str, str] = {}
|
files: dict[str, str] = {}
|
||||||
files.update(gen_coredns())
|
files.update(gen_coredns())
|
||||||
files.update(_emit_backend(backend))
|
files.update(_emit_backend(backend))
|
||||||
files.update(_emit_dab())
|
files.update(_emit_dab(sql=dab_sql))
|
||||||
return files
|
return files
|
||||||
|
|
||||||
|
|
||||||
@@ -180,9 +189,14 @@ def main() -> int:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--backend", default=None, help="Override CI backend (woodpecker|github)"
|
"--backend", default=None, help="Override CI backend (woodpecker|github)"
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dab-sql",
|
||||||
|
action="store_true",
|
||||||
|
help="Also generate bundle/sql/ and bundle/ddl/ (slow, ~4 min)",
|
||||||
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
files = generate(args.backend)
|
files = generate(args.backend, dab_sql=args.dab_sql)
|
||||||
|
|
||||||
if args.check:
|
if args.check:
|
||||||
diffs = check_all(files)
|
diffs = check_all(files)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Tests for the Databricks Asset Bundle generator.
|
"""Tests for the Databricks Asset Bundle generator.
|
||||||
|
|
||||||
Covers: round-trip, completeness, dependency ordering, idempotency,
|
Covers: round-trip, completeness, dependency ordering, idempotency,
|
||||||
target isolation, plugin extensibility, and pipeline metadata.
|
target isolation, plugin extensibility, SQL transpilation, and DDL.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -69,35 +69,35 @@ class TestPipelineMetadata:
|
|||||||
|
|
||||||
|
|
||||||
class TestDabEmitter:
|
class TestDabEmitter:
|
||||||
"""#172 — Core generator produces valid databricks.yml."""
|
"""#172 — Core generator produces valid databricks.yml + SQL files."""
|
||||||
|
|
||||||
def _generate(self) -> str:
|
def _emit(self) -> dict[str, str]:
|
||||||
from backends.databricks import emit
|
from backends.databricks import emit
|
||||||
|
|
||||||
from conf import cfg
|
from conf import cfg
|
||||||
|
|
||||||
files = emit(cfg._data)
|
return emit(cfg._data)
|
||||||
return files["databricks.yml"]
|
|
||||||
|
def _yaml(self) -> dict:
|
||||||
|
files = self._emit()
|
||||||
|
return yaml.safe_load(files["databricks.yml"])
|
||||||
|
|
||||||
def test_generates_valid_yaml(self):
|
def test_generates_valid_yaml(self):
|
||||||
content = self._generate()
|
data = self._yaml()
|
||||||
data = yaml.safe_load(content)
|
|
||||||
assert isinstance(data, dict)
|
assert isinstance(data, dict)
|
||||||
|
|
||||||
def test_has_required_top_level_keys(self):
|
def test_has_required_top_level_keys(self):
|
||||||
data = yaml.safe_load(self._generate())
|
data = self._yaml()
|
||||||
for key in ("bundle", "variables", "workspace", "targets", "resources"):
|
for key in ("bundle", "variables", "workspace", "targets", "resources"):
|
||||||
assert key in data, f"Missing top-level key: {key}"
|
assert key in data, f"Missing top-level key: {key}"
|
||||||
|
|
||||||
def test_bundle_name(self):
|
def test_bundle_name(self):
|
||||||
data = yaml.safe_load(self._generate())
|
assert self._yaml()["bundle"]["name"] == "stack"
|
||||||
assert data["bundle"]["name"] == "stack"
|
|
||||||
|
|
||||||
def test_all_registry_pipelines_appear_as_tasks(self):
|
def test_all_registry_pipelines_appear_as_tasks(self):
|
||||||
from aco.pipe import registry
|
from aco.pipe import registry
|
||||||
|
|
||||||
data = yaml.safe_load(self._generate())
|
tasks = self._yaml()["resources"]["jobs"]["stack_pipelines"]["tasks"]
|
||||||
tasks = data["resources"]["jobs"]["stack_pipelines"]["tasks"]
|
|
||||||
task_keys = {t["task_key"] for t in tasks}
|
task_keys = {t["task_key"] for t in tasks}
|
||||||
for name in registry:
|
for name in registry:
|
||||||
assert name in task_keys, f"Pipeline '{name}' missing from tasks"
|
assert name in task_keys, f"Pipeline '{name}' missing from tasks"
|
||||||
@@ -105,44 +105,93 @@ class TestDabEmitter:
|
|||||||
def test_task_count_matches_registry(self):
|
def test_task_count_matches_registry(self):
|
||||||
from aco.pipe import registry
|
from aco.pipe import registry
|
||||||
|
|
||||||
data = yaml.safe_load(self._generate())
|
tasks = self._yaml()["resources"]["jobs"]["stack_pipelines"]["tasks"]
|
||||||
tasks = data["resources"]["jobs"]["stack_pipelines"]["tasks"]
|
|
||||||
assert len(tasks) == len(registry)
|
assert len(tasks) == len(registry)
|
||||||
|
|
||||||
def test_dependencies_match_upstream(self):
|
def test_dependencies_match_upstream(self):
|
||||||
from aco.pipe import registry
|
from aco.pipe import registry
|
||||||
|
|
||||||
data = yaml.safe_load(self._generate())
|
tasks = self._yaml()["resources"]["jobs"]["stack_pipelines"]["tasks"]
|
||||||
tasks = data["resources"]["jobs"]["stack_pipelines"]["tasks"]
|
|
||||||
task_map = {t["task_key"]: t for t in tasks}
|
task_map = {t["task_key"]: t for t in tasks}
|
||||||
|
|
||||||
for name, pipe in registry.items():
|
for name, pipe in registry.items():
|
||||||
task = task_map[name]
|
task = task_map[name]
|
||||||
if pipe.upstream:
|
if pipe.upstream:
|
||||||
deps = [d["task_key"] for d in task.get("depends_on", [])]
|
deps = [d["task_key"] for d in task.get("depends_on", [])]
|
||||||
assert sorted(deps) == sorted(pipe.upstream), (
|
assert sorted(deps) == sorted(pipe.upstream)
|
||||||
f"{name}: depends_on {deps} != upstream {pipe.upstream}"
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
assert "depends_on" not in task, (
|
assert "depends_on" not in task
|
||||||
f"{name} has no upstream but has depends_on"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_task_parameters_include_pipeline_name(self):
|
def test_tasks_use_sql_task(self):
|
||||||
data = yaml.safe_load(self._generate())
|
tasks = self._yaml()["resources"]["jobs"]["stack_pipelines"]["tasks"]
|
||||||
tasks = data["resources"]["jobs"]["stack_pipelines"]["tasks"]
|
|
||||||
for task in tasks:
|
for task in tasks:
|
||||||
params = task["python_wheel_task"]["parameters"]
|
assert "sql_task" in task, f"Task {task['task_key']} missing sql_task"
|
||||||
assert params[0] == "run"
|
assert "file" in task["sql_task"]
|
||||||
assert params[1] == task["task_key"]
|
|
||||||
assert "--target" in params
|
|
||||||
assert "--save" in params
|
class TestSQLFiles:
|
||||||
|
"""SQL transpilation produces actual Databricks SQL."""
|
||||||
|
|
||||||
|
def _emit(self) -> dict[str, str]:
|
||||||
|
from backends.databricks import emit
|
||||||
|
|
||||||
|
from conf import cfg
|
||||||
|
|
||||||
|
return emit(cfg._data)
|
||||||
|
|
||||||
|
def test_sql_files_generated(self):
|
||||||
|
files = self._emit()
|
||||||
|
sql_files = [
|
||||||
|
k for k in files if k.startswith("bundle/sql/") and k.endswith(".sql")
|
||||||
|
]
|
||||||
|
assert len(sql_files) > 100, f"Only {len(sql_files)} SQL files generated"
|
||||||
|
|
||||||
|
def test_sql_files_contain_sql(self):
|
||||||
|
files = self._emit()
|
||||||
|
sql_files = [
|
||||||
|
k for k in files if k.startswith("bundle/sql/") and k.endswith(".sql")
|
||||||
|
]
|
||||||
|
for path in sql_files[:10]:
|
||||||
|
content = files[path]
|
||||||
|
assert "SELECT" in content.upper() or "CREATE" in content.upper(), (
|
||||||
|
f"{path} doesn't contain SQL"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_sql_references_catalog_variable(self):
|
||||||
|
files = self._emit()
|
||||||
|
sql_files = [
|
||||||
|
k for k in files if k.startswith("bundle/sql/") and k.endswith(".sql")
|
||||||
|
]
|
||||||
|
# At least some SQL files should reference the catalog variable
|
||||||
|
has_catalog_ref = any("${var.catalog}" in files[p] for p in sql_files)
|
||||||
|
assert has_catalog_ref, "No SQL files reference ${var.catalog}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestDDLFiles:
|
||||||
|
"""DDL generation from SQLTable models."""
|
||||||
|
|
||||||
|
def _emit(self) -> dict[str, str]:
|
||||||
|
from backends.databricks import emit
|
||||||
|
|
||||||
|
from conf import cfg
|
||||||
|
|
||||||
|
return emit(cfg._data)
|
||||||
|
|
||||||
|
def test_ddl_files_generated(self):
|
||||||
|
files = self._emit()
|
||||||
|
ddl_files = [k for k in files if k.startswith("bundle/ddl/")]
|
||||||
|
assert len(ddl_files) > 100, f"Only {len(ddl_files)} DDL files generated"
|
||||||
|
|
||||||
|
def test_ddl_contains_create_table(self):
|
||||||
|
files = self._emit()
|
||||||
|
ddl_files = [k for k in files if k.startswith("bundle/ddl/")]
|
||||||
|
for path in ddl_files[:10]:
|
||||||
|
assert "CREATE TABLE" in files[path], f"{path} missing CREATE TABLE"
|
||||||
|
|
||||||
|
|
||||||
class TestTargets:
|
class TestTargets:
|
||||||
"""#171 — Target isolation."""
|
"""Target isolation."""
|
||||||
|
|
||||||
def _data(self) -> dict:
|
def _yaml(self) -> dict:
|
||||||
from backends.databricks import emit
|
from backends.databricks import emit
|
||||||
|
|
||||||
from conf import cfg
|
from conf import cfg
|
||||||
@@ -150,46 +199,26 @@ class TestTargets:
|
|||||||
return yaml.safe_load(emit(cfg._data)["databricks.yml"])
|
return yaml.safe_load(emit(cfg._data)["databricks.yml"])
|
||||||
|
|
||||||
def test_three_targets(self):
|
def test_three_targets(self):
|
||||||
data = self._data()
|
assert set(self._yaml()["targets"].keys()) == {"dev", "staging", "prod"}
|
||||||
assert set(data["targets"].keys()) == {"dev", "staging", "prod"}
|
|
||||||
|
|
||||||
def test_dev_is_default(self):
|
def test_dev_is_default(self):
|
||||||
data = self._data()
|
assert self._yaml()["targets"]["dev"].get("default") is True
|
||||||
assert data["targets"]["dev"].get("default") is True
|
|
||||||
|
|
||||||
def test_dev_mode(self):
|
def test_dev_mode(self):
|
||||||
data = self._data()
|
assert self._yaml()["targets"]["dev"]["mode"] == "development"
|
||||||
assert data["targets"]["dev"]["mode"] == "development"
|
|
||||||
|
|
||||||
def test_prod_mode(self):
|
def test_prod_mode(self):
|
||||||
data = self._data()
|
assert self._yaml()["targets"]["prod"]["mode"] == "production"
|
||||||
assert data["targets"]["prod"]["mode"] == "production"
|
|
||||||
|
|
||||||
def test_each_target_has_catalog(self):
|
def test_each_target_has_catalog(self):
|
||||||
data = self._data()
|
for tname, tcfg in self._yaml()["targets"].items():
|
||||||
for tname, tcfg in data["targets"].items():
|
|
||||||
assert "catalog" in tcfg.get("variables", {}), (
|
assert "catalog" in tcfg.get("variables", {}), (
|
||||||
f"Target '{tname}' missing catalog variable"
|
f"Target '{tname}' missing catalog variable"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestArtifacts:
|
|
||||||
"""#175 — Artifact configuration."""
|
|
||||||
|
|
||||||
def test_has_artifact(self):
|
|
||||||
from backends.databricks import emit
|
|
||||||
|
|
||||||
from conf import cfg
|
|
||||||
|
|
||||||
data = yaml.safe_load(emit(cfg._data)["databricks.yml"])
|
|
||||||
assert "artifacts" in data
|
|
||||||
assert "default" in data["artifacts"]
|
|
||||||
assert data["artifacts"]["default"]["type"] == "whl"
|
|
||||||
assert data["artifacts"]["default"]["build"] == "uv build"
|
|
||||||
|
|
||||||
|
|
||||||
class TestSchemas:
|
class TestSchemas:
|
||||||
"""#173 — Unity Catalog schemas from table catalog."""
|
"""Unity Catalog schemas from table catalog."""
|
||||||
|
|
||||||
def test_schemas_generated(self):
|
def test_schemas_generated(self):
|
||||||
from backends.databricks import emit
|
from backends.databricks import emit
|
||||||
@@ -198,8 +227,7 @@ class TestSchemas:
|
|||||||
|
|
||||||
data = yaml.safe_load(emit(cfg._data)["databricks.yml"])
|
data = yaml.safe_load(emit(cfg._data)["databricks.yml"])
|
||||||
schemas = data["resources"].get("schemas", {})
|
schemas = data["resources"].get("schemas", {})
|
||||||
assert len(schemas) > 0, "No schemas generated"
|
assert len(schemas) > 0
|
||||||
# Core schema should always exist
|
|
||||||
assert "core" in schemas
|
assert "core" in schemas
|
||||||
|
|
||||||
def test_schema_references_catalog_variable(self):
|
def test_schema_references_catalog_variable(self):
|
||||||
@@ -209,40 +237,25 @@ class TestSchemas:
|
|||||||
|
|
||||||
data = yaml.safe_load(emit(cfg._data)["databricks.yml"])
|
data = yaml.safe_load(emit(cfg._data)["databricks.yml"])
|
||||||
for name, schema in data["resources"]["schemas"].items():
|
for name, schema in data["resources"]["schemas"].items():
|
||||||
assert schema["catalog_name"] == "${var.catalog}", (
|
assert schema["catalog_name"] == "${var.catalog}"
|
||||||
f"Schema '{name}' doesn't reference catalog variable"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestVolumes:
|
|
||||||
"""#173 — Staging volume."""
|
|
||||||
|
|
||||||
def test_staging_volume(self):
|
|
||||||
from backends.databricks import emit
|
|
||||||
|
|
||||||
from conf import cfg
|
|
||||||
|
|
||||||
data = yaml.safe_load(emit(cfg._data)["databricks.yml"])
|
|
||||||
volumes = data["resources"].get("volumes", {})
|
|
||||||
assert "staging" in volumes
|
|
||||||
assert volumes["staging"]["volume_type"] == "MANAGED"
|
|
||||||
|
|
||||||
|
|
||||||
class TestIdempotency:
|
class TestIdempotency:
|
||||||
"""#179 — Running the generator twice produces identical output."""
|
"""Running the generator twice produces identical output."""
|
||||||
|
|
||||||
def test_idempotent(self):
|
def test_idempotent(self):
|
||||||
from backends.databricks import emit
|
from backends.databricks import emit
|
||||||
|
|
||||||
from conf import cfg
|
from conf import cfg
|
||||||
|
|
||||||
first = emit(cfg._data)["databricks.yml"]
|
first = emit(cfg._data)
|
||||||
second = emit(cfg._data)["databricks.yml"]
|
second = emit(cfg._data)
|
||||||
assert first == second
|
assert first.keys() == second.keys()
|
||||||
|
assert first["databricks.yml"] == second["databricks.yml"]
|
||||||
|
|
||||||
|
|
||||||
class TestPluginArchitecture:
|
class TestPluginArchitecture:
|
||||||
"""#178 — Plugin extensibility."""
|
"""Plugin extensibility."""
|
||||||
|
|
||||||
def test_custom_plugin(self):
|
def test_custom_plugin(self):
|
||||||
from backends.databricks import _DEFAULT_PLUGINS, DabPlugin, emit
|
from backends.databricks import _DEFAULT_PLUGINS, DabPlugin, emit
|
||||||
@@ -253,13 +266,14 @@ class TestPluginArchitecture:
|
|||||||
def resources(self, cfg, registry, catalog_schemas):
|
def resources(self, cfg, registry, catalog_schemas):
|
||||||
return {"experiments": {"test_exp": {"name": "test-experiment"}}}
|
return {"experiments": {"test_exp": {"name": "test-experiment"}}}
|
||||||
|
|
||||||
|
def files(self, cfg, registry):
|
||||||
|
return {"bundle/test/hello.sql": "SELECT 1"}
|
||||||
|
|
||||||
_DEFAULT_PLUGINS.append(TestPlugin())
|
_DEFAULT_PLUGINS.append(TestPlugin())
|
||||||
try:
|
try:
|
||||||
data = yaml.safe_load(emit(cfg._data)["databricks.yml"])
|
result = emit(cfg._data)
|
||||||
|
data = yaml.safe_load(result["databricks.yml"])
|
||||||
assert "experiments" in data["resources"]
|
assert "experiments" in data["resources"]
|
||||||
assert (
|
assert "bundle/test/hello.sql" in result
|
||||||
data["resources"]["experiments"]["test_exp"]["name"]
|
|
||||||
== "test-experiment"
|
|
||||||
)
|
|
||||||
finally:
|
finally:
|
||||||
_DEFAULT_PLUGINS.pop()
|
_DEFAULT_PLUGINS.pop()
|
||||||
|
|||||||
Reference in New Issue
Block a user