diff --git a/.nextchanges/cli/6646.md b/.nextchanges/cli/6646.md new file mode 100644 index 00000000000..842c8f8d68b --- /dev/null +++ b/.nextchanges/cli/6646.md @@ -0,0 +1 @@ +* Add `databricks bundle test` for running BundleTest workflows. ([#6646](https://github.com/databricks/cli/pull/6646)) diff --git a/cmd/bundle/bundle.go b/cmd/bundle/bundle.go index d8334d38289..8528a1fa6f4 100644 --- a/cmd/bundle/bundle.go +++ b/cmd/bundle/bundle.go @@ -29,6 +29,7 @@ Online documentation: https://docs.databricks.com/en/dev-tools/bundles/index.htm cmd.AddCommand(newDeployCommand()) cmd.AddCommand(newDestroyCommand()) cmd.AddCommand(newRunCommand()) + cmd.AddCommand(newTestCommand()) cmd.AddCommand(newSchemaCommand()) cmd.AddCommand(newSyncCommand()) cmd.AddCommand(newValidateCommand()) diff --git a/cmd/bundle/test.go b/cmd/bundle/test.go new file mode 100644 index 00000000000..b15bde2aa00 --- /dev/null +++ b/cmd/bundle/test.go @@ -0,0 +1,58 @@ +package bundle + +import ( + "fmt" + "os" + "os/exec" + + "github.com/databricks/cli/libs/execv" + "github.com/spf13/cobra" +) + +var ( + bundleTestLookPath = exec.LookPath + bundleTestExecv = execv.Execv +) + +func newTestCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "test [bundletest-args...]", + Short: "Run tests for a bundle with bundletest", + Long: `Run tests for a bundle with the experimental bundletest runner. + +All arguments are passed directly to bundletest. Arguments that bundletest does +not consume are forwarded to pytest, including arguments after --. + +Examples: + databricks bundle test --local -v + databricks bundle test --local --changed --base origin/main + databricks bundle test --cloud --profile dev --warehouse-id abc123 + databricks bundle test --local -- -k transform_orders`, + Args: cobra.ArbitraryArgs, + DisableFlagParsing: true, + RunE: func(cmd *cobra.Command, args []string) error { + return executeBundleTest(args) + }, + } + + return cmd +} + +func executeBundleTest(args []string) error { + runner, err := bundleTestLookPath("bundletest") + if err != nil { + return fmt.Errorf("cannot find bundletest on PATH; install it with `uv pip install -e /path/to/databricks-cli/experimental/bundletest`: %w", err) + } + + argv := make([]string, 1, len(args)+1) + argv[0] = runner + argv = append(argv, args...) + err = bundleTestExecv(execv.Options{ + Args: argv, + Env: os.Environ(), + }) + if err != nil { + return fmt.Errorf("failed to run bundletest: %w", err) + } + return nil +} diff --git a/cmd/bundle/test_test.go b/cmd/bundle/test_test.go new file mode 100644 index 00000000000..97358b5bcc7 --- /dev/null +++ b/cmd/bundle/test_test.go @@ -0,0 +1,103 @@ +package bundle + +import ( + "errors" + "os/exec" + "testing" + + "github.com/databricks/cli/libs/execv" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func stubBundleTestProcess(t *testing.T, lookPath func(string) (string, error), execute func(execv.Options) error) { + originalLookPath := bundleTestLookPath + originalExecv := bundleTestExecv + bundleTestLookPath = lookPath + bundleTestExecv = execute + t.Cleanup(func() { + bundleTestLookPath = originalLookPath + bundleTestExecv = originalExecv + }) +} + +func executeTestCommand(t *testing.T, args ...string) error { + root := &cobra.Command{Use: "databricks", SilenceErrors: true, SilenceUsage: true} + bundle := &cobra.Command{Use: "bundle"} + root.AddCommand(bundle) + bundle.AddCommand(newTestCommand()) + root.SetArgs(append([]string{"bundle", "test"}, args...)) + return root.ExecuteContext(t.Context()) +} + +func TestBundleTestForwardsArgumentsUnchanged(t *testing.T) { + t.Setenv("BUNDLETEST_ADAPTER_TEST", "present") + + var options execv.Options + stubBundleTestProcess(t, + func(name string) (string, error) { + assert.Equal(t, "bundletest", name) + return "/resolved/bundletest", nil + }, + func(got execv.Options) error { + options = got + return nil + }, + ) + + err := executeTestCommand(t, + "--local", + "--changed", + "--base", "origin/main", + "-k", "transform_orders", + "--", + "tests/test_job.py::test_transform", + ) + require.NoError(t, err) + assert.Equal(t, []string{ + "/resolved/bundletest", + "--local", + "--changed", + "--base", "origin/main", + "-k", "transform_orders", + "--", + "tests/test_job.py::test_transform", + }, options.Args) + assert.Contains(t, options.Env, "BUNDLETEST_ADAPTER_TEST=present") + assert.Empty(t, options.Dir) +} + +func TestBundleTestRunnerNotFound(t *testing.T) { + stubBundleTestProcess(t, + func(string) (string, error) { + return "", exec.ErrNotFound + }, + func(execv.Options) error { + return errors.New("execv should not be called") + }, + ) + + err := executeTestCommand(t, "--local") + require.Error(t, err) + assert.ErrorIs(t, err, exec.ErrNotFound) + assert.ErrorContains(t, err, "cannot find bundletest on PATH") + assert.ErrorContains(t, err, "experimental/bundletest") +} + +func TestBundleTestRunnerStartError(t *testing.T) { + startErr := errors.New("start failed") + stubBundleTestProcess(t, + func(string) (string, error) { + return "/resolved/bundletest", nil + }, + func(execv.Options) error { + return startErr + }, + ) + + err := executeTestCommand(t, "--local") + require.Error(t, err) + assert.ErrorIs(t, err, startErr) + assert.ErrorContains(t, err, "failed to run bundletest") +} diff --git a/experimental/bundletest/README.md b/experimental/bundletest/README.md index d15cdb955f0..3cabb5e36b7 100644 --- a/experimental/bundletest/README.md +++ b/experimental/bundletest/README.md @@ -2,6 +2,36 @@ Experimental, pytest-style **isolation testing** for Databricks Asset Bundles (DABs). +## Quick start + +Install the package in a bundle project, generate a starter test, and run locally: + +```sh +uv pip install -e /path/to/cli/experimental/bundletest +databricks bundle test init +databricks bundle test --local +``` + +`bundletest init` finds the nearest `databricks.yml`, chooses a declared resource, and +creates `tests/conftest.py` plus a marked `tests/test_bundle.py`. It refuses to overwrite +either file unless `--force` is supplied. + +Every local run begins with a support report, so unsupported tasks are visible before +pytest starts: + +```text +bundletest local support: orders + +[LOCAL ] jobs.transform_orders/transform: runs src/transform_orders.sql +[LOCAL ] jobs.aggregate_orders/aggregate: runs src/aggregate_orders.sql +[CLOUD ] jobs.score_model/score: notebook_task requires a Databricks workspace +[CONFIG] 33 non-job resources: configuration assertions only + +summary: 2 local, 1 cloud-only, 33 config-only +``` + +Use `databricks bundle test --support-only` for the report without a test run. + Unit tests answer "does my function return the right value?" `bundletest` answers the next question up: **"does my deployed bundle resource actually produce the right table?"** That class of bug — a job wired to the wrong upstream, a renamed table, a transform @@ -35,6 +65,25 @@ We isolate by substituting the component's **data-boundary neighbors**, never by the component's own output — faking the thing under test is a tautology that catches nothing. +Job runs are checked by default. A failed task raises an assertion with the resource, +task, source file, backend, run ID when available, and the original error: + +```text +bundle job 'transform_orders' failed + task: transform + source: src/transform_orders.sql + backend: local + error: Catalog Error: Table raw_orders does not exist + use check=False to inspect an expected failure +``` + +Expected-failure tests can opt out explicitly: + +```python +result = env.run_job("transform_orders", check=False) +assert not result.succeeded +``` + ## Two backends, one seam The same test runs against either backend, chosen by the `BUNDLETEST_BACKEND` env var: @@ -84,21 +133,49 @@ the CLI repo are required in addition to Python. ```sh uv venv --python 3.12 uv pip install -e ".[dev]" -uv run pytest -v +databricks bundle test --bundle examples/orders_bundle --local -v ``` +Arguments that `bundletest` does not consume are passed to pytest, so `-k`, `-x`, `-v`, +node IDs, and plugins continue to work normally. Use `--no-support-report` for compact CI +output. + +### Run tests affected by a change + +Mark each test with the resources it exercises: + +```python +@pytest.mark.bundle_resource("jobs.transform_orders") +def test_transform_dedupes(env): ... +``` + +Then select tests from the Git diff: + +```sh +databricks bundle test --local --changed --base origin/main +``` + +`bundletest` maps changed paths such as `src/transform_orders.sql` back to the bundle +resources that reference them. It runs tests with matching `bundle_resource` markers and +always includes changed test files. A YAML change runs the complete suite because it can +alter resource wiring, variables, or targets. The selection includes committed, staged, +unstaged, and untracked files. + ### Run it on cloud The cloud backend deploys to a real workspace. Example fixture `examples/cloud_orders/` contains two SQL jobs, a managed volume, and a file_path dashboard under `main.bundletest_cloud`: ```sh -export BUNDLETEST_BACKEND=cloud -export BUNDLETEST_PROFILE= # from ~/.databrickscfg -export BUNDLETEST_WAREHOUSE_ID= # used for seeding + assertion queries export BUNDLE_VAR_warehouse_id= -uv run --extra dev pytest examples/cloud_orders +databricks bundle test --cloud \ + --bundle examples/cloud_orders \ + --profile \ + --warehouse-id ``` +The command requires `--profile` for cloud runs; it never selects a Databricks profile +implicitly. Add `--target ` when the bundle has a dedicated test target. + (`examples/orders_bundle/` is local static-config only, not deployable to cloud.) Seeded tables and job runs are real and cost money, so unlike the local backend (a fresh diff --git a/experimental/bundletest/examples/cloud_orders/tests/test_cloud_e2e.py b/experimental/bundletest/examples/cloud_orders/tests/test_cloud_e2e.py index 440a6253718..86d26b95132 100644 --- a/experimental/bundletest/examples/cloud_orders/tests/test_cloud_e2e.py +++ b/experimental/bundletest/examples/cloud_orders/tests/test_cloud_e2e.py @@ -8,6 +8,7 @@ import pytest +@pytest.mark.bundle_resource("jobs.transform_orders", "jobs.aggregate_orders") def test_bronze_to_silver_to_gold(env, schema): env.seed( f"{schema}.raw_orders", @@ -33,17 +34,20 @@ def test_bronze_to_silver_to_gold(env, schema): @pytest.mark.cloud_only +@pytest.mark.bundle_resource("jobs.transform_orders") def test_price_type_is_databricks_decimal(env, schema): env.seed(f"{schema}.raw_orders", [{"order_id": 1, "total_price": 10.0}]) env.run_job("transform_orders") assert env.table(f"{schema}.orders").schema["total_price"] == "decimal(10,2)" +@pytest.mark.bundle_resource("jobs.transform_orders") def test_job_is_wired_to_its_sql(env): job = env.backend.get_resource("jobs", "transform_orders") assert job["tasks"][0]["sql_task"]["file"]["path"].endswith("transform_orders.sql") +@pytest.mark.bundle_resource("dashboards.orders_overview") def test_dashboard_source_tables_from_file_path(env, schema): # The dashboard is defined by file_path, not inline, yet source_tables() still resolves: # `bundle summary` inlines the file's serialized form at config-load, so get_resource has it. @@ -52,6 +56,7 @@ def test_dashboard_source_tables_from_file_path(env, schema): assert dashboard.source_tables() == [f"{schema}.order_summary"] +@pytest.mark.bundle_resource("volumes.raw_data") def test_uploaded_csv_is_readable(env, tmp_path): csv = tmp_path / "orders.csv" csv.write_text("order_id,total_price\n1,10.0\n2,5.0\n") @@ -65,6 +70,7 @@ def test_uploaded_csv_is_readable(env, tmp_path): @pytest.mark.cloud_only +@pytest.mark.bundle_resource("jobs.transform_orders") def test_deployed_job_carries_server_filled_fields(env): # get_deployed reads the workspace's stored object, so it carries values the server filled # in or normalized that our databricks.yml never declared — what get_resource (the declared diff --git a/experimental/bundletest/examples/orders_bundle/tests/test_job_config.py b/experimental/bundletest/examples/orders_bundle/tests/test_job_config.py index 1f14477ca6a..92dd69145b6 100644 --- a/experimental/bundletest/examples/orders_bundle/tests/test_job_config.py +++ b/experimental/bundletest/examples/orders_bundle/tests/test_job_config.py @@ -16,7 +16,10 @@ the typed handles (env.pipeline, env.dashboard, ...) add resource-specific accessors on top. """ +import pytest + +@pytest.mark.bundle_resource("jobs.transform_orders") def test_job_is_wired_to_its_sql(env): job = env.backend.get_resource("jobs", "transform_orders") assert job["name"] == "transform_orders" diff --git a/experimental/bundletest/examples/orders_bundle/tests/test_job_sql.py b/experimental/bundletest/examples/orders_bundle/tests/test_job_sql.py index b4b804d4cf2..79988e5ce7b 100644 --- a/experimental/bundletest/examples/orders_bundle/tests/test_job_sql.py +++ b/experimental/bundletest/examples/orders_bundle/tests/test_job_sql.py @@ -19,6 +19,7 @@ import pytest +@pytest.mark.bundle_resource("jobs.transform_orders") def test_transform_dedupes(env): env.seed( "shop.bronze.raw_orders", @@ -43,6 +44,7 @@ def test_transform_dedupes(env): @pytest.mark.cloud_only +@pytest.mark.bundle_resource("jobs.transform_orders") def test_price_type_is_databricks_decimal(env): env.seed("shop.bronze.raw_orders", [{"order_id": 1, "total_price": 10.0}]) env.run_job("transform_orders") diff --git a/experimental/bundletest/examples/orders_bundle/tests/test_pipeline_end_to_end.py b/experimental/bundletest/examples/orders_bundle/tests/test_pipeline_end_to_end.py index 7045409fbb8..dd822553256 100644 --- a/experimental/bundletest/examples/orders_bundle/tests/test_pipeline_end_to_end.py +++ b/experimental/bundletest/examples/orders_bundle/tests/test_pipeline_end_to_end.py @@ -12,7 +12,10 @@ - automatic dependency ordering (here the test sequences run_job calls itself) """ +import pytest + +@pytest.mark.bundle_resource("jobs.transform_orders", "jobs.aggregate_orders") def test_bronze_to_silver_to_gold(env): env.seed( "shop.bronze.raw_orders", diff --git a/experimental/bundletest/pyproject.toml b/experimental/bundletest/pyproject.toml index 010822dc563..e78e0eb3a11 100644 --- a/experimental/bundletest/pyproject.toml +++ b/experimental/bundletest/pyproject.toml @@ -11,6 +11,7 @@ requires-python = ">=3.10" license = "Apache-2.0" dependencies = [ "duckdb>=1.0", + "pytest>=7.0", "pyyaml>=6.0", ] @@ -18,7 +19,10 @@ dependencies = [ # The cloud backend talks to a real workspace through the Databricks SDK. Kept optional and # lazily imported so a local-only install (and its DuckDB backend) never pulls it in. cloud = ["databricks-sdk>=0.40"] -dev = ["pytest>=7.0", "databricks-sdk>=0.40"] +dev = ["databricks-sdk>=0.40"] + +[project.scripts] +bundletest = "bundletest.cli:main" [project.entry-points.pytest11] bundletest = "bundletest.pytest_plugin" diff --git a/experimental/bundletest/src/bundletest/backend.py b/experimental/bundletest/src/bundletest/backend.py index 9f702301ed0..8918beeb6ff 100644 --- a/experimental/bundletest/src/bundletest/backend.py +++ b/experimental/bundletest/src/bundletest/backend.py @@ -30,21 +30,34 @@ class RunResult: duration_seconds: float = 0.0 # wall-clock; only meaningful on the cloud backend run_id: str = "" error: str = "" # failure detail when result_state == "FAILED" + backend: str = "" + resource_name: str = "" + task_key: str = "" + source_path: str = "" @property def succeeded(self) -> bool: return self.result_state == "SUCCESS" -class JobRunFailed(Exception): - """A job run finished unsuccessfully and the caller did not opt out with ``check=False``. - - Raised at the handle layer (not the backends) so both tiers get it for free. Carries the - ``RunResult`` so a test that deliberately runs a failing job can still inspect it.""" - - def __init__(self, result: RunResult): +class JobRunFailed(AssertionError): + """A checked bundle job run that did not succeed.""" + + def __init__(self, name: str, result: RunResult): + detail = result.error or f"run finished in state {result.result_state}" + lines = [f"bundle job {name!r} failed"] + if result.task_key: + lines.append(f" task: {result.task_key}") + if result.source_path: + lines.append(f" source: {result.source_path}") + if result.backend: + lines.append(f" backend: {result.backend}") + if result.run_id: + lines.append(f" run id: {result.run_id}") + lines.extend((f" error: {detail}", " use check=False to inspect an expected failure")) + super().__init__("\n".join(lines)) + self.name = name self.result = result - super().__init__(result.error or f"job run reported {result.result_state}") @runtime_checkable diff --git a/experimental/bundletest/src/bundletest/backends/cloud.py b/experimental/bundletest/src/bundletest/backends/cloud.py index eb36aed061f..a5995645232 100644 --- a/experimental/bundletest/src/bundletest/backends/cloud.py +++ b/experimental/bundletest/src/bundletest/backends/cloud.py @@ -178,6 +178,8 @@ def run_job(self, name: str, params: dict[str, Any] | None = None) -> RunResult: (run.run_duration or 0) / 1000, str(run.run_id), error="" if succeeded else (run.state.state_message or ""), + backend="cloud", + resource_name=name, ) # --- data plane --- diff --git a/experimental/bundletest/src/bundletest/backends/duckdb.py b/experimental/bundletest/src/bundletest/backends/duckdb.py index 0da5bafc407..f424dd04b93 100644 --- a/experimental/bundletest/src/bundletest/backends/duckdb.py +++ b/experimental/bundletest/src/bundletest/backends/duckdb.py @@ -179,8 +179,10 @@ def run_job(self, name: str, params: dict[str, Any] | None = None) -> RunResult: if tasks is None: raise KeyError(f"no job named {name!r} in the bundle (known: {sorted(self._jobs)})") start = time.perf_counter() + current_task: _Task | None = None try: for task in tasks: + current_task = task if task.kind != "sql": raise LocalUnsupported( f"job {name!r} task {task.key!r} is a {task.kind} task; the " @@ -196,13 +198,26 @@ def run_job(self, name: str, params: dict[str, Any] | None = None) -> RunResult: self._prepare_namespaces(sql) for statement in _split_statements(sql): self._con.execute(statement) - return RunResult("SUCCESS", time.perf_counter() - start) + return RunResult( + "SUCCESS", + time.perf_counter() - start, + backend="local", + resource_name=name, + ) except LocalUnsupported: raise except duckdb.Error as e: if _MISSING_FUNCTION.search(str(e)): raise LocalUnsupported(f"job {name!r} uses SQL not available locally: {_first_line(e)}") from e - return RunResult("FAILED", time.perf_counter() - start, error=_first_line(e)) + return RunResult( + "FAILED", + time.perf_counter() - start, + error=_first_line(e), + backend="local", + resource_name=name, + task_key=current_task.key if current_task else "", + source_path=current_task.sql_file if current_task and current_task.sql_file else "", + ) # --- data plane --- def execute_sql(self, query: str) -> list[tuple]: @@ -224,8 +239,7 @@ def get_resource(self, kind: str, name: str) -> dict[str, Any]: reason = _unresolved(cfg) if reason is not None: raise LocalUnsupported( - f"resource {kind}.{name} can't be introspected locally: {reason}; " - f"use the cloud backend" + f"resource {kind}.{name} can't be introspected locally: {reason}; use the cloud backend" ) return cfg diff --git a/experimental/bundletest/src/bundletest/cli.py b/experimental/bundletest/src/bundletest/cli.py new file mode 100644 index 00000000000..fcbc5d773c8 --- /dev/null +++ b/experimental/bundletest/src/bundletest/cli.py @@ -0,0 +1,162 @@ +"""Command-line workflow for running and scaffolding bundle tests.""" + +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path +from typing import Sequence + +from bundletest.project import ProjectError, find_bundle_root +from bundletest.scaffold import create_starter +from bundletest.selection import ( + format_change_selection, + git_changed_files, + select_changes, +) +from bundletest.support import format_support_report, inspect_local_support + + +def main(argv: Sequence[str] | None = None) -> int: + args = list(argv if argv is not None else sys.argv[1:]) + try: + if args and args[0] == "init": + return _init(args[1:]) + return _run(args) + except (FileExistsError, ProjectError, RuntimeError, ValueError) as err: + print(f"bundletest: {err}", file=sys.stderr) + return 2 + + +def _run(argv: Sequence[str]) -> int: + parser = _run_parser() + options, pytest_args = parser.parse_known_args(argv) + _validate_options(options) + root = find_bundle_root(options.bundle) + backend = "cloud" if options.cloud else "local" + _configure_environment(options, backend) + + report = inspect_local_support(root) + if not options.no_support_report: + print(format_support_report(root, report)) + print() + if options.support_only: + return 1 if any(entry.status == "error" for entry in report.entries) else 0 + + selection = None + if options.changed: + base = options.base or "HEAD~1" + changed = git_changed_files(root, base) + selection = select_changes(root, changed) + print(format_change_selection(selection, base)) + print() + if selection.is_empty: + return 0 + + arguments = list(pytest_args) + if arguments and arguments[0] == "--": + arguments.pop(0) + if not _has_test_path(arguments, root): + tests = root / "tests" + arguments.append(str(tests if tests.is_dir() else root)) + if selection and not selection.run_all: + arguments.extend(f"--bundletest-resource={name}" for name in selection.resources) + arguments.extend(f"--bundletest-test-file={name}" for name in selection.test_files) + + import pytest + + print(f"bundletest: running {backend} tests") + previous = Path.cwd() + os.chdir(root) + try: + return int(pytest.main(arguments)) + finally: + os.chdir(previous) + + +def _init(argv: Sequence[str]) -> int: + parser = argparse.ArgumentParser( + prog="bundletest init", + description="Generate starter pytest files for an existing Databricks bundle", + ) + parser.add_argument("path", nargs="?", default=".", help="bundle directory or a child path") + parser.add_argument("--force", action="store_true", help="replace the generated starter files if they exist") + options = parser.parse_args(argv) + root = find_bundle_root(options.path) + generated = create_starter(root, force=options.force) + print(f"bundletest: generated starter for {generated.resource}") + for path in generated.files: + print(f" {path.relative_to(root)}") + print("next: bundletest --local") + return 0 + + +def _run_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="bundletest", + description="Run pytest against a Databricks bundle locally or in a workspace", + ) + parser.add_argument("--bundle", default=".", help="bundle directory or a child path (default: current directory)") + backend = parser.add_mutually_exclusive_group() + backend.add_argument("--local", action="store_true", help="run with the local DuckDB backend") + backend.add_argument("--cloud", action="store_true", help="run against a Databricks workspace") + parser.add_argument("--profile", help="explicit Databricks CLI profile for --cloud") + parser.add_argument("--warehouse-id", help="SQL warehouse used by cloud tests") + parser.add_argument("--target", help="bundle target used by cloud tests") + parser.add_argument("--changed", action="store_true", help="run tests affected by files changed from --base") + parser.add_argument("--base", help="Git base for --changed (default: HEAD~1)") + parser.add_argument("--support-only", action="store_true", help="print local support without running pytest") + parser.add_argument("--no-support-report", action="store_true", help="do not print the local support report") + return parser + + +def _validate_options(options: argparse.Namespace) -> None: + if options.base and not options.changed: + raise ValueError("--base requires --changed") + if options.support_only and options.no_support_report: + raise ValueError("--support-only cannot be used with --no-support-report") + + +def _configure_environment(options: argparse.Namespace, backend: str) -> None: + if backend == "local": + incompatible = [ + flag + for flag, value in ( + ("--profile", options.profile), + ("--warehouse-id", options.warehouse_id), + ("--target", options.target), + ) + if value + ] + if incompatible: + raise ValueError(f"{', '.join(incompatible)} can only be used with --cloud") + elif not options.profile: + raise ValueError("--cloud requires an explicit --profile") + + os.environ["BUNDLETEST_BACKEND"] = backend + _set_or_clear("BUNDLETEST_PROFILE", options.profile) + _set_or_clear("BUNDLETEST_WAREHOUSE_ID", options.warehouse_id) + _set_or_clear("BUNDLETEST_TARGET", options.target) + + +def _set_or_clear(name: str, value: str | None) -> None: + if value is None: + os.environ.pop(name, None) + else: + os.environ[name] = value + + +def _has_test_path(arguments: Sequence[str], root: Path) -> bool: + for argument in arguments: + if argument.startswith("-"): + continue + path_text = argument.split("::", 1)[0] + path = Path(path_text) + if (path if path.is_absolute() else root / path).exists(): + return True + return False + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/experimental/bundletest/src/bundletest/env.py b/experimental/bundletest/src/bundletest/env.py index 05781669687..11111c37afd 100644 --- a/experimental/bundletest/src/bundletest/env.py +++ b/experimental/bundletest/src/bundletest/env.py @@ -65,7 +65,7 @@ def run(self, params: dict[str, Any] | None = None, check: bool = True) -> RunRe # tests that deliberately run a failing job pass check=False and inspect the result. self._last = self._backend.run_job(self.name, params) if check and not self._last.succeeded: - raise JobRunFailed(self._last) + raise JobRunFailed(self.name, self._last) return self._last def last_run(self) -> RunResult: diff --git a/experimental/bundletest/src/bundletest/project.py b/experimental/bundletest/src/bundletest/project.py new file mode 100644 index 00000000000..e5f5c98e0d0 --- /dev/null +++ b/experimental/bundletest/src/bundletest/project.py @@ -0,0 +1,28 @@ +"""Bundle project discovery and configuration loading.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from bundletest.backends.duckdb import _resolve_config + + +class ProjectError(ValueError): + """The requested path is not a usable bundle project.""" + + +def find_bundle_root(start: str | Path = ".") -> Path: + """Return the nearest parent containing ``databricks.yml``.""" + path = Path(start).resolve() + if path.is_file(): + path = path.parent + for candidate in (path, *path.parents): + if (candidate / "databricks.yml").is_file(): + return candidate + raise ProjectError(f"no databricks.yml found at or above {path}") + + +def load_bundle_config(root: Path) -> dict[str, Any]: + """Resolve bundle configuration offline with the CLI's bundle engine.""" + return _resolve_config(str(root)) diff --git a/experimental/bundletest/src/bundletest/pytest_plugin.py b/experimental/bundletest/src/bundletest/pytest_plugin.py index 4e385bf8833..279075e1528 100644 --- a/experimental/bundletest/src/bundletest/pytest_plugin.py +++ b/experimental/bundletest/src/bundletest/pytest_plugin.py @@ -8,6 +8,8 @@ from __future__ import annotations +from pathlib import Path + import pytest from _pytest.outcomes import Skipped @@ -15,11 +17,31 @@ from bundletest.env import current_backend_kind +def pytest_addoption(parser: pytest.Parser) -> None: + group = parser.getgroup("bundletest") + group.addoption( + "--bundletest-resource", + action="append", + default=[], + help="collect tests marked for this bundle resource (repeatable)", + ) + group.addoption( + "--bundletest-test-file", + action="append", + default=[], + help="collect this changed test file even when it has no resource marker", + ) + + def pytest_configure(config: pytest.Config) -> None: config.addinivalue_line( "markers", "cloud_only: assertion depends on cloud-only behavior; skipped unless BUNDLETEST_BACKEND=cloud", ) + config.addinivalue_line( + "markers", + "bundle_resource(name): resource exercised by this test, such as jobs.transform_orders", + ) @pytest.hookimpl(hookwrapper=True) @@ -35,6 +57,7 @@ def pytest_runtest_call(item: pytest.Item): def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None: + _select_changed_items(config, items) backend = current_backend_kind() if backend == "cloud": return @@ -42,3 +65,22 @@ def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item for item in items: if "cloud_only" in item.keywords: item.add_marker(skip) + + +def _select_changed_items(config: pytest.Config, items: list[pytest.Item]) -> None: + resources = set(config.getoption("--bundletest-resource")) + test_files = {Path(path).resolve() for path in config.getoption("--bundletest-test-file")} + if not resources and not test_files: + return + + selected: list[pytest.Item] = [] + deselected: list[pytest.Item] = [] + for item in items: + marked = {str(argument) for marker in item.iter_markers("bundle_resource") for argument in marker.args} + if marked.intersection(resources) or Path(str(item.path)).resolve() in test_files: + selected.append(item) + else: + deselected.append(item) + items[:] = selected + if deselected: + config.hook.pytest_deselected(items=deselected) diff --git a/experimental/bundletest/src/bundletest/scaffold.py b/experimental/bundletest/src/bundletest/scaffold.py new file mode 100644 index 00000000000..d2f92094e10 --- /dev/null +++ b/experimental/bundletest/src/bundletest/scaffold.py @@ -0,0 +1,86 @@ +"""Safe starter-test generation for an existing bundle.""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from bundletest.project import load_bundle_config + + +@dataclass(frozen=True) +class GeneratedStarter: + resource: str + files: tuple[Path, ...] + + +def create_starter(root: Path, *, force: bool = False) -> GeneratedStarter: + config = load_bundle_config(root) + kind, name = _first_resource(config) + tests = root / "tests" + files = (tests / "conftest.py", tests / "test_bundle.py") + existing = [path for path in files if path.exists()] + if existing and not force: + joined = ", ".join(str(path.relative_to(root)) for path in existing) + raise FileExistsError(f"starter files already exist: {joined}; pass --force to replace them") + + tests.mkdir(parents=True, exist_ok=True) + files[0].write_text(_conftest()) + files[1].write_text(_starter_test(kind, name)) + return GeneratedStarter(f"{kind}.{name}", files) + + +def _first_resource(config: dict[str, Any]) -> tuple[str, str]: + resources = config.get("resources", {}) or {} + jobs = resources.get("jobs", {}) or {} + for name, resource in jobs.items(): + if any("sql_task" in task for task in resource.get("tasks", [])): + return "jobs", name + for kind, declared in resources.items(): + if isinstance(declared, dict) and declared: + name = next(iter(declared)) + return kind, name + raise ValueError("bundle has no declared resources to generate a starter test for") + + +def _conftest() -> str: + return '''"""Shared bundletest fixture.""" + +from pathlib import Path + +import pytest +from bundletest import bundle_env + +BUNDLE_ROOT = Path(__file__).resolve().parent.parent + + +@pytest.fixture +def env(): + with bundle_env(str(BUNDLE_ROOT)) as test_env: + yield test_env +''' + + +def _starter_test(kind: str, name: str) -> str: + function_name = re.sub(r"\W+", "_", name).strip("_") or "resource" + marker = f"{kind}.{name}" + if kind == "jobs": + body = f""" job = env.jobs[{json.dumps(name)}] + assert job.exists() + assert job.config.get("tasks"), "job must declare at least one task" +""" + else: + body = f""" resource = env.resource({json.dumps(kind)}, {json.dumps(name)}) + assert resource.exists() +""" + return f'''"""Starter assertions generated by ``bundletest init``.""" + +import pytest + + +@pytest.mark.bundle_resource({json.dumps(marker)}) +def test_{function_name}_is_declared(env): +{body}''' diff --git a/experimental/bundletest/src/bundletest/selection.py b/experimental/bundletest/src/bundletest/selection.py new file mode 100644 index 00000000000..d00e6d8664f --- /dev/null +++ b/experimental/bundletest/src/bundletest/selection.py @@ -0,0 +1,116 @@ +"""Map changed bundle files to pytest resource markers.""" + +from __future__ import annotations + +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Iterable + +from bundletest.project import load_bundle_config + +_PATH_KEYS = {"file_path", "notebook_path", "path", "source_code_path"} + + +@dataclass(frozen=True) +class ChangeSelection: + changed_files: tuple[str, ...] + resources: tuple[str, ...] + test_files: tuple[str, ...] + run_all: bool = False + + @property + def is_empty(self) -> bool: + return not self.run_all and not self.resources and not self.test_files + + +def git_changed_files(root: Path, base: str) -> list[str]: + """Return committed, staged, unstaged, and untracked paths relative to the bundle.""" + repo = _git(root, "rev-parse", "--show-toplevel").strip() + repo_root = Path(repo) + paths: set[str] = set() + commands = ( + ("diff", "--name-only", "--diff-filter=ACMR", f"{base}...HEAD"), + ("diff", "--name-only", "--diff-filter=ACMR"), + ("diff", "--cached", "--name-only", "--diff-filter=ACMR"), + ("ls-files", "--others", "--exclude-standard"), + ) + for args in commands: + for name in _git(root, *args).splitlines(): + absolute = (repo_root / name).resolve() + if absolute == root or root in absolute.parents: + paths.add(absolute.relative_to(root).as_posix()) + return sorted(paths) + + +def select_changes(root: Path, changed_files: Iterable[str]) -> ChangeSelection: + changed = tuple(sorted(set(changed_files))) + config = load_bundle_config(root) + resources = config.get("resources", {}) or {} + all_resources = tuple( + f"{kind}.{name}" for kind, declared in resources.items() if isinstance(declared, dict) for name in declared + ) + + if any(Path(name).suffix in {".yml", ".yaml"} for name in changed): + return ChangeSelection(changed, all_resources, (), run_all=True) + + references = _resource_references(root, resources) + affected: set[str] = set() + tests: set[str] = set() + for name in changed: + path = (root / name).resolve() + if _is_test_file(root, path): + tests.add(path.as_posix()) + for resource, targets in references.items(): + if any(path == target or target in path.parents for target in targets): + affected.add(resource) + return ChangeSelection(changed, tuple(sorted(affected)), tuple(sorted(tests))) + + +def format_change_selection(selection: ChangeSelection, base: str) -> str: + lines = [f"bundletest changed selection: {base}"] + if selection.run_all: + lines.append("bundle configuration changed; running the complete suite") + return "\n".join(lines) + for resource in selection.resources: + lines.append(f"[RESOURCE] {resource}") + for test in selection.test_files: + lines.append(f"[TEST ] {test}") + if selection.is_empty: + lines.append("no bundle resources or tests were affected") + return "\n".join(lines) + + +def _git(root: Path, *args: str) -> str: + result = subprocess.run(["git", *args], cwd=root, capture_output=True, text=True, check=False) + if result.returncode: + detail = result.stderr.strip() or result.stdout.strip() + raise RuntimeError(f"git {' '.join(args)} failed: {detail}") + return result.stdout + + +def _resource_references(root: Path, resources: dict[str, Any]) -> dict[str, tuple[Path, ...]]: + references: dict[str, tuple[Path, ...]] = {} + for kind, declared in resources.items(): + if not isinstance(declared, dict): + continue + for name, config in declared.items(): + paths = tuple((root / value).resolve() for value in _path_values(config)) + references[f"{kind}.{name}"] = paths + return references + + +def _path_values(node: Any, key: str = "") -> Iterable[str]: + if isinstance(node, dict): + for child_key, value in node.items(): + yield from _path_values(value, child_key) + elif isinstance(node, list): + for value in node: + yield from _path_values(value, key) + elif key in _PATH_KEYS and isinstance(node, str) and "://" not in node: + yield node + + +def _is_test_file(root: Path, path: Path) -> bool: + tests = root / "tests" + return path.suffix == ".py" and (path == tests or tests in path.parents) diff --git a/experimental/bundletest/src/bundletest/support.py b/experimental/bundletest/src/bundletest/support.py new file mode 100644 index 00000000000..26882f91c49 --- /dev/null +++ b/experimental/bundletest/src/bundletest/support.py @@ -0,0 +1,111 @@ +"""Static report of what the local backend can exercise.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from bundletest.backends.duckdb import _QUALIFIED, _RESERVED_CATALOGS, _VAR_REF +from bundletest.project import load_bundle_config + + +@dataclass(frozen=True) +class SupportEntry: + resource: str + task: str + status: str + reason: str + + +@dataclass(frozen=True) +class SupportReport: + bundle_name: str + entries: tuple[SupportEntry, ...] + config_only_resources: int + + @property + def local_count(self) -> int: + return sum(entry.status == "local" for entry in self.entries) + + @property + def cloud_count(self) -> int: + return sum(entry.status == "cloud" for entry in self.entries) + + @property + def config_count(self) -> int: + return self.config_only_resources + sum(entry.status == "config" for entry in self.entries) + + @property + def error_count(self) -> int: + return sum(entry.status == "error" for entry in self.entries) + + +def inspect_local_support(root: Path) -> SupportReport: + config = load_bundle_config(root) + resources = config.get("resources", {}) or {} + entries: list[SupportEntry] = [] + for job_name, job in (resources.get("jobs", {}) or {}).items(): + tasks = job.get("tasks", []) or [] + if not tasks: + entries.append(SupportEntry(f"jobs.{job_name}", "-", "config", "no executable tasks declared")) + continue + for task in tasks: + entries.append(_inspect_task(root, job_name, task)) + + config_only = sum( + len(declared or {}) for kind, declared in resources.items() if kind != "jobs" and isinstance(declared, dict) + ) + return SupportReport( + bundle_name=(config.get("bundle", {}) or {}).get("name", root.name), + entries=tuple(entries), + config_only_resources=config_only, + ) + + +def _inspect_task(root: Path, job_name: str, task: dict[str, Any]) -> SupportEntry: + resource = f"jobs.{job_name}" + task_key = task.get("task_key", "-") + sql_task = task.get("sql_task") + if not isinstance(sql_task, dict): + kind = next((key for key in task if key.endswith("_task")), "unknown task") + return SupportEntry(resource, task_key, "cloud", f"{kind} requires a Databricks workspace") + + file = sql_task.get("file") + if not isinstance(file, dict) or not file.get("path"): + return SupportEntry(resource, task_key, "cloud", "sql_task does not reference a local file") + relative = str(file["path"]) + if _VAR_REF.search(relative): + return SupportEntry(resource, task_key, "cloud", f"SQL path contains online reference {relative!r}") + source = root / relative + if not source.is_file(): + return SupportEntry(resource, task_key, "error", f"SQL file does not exist: {relative}") + + sql = source.read_text() + reserved = next( + ( + match.group(1) + for match in _QUALIFIED.finditer(sql) + if match.group(3) and match.group(1) in _RESERVED_CATALOGS + ), + None, + ) + if reserved: + return SupportEntry(resource, task_key, "cloud", f"catalog {reserved!r} is reserved by DuckDB") + return SupportEntry(resource, task_key, "local", f"runs {relative}") + + +def format_support_report(root: Path, report: SupportReport) -> str: + lines = [f"bundletest local support: {report.bundle_name}", f"bundle: {root}", ""] + labels = {"local": "LOCAL", "cloud": "CLOUD", "config": "CONFIG", "error": "ERROR"} + for entry in report.entries: + target = entry.resource if entry.task == "-" else f"{entry.resource}/{entry.task}" + lines.append(f"[{labels[entry.status]:6}] {target}: {entry.reason}") + if report.config_only_resources: + lines.append(f"[CONFIG] {report.config_only_resources} non-job resources: configuration assertions only") + summary = f"summary: {report.local_count} local, {report.cloud_count} cloud-only, {report.config_count} config-only" + if report.error_count: + suffix = "error" if report.error_count == 1 else "errors" + summary += f", {report.error_count} {suffix}" + lines.extend(("", summary)) + return "\n".join(lines) diff --git a/experimental/bundletest/tests/test_assertions.py b/experimental/bundletest/tests/test_assertions.py index 8dbf69025d7..c8dc53b5dd0 100644 --- a/experimental/bundletest/tests/test_assertions.py +++ b/experimental/bundletest/tests/test_assertions.py @@ -71,7 +71,15 @@ def test_wrong_table_name_fails_red(tmp_path): "CREATE OR REPLACE TABLE app.gold.out AS SELECT * FROM app.bronze.does_not_exist;", ) with bundle_env(str(tmp_path), backend="local") as env: - # This job intentionally fails, so opt out of the raise-by-default and inspect it. + with pytest.raises(JobRunFailed) as failure: + env.run_job("j") + message = str(failure.value) + assert "bundle job 'j' failed" in message + assert "task: t" in message + assert "source: job.sql" in message + assert "backend: local" in message + assert "does_not_exist" in message + result = env.run_job("j", check=False) assert not result.succeeded assert "does_not_exist" in result.error diff --git a/experimental/bundletest/tests/test_cli.py b/experimental/bundletest/tests/test_cli.py new file mode 100644 index 00000000000..a1066c7402d --- /dev/null +++ b/experimental/bundletest/tests/test_cli.py @@ -0,0 +1,108 @@ +from pathlib import Path + +import pytest +from bundletest.cli import main + + +def _write_bundle(root: Path) -> None: + (root / "tests").mkdir() + (root / "databricks.yml").write_text("bundle:\n name: demo\nresources: {}\n") + + +def test_run_uses_local_backend_and_default_test_directory(tmp_path, monkeypatch): + _write_bundle(tmp_path) + captured = [] + original = Path.cwd() + + def run_pytest(args): + assert Path.cwd() == tmp_path + captured.extend(args) + return 0 + + monkeypatch.setattr(pytest, "main", run_pytest) + + assert main(["--bundle", str(tmp_path), "--local", "-q"]) == 0 + + assert captured == ["-q", str(tmp_path / "tests")] + assert Path.cwd() == original + + +def test_pytest_option_value_is_not_mistaken_for_test_path(tmp_path, monkeypatch): + _write_bundle(tmp_path) + captured = [] + monkeypatch.setattr(pytest, "main", lambda args: captured.extend(args) or 0) + + assert main(["--bundle", str(tmp_path), "-p", "no:cacheprovider"]) == 0 + + assert captured == ["-p", "no:cacheprovider", str(tmp_path / "tests")] + + +def test_cloud_requires_explicit_profile(tmp_path, capsys): + _write_bundle(tmp_path) + + assert main(["--bundle", str(tmp_path), "--cloud"]) == 2 + + assert "--cloud requires an explicit --profile" in capsys.readouterr().err + + +def test_invalid_bundle_yaml_has_concise_error(tmp_path, capsys): + (tmp_path / "databricks.yml").write_text("resources: [") + + assert main(["--bundle", str(tmp_path)]) == 2 + + error = capsys.readouterr().err + assert "bundletest: offline bundle resolution failed" in error + assert "Traceback" not in error + + +def test_local_rejects_cloud_options(tmp_path, capsys): + _write_bundle(tmp_path) + + assert main(["--bundle", str(tmp_path), "--local", "--profile", "dev"]) == 2 + + assert "--profile can only be used with --cloud" in capsys.readouterr().err + + +def test_support_only_does_not_invoke_pytest(tmp_path, monkeypatch): + _write_bundle(tmp_path) + monkeypatch.setattr(pytest, "main", lambda args: pytest.fail("pytest should not run")) + + assert main(["--bundle", str(tmp_path), "--support-only"]) == 0 + + +@pytest.mark.parametrize( + ("arguments", "message"), + [ + (["--base", "origin/main"], "--base requires --changed"), + ( + ["--support-only", "--no-support-report"], + "--support-only cannot be used with --no-support-report", + ), + ], +) +def test_rejects_incompatible_options(tmp_path, capsys, arguments, message): + _write_bundle(tmp_path) + + assert main(["--bundle", str(tmp_path), *arguments]) == 2 + + assert message in capsys.readouterr().err + + +def test_init_generates_a_starter(tmp_path, capsys): + _write_bundle(tmp_path) + (tmp_path / "databricks.yml").write_text( + """resources: + jobs: + transform: + tasks: + - task_key: transform + sql_task: + file: + path: transform.sql +""" + ) + + assert main(["init", str(tmp_path)]) == 0 + + assert (tmp_path / "tests" / "test_bundle.py").is_file() + assert "next: bundletest --local" in capsys.readouterr().out diff --git a/experimental/bundletest/tests/test_pytest_plugin.py b/experimental/bundletest/tests/test_pytest_plugin.py new file mode 100644 index 00000000000..887159214bb --- /dev/null +++ b/experimental/bundletest/tests/test_pytest_plugin.py @@ -0,0 +1,24 @@ +pytest_plugins = ["pytester"] + + +def test_resource_filter_deselects_unaffected_tests(pytester): + pytester.makepyfile( + """ + import pytest + + @pytest.mark.bundle_resource("jobs.transform") + def test_transform(): + pass + + @pytest.mark.bundle_resource("jobs.aggregate") + def test_aggregate(): + pass + + def test_unmarked(): + pass + """ + ) + + result = pytester.runpytest("--bundletest-resource=jobs.transform", "-q") + + result.assert_outcomes(passed=1, deselected=2) diff --git a/experimental/bundletest/tests/test_scaffold.py b/experimental/bundletest/tests/test_scaffold.py new file mode 100644 index 00000000000..26a0b957e18 --- /dev/null +++ b/experimental/bundletest/tests/test_scaffold.py @@ -0,0 +1,38 @@ +from pathlib import Path + +import pytest +from bundletest.scaffold import create_starter + + +def _write_bundle(root: Path) -> None: + (root / "databricks.yml").write_text( + """resources: + jobs: + transform_orders: + tasks: + - task_key: transform + sql_task: + file: + path: src/transform.sql +""" + ) + + +def test_create_starter_generates_fixture_and_marked_test(tmp_path): + _write_bundle(tmp_path) + + generated = create_starter(tmp_path) + + assert generated.resource == "jobs.transform_orders" + assert (tmp_path / "tests" / "conftest.py").is_file() + test = (tmp_path / "tests" / "test_bundle.py").read_text() + assert 'pytest.mark.bundle_resource("jobs.transform_orders")' in test + assert 'env.jobs["transform_orders"]' in test + + +def test_create_starter_does_not_overwrite_tests_by_default(tmp_path): + _write_bundle(tmp_path) + create_starter(tmp_path) + + with pytest.raises(FileExistsError, match="--force"): + create_starter(tmp_path) diff --git a/experimental/bundletest/tests/test_selection.py b/experimental/bundletest/tests/test_selection.py new file mode 100644 index 00000000000..816c26a2080 --- /dev/null +++ b/experimental/bundletest/tests/test_selection.py @@ -0,0 +1,80 @@ +from pathlib import Path + +from bundletest.selection import format_change_selection, select_changes + + +def _write_bundle(root: Path) -> None: + (root / "databricks.yml").write_text( + """resources: + jobs: + transform: + tasks: + - task_key: transform + sql_task: + file: + path: src/transform.sql + aggregate: + tasks: + - task_key: aggregate + sql_task: + file: + path: src/aggregate.sql + dashboards: + overview: + file_path: dashboards/overview.json +""" + ) + + +def test_changed_source_selects_referencing_resource(tmp_path): + _write_bundle(tmp_path) + + selection = select_changes(tmp_path, ["src/transform.sql"]) + + assert selection.resources == ("jobs.transform",) + assert not selection.run_all + assert "[RESOURCE] jobs.transform" in format_change_selection(selection, "main") + + +def test_changed_directory_selects_resource(tmp_path): + _write_bundle(tmp_path) + (tmp_path / "app").mkdir() + config = (tmp_path / "databricks.yml").read_text() + (tmp_path / "databricks.yml").write_text(config + " apps:\n portal:\n source_code_path: app\n") + + selection = select_changes(tmp_path, ["app/server.py"]) + + assert selection.resources == ("apps.portal",) + + +def test_changed_test_is_selected_directly(tmp_path): + _write_bundle(tmp_path) + test = tmp_path / "tests" / "test_transform.py" + + selection = select_changes(tmp_path, ["tests/test_transform.py"]) + + assert selection.test_files == (test.resolve().as_posix(),) + + +def test_changed_yaml_runs_complete_suite(tmp_path): + _write_bundle(tmp_path) + + selection = select_changes(tmp_path, ["resources/jobs.yml"]) + + assert selection.run_all + assert set(selection.resources) == {"jobs.transform", "jobs.aggregate", "dashboards.overview"} + + +def test_changed_source_resolves_resources_from_included_files(tmp_path): + (tmp_path / "databricks.yml").write_text("bundle:\n name: demo\ninclude:\n - resources/*.yml\n") + resources = tmp_path / "resources" + resources.mkdir() + (resources / "jobs.yml").write_text( + "resources:\n jobs:\n transform:\n tasks:\n" + " - task_key: transform\n sql_task:\n" + " file:\n path: src/transform.sql\n" + ) + + selection = select_changes(tmp_path, ["src/transform.sql"]) + + assert selection.resources == ("jobs.transform",) diff --git a/experimental/bundletest/tests/test_support.py b/experimental/bundletest/tests/test_support.py new file mode 100644 index 00000000000..9996ffa0ae6 --- /dev/null +++ b/experimental/bundletest/tests/test_support.py @@ -0,0 +1,83 @@ +from pathlib import Path + +from bundletest.support import format_support_report, inspect_local_support + + +def _write_bundle(root: Path) -> None: + (root / "src").mkdir() + (root / "src" / "local.sql").write_text("SELECT * FROM shop.bronze.orders") + (root / "src" / "cloud.sql").write_text("SELECT * FROM main.bronze.orders") + (root / "databricks.yml").write_text( + """bundle: + name: demo +resources: + jobs: + local_job: + tasks: + - task_key: local + sql_task: + file: + path: src/local.sql + cloud_job: + tasks: + - task_key: cloud + sql_task: + file: + path: src/cloud.sql + notebook_job: + tasks: + - task_key: notebook + notebook_task: + notebook_path: src/notebook.py + dashboards: + overview: + file_path: dashboard.json +""" + ) + + +def test_support_report_classifies_local_and_cloud_tasks(tmp_path): + _write_bundle(tmp_path) + + report = inspect_local_support(tmp_path) + + assert report.bundle_name == "demo" + assert [(entry.resource, entry.status) for entry in report.entries] == [ + ("jobs.cloud_job", "cloud"), + ("jobs.local_job", "local"), + ("jobs.notebook_job", "cloud"), + ] + assert report.config_only_resources == 1 + rendered = format_support_report(tmp_path, report) + assert "[LOCAL ] jobs.local_job/local: runs src/local.sql" in rendered + assert "catalog 'main' is reserved by DuckDB" in rendered + assert "summary: 1 local, 2 cloud-only, 1 config-only" in rendered + + +def test_support_report_surfaces_missing_sql_file(tmp_path): + (tmp_path / "databricks.yml").write_text( + """resources: + jobs: + broken: + tasks: + - task_key: missing + sql_task: + file: + path: src/missing.sql +""" + ) + + report = inspect_local_support(tmp_path) + + assert report.entries[0].status == "error" + assert "does not exist" in report.entries[0].reason + assert "summary: 0 local, 0 cloud-only, 0 config-only, 1 error" in format_support_report(tmp_path, report) + + +def test_support_report_counts_job_without_tasks_as_config_only(tmp_path): + (tmp_path / "databricks.yml").write_text("resources:\n jobs:\n empty: {}\n") + + report = inspect_local_support(tmp_path) + + assert report.config_count == 1 + assert "summary: 0 local, 0 cloud-only, 1 config-only" in format_support_report(tmp_path, report) diff --git a/experimental/bundletest/tests/test_variables.py b/experimental/bundletest/tests/test_variables.py index 1c2f218277a..39d6bce7d39 100644 --- a/experimental/bundletest/tests/test_variables.py +++ b/experimental/bundletest/tests/test_variables.py @@ -87,13 +87,11 @@ def test_included_file_is_resolved(tmp_path): # A resource defined in an included file, referencing a variable. The old single-file # resolver never read includes, so it couldn't see this job at all. (tmp_path / "databricks.yml").write_text( - "bundle:\n name: b\ninclude:\n - resources/*.yml\n" - "variables:\n catalog:\n default: shop\n" + "bundle:\n name: b\ninclude:\n - resources/*.yml\nvariables:\n catalog:\n default: shop\n" ) (tmp_path / "resources").mkdir() (tmp_path / "resources" / "jobs.yml").write_text( - "resources:\n jobs:\n j:\n name: ${var.catalog}_job\n" - " tasks:\n - task_key: t\n" + "resources:\n jobs:\n j:\n name: ${var.catalog}_job\n tasks:\n - task_key: t\n" ) with bundle_env(str(tmp_path)) as env: assert env.backend.get_resource("jobs", "j")["name"] == "shop_job"