Files
stack/dev/scripts/backends/databricks.py
kert 3c5b8e5ac1
Some checks failed
CI / skinny-install (aco) (push) Successful in 49s
CI / skinny-install (api) (push) Successful in 30s
CI / lint-test (push) Successful in 1m27s
CI / skinny-install (bcda) (push) Successful in 33s
CI / skinny-install (bib) (push) Successful in 30s
CI / skinny-install (ccw) (push) Successful in 27s
CI / skinny-install (bls) (push) Successful in 30s
CI / skinny-install (cms) (push) Successful in 26s
CI / skinny-install (cli) (push) Successful in 34s
CI / skinny-install (conf) (push) Successful in 25s
CI / skinny-install (perf) (push) Successful in 29s
CI / skinny-install (pfs) (push) Successful in 32s
CI / skinny-install (rex) (push) Successful in 26s
Infra CI / notebooks (push) Successful in 6s
Infra CI / zotero (push) Successful in 7s
Infra CI / docs (push) Successful in 8s
Infra CI / api (push) Successful in 9s
Infra CI / mc (push) Successful in 7s
Package Supply Chain / pkg-supply-chain (push) Failing after 52s
Deploy / build-scan-report (push) Successful in 3m21s
feat: databricks-bundles Python resource loader — typed DAB resources
Replace 375-line OrderedDict YAML generator with typed Python
resources via databricks-bundles 0.295.0. databricks.yml shrinks
from 278 lines to 23 — delegates all resource definitions to
bundle/resources/__init__.py via python.resources entry point.

- bundle/resources/__init__.py: load_resources() returns Resources
  with Job, Schema, Volume from pipeline registry
- databricks.yml: minimal (bundle name, targets, python entry point)
- Replaced databricks-cli>=0.18.0 with databricks-bundles>=0.295.0
- 30 DAB tests passing (12 new Python resource tests)
- 12,067 total tests passing

Refs #224
2026-03-25 00:29:13 -04:00

325 lines
11 KiB
Python

"""Databricks Asset Bundle (DAB) emitter.
Generates databricks.yml + transpiled SQL files from the pipeline
registry, table catalog, and stack.toml [databricks] config.
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.
"""
from __future__ import annotations
from collections import OrderedDict
import yaml
# ── YAML helpers ──────────────────────────────────────────────────
yaml.add_representer(
OrderedDict,
lambda dumper, data: dumper.represent_mapping(
"tag:yaml.org,2002:map", data.items()
),
)
def _dump(data: dict) -> str:
return yaml.dump(
data,
default_flow_style=False,
sort_keys=False,
width=120,
allow_unicode=True,
)
# ── Plugin protocol ──────────────────────────────────────────────
class DabPlugin:
"""Protocol for DAB resource plugins."""
def resources(self, cfg: dict, registry: dict, catalog_schemas: list[str]) -> dict:
return {}
def files(self, cfg: dict, registry: dict) -> dict[str, str]:
"""Return {relative_path: content} for extra files."""
return {}
# ── 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-- Generated by gen_config.py — DO NOT EDIT\n\n"
)
result[path] = header + sql + "\n"
return result
class JobsPlugin(DabPlugin):
"""Generate jobs with sql_task references to transpiled SQL files."""
def resources(self, cfg: dict, registry: dict, catalog_schemas: list) -> dict:
# Collect which cluster profiles are actually used
used_profiles: set[str] = set()
for pipe in registry.values():
used_profiles.add(getattr(pipe, "cluster_profile", "default"))
# Build job_clusters
clusters_cfg = cfg.get("clusters", {"default": {}})
job_clusters = []
for profile_name in sorted(used_profiles):
cluster_spec = dict(clusters_cfg.get(profile_name, {}))
job_clusters.append(
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)),
]
),
),
]
)
)
# Pipeline tasks — each pipeline runs as a Python wheel task
pipeline_tasks = []
for pipe_name, pipe in sorted(registry.items()):
profile = getattr(pipe, "cluster_profile", "default")
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"
task["python_wheel_task"] = OrderedDict(
[
("package_name", cfg.get("package_name", "stack")),
("entry_point", cfg.get("entry_point", "cli")),
(
"named_parameters",
OrderedDict(
[
("run", pipe_name),
("--target", "spark"),
("--catalog", "${var.catalog}"),
("--save", ""),
]
),
),
]
)
task["libraries"] = [
OrderedDict([("whl", "dbfs:/FileStore/wheels/stack-latest.whl")])
]
pipeline_tasks.append(task)
schedule = cfg.get("schedule", "0 0 6 * * ?")
timezone = cfg.get("timezone", "America/New_York")
job = OrderedDict()
job["name"] = f"{cfg.get('bundle_name', 'stack')}-pipelines"
job["description"] = (
f"Run all {len(registry)} ACO pipelines in dependency order"
)
job["schedule"] = OrderedDict(
[
("quartz_cron_expression", schedule),
("timezone_id", timezone),
]
)
job["job_clusters"] = job_clusters
job["tasks"] = pipeline_tasks
return {"jobs": OrderedDict([("stack_pipelines", job)])}
class SchemasPlugin(DabPlugin):
"""Generate Unity Catalog schema resources."""
def resources(self, cfg: dict, registry: dict, catalog_schemas: list) -> dict:
if not catalog_schemas:
return {}
schemas = OrderedDict()
for schema_name in sorted(catalog_schemas):
schemas[schema_name] = OrderedDict(
[
("name", schema_name),
("catalog_name", "${var.catalog}"),
]
)
return {"schemas": schemas}
class VolumesPlugin(DabPlugin):
"""Generate a staging volume for data upload."""
def resources(self, cfg: dict, registry: dict, catalog_schemas: list) -> dict:
return {
"volumes": OrderedDict(
[
(
"staging",
OrderedDict(
[
("name", "staging"),
("catalog_name", "${var.catalog}"),
("schema_name", "default"),
("volume_type", "MANAGED"),
]
),
),
]
)
}
# ── Core generator ───────────────────────────────────────────────
_HEADER = (
"# DO NOT EDIT — generated by gen_config.py from stack.toml + pipeline registry\n"
"# Re-generate: uv run python dev/scripts/gen_config.py\n"
)
_DEFAULT_PLUGINS: list[DabPlugin] = [
JobsPlugin(),
SchemasPlugin(),
VolumesPlugin(),
]
# Plugins used only for SQL generation (--dab-sql flag)
_SQL_PLUGINS: list[DabPlugin] = [SQLPlugin()]
def _get_catalog_schemas() -> list[str]:
"""Return only schemas produced by registered pipelines."""
try:
from aco.lake.catalog import Catalog
return Catalog.pipeline_schemas()
except Exception:
return []
def emit(cfg_data: dict, *, sql: bool = False) -> dict[str, str]:
"""Generate databricks.yml from config + pipeline registry.
The generated databricks.yml delegates resource definitions to
``bundle/resources/__init__.py`` via the ``python.resources`` key.
The YAML only contains bundle metadata, targets, and the Python
entry point — all resources (jobs, schemas, volumes) are defined
in typed Python code.
When ``sql=True``, also generates transpiled SQL files via SQLPlugin
(for audit/validation, not deployment).
Returns {relative_path: content} for all generated files.
"""
dab_cfg = cfg_data.get("databricks", {})
bundle_name = dab_cfg.get("bundle_name", "stack")
targets_cfg = dab_cfg.get("targets", {})
all_files: dict[str, str] = {}
# SQL generation (on demand only)
if sql:
from aco.pipe import registry
for plugin in _SQL_PLUGINS:
plugin_files = plugin.files(dab_cfg, registry)
all_files.update(plugin_files)
# Build minimal bundle YAML — resources come from Python
bundle = OrderedDict()
bundle["bundle"] = OrderedDict([("name", bundle_name)])
bundle["python"] = OrderedDict(
[
("venv_path", ".venv"),
(
"resources",
["resources:load_resources"],
),
]
)
bundle["workspace"] = OrderedDict([("host", "${DATABRICKS_HOST}")])
# Targets
targets = OrderedDict()
for tname, tcfg in sorted(targets_cfg.items()):
target = OrderedDict()
if tcfg.get("mode"):
target["mode"] = tcfg["mode"]
if tcfg.get("default"):
target["default"] = True
target["variables"] = OrderedDict(
[
("catalog", tcfg.get("catalog", "aco")),
]
)
if tcfg.get("mode") == "production":
run_as = dab_cfg.get("run_as", {})
if run_as.get("service_principal_name"):
target["run_as"] = OrderedDict(
[
("service_principal_name", run_as["service_principal_name"]),
]
)
targets[tname] = target
bundle["targets"] = targets
all_files["databricks.yml"] = _HEADER + "\n" + _dump(dict(bundle))
return all_files