From 161095c25c940410f9dadbf2253dfbcdb2af89ad Mon Sep 17 00:00:00 2001 From: Sankalp-Mittal Date: Fri, 28 Aug 2026 11:22:34 +0000 Subject: [PATCH 01/12] Test the dataclass path in test_add_resource_type test_add_resource_type and test_add_resource_type_dict had byte-identical bodies, both feeding dict_example, so the add_(dataclass) normalization path went untested for the parametrized resources. Feed dataclass_example to the non-_dict variant, mirroring test_add_job vs test_add_job_dict. Co-authored-by: Isaac --- python/databricks_tests/core/test_resources.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/databricks_tests/core/test_resources.py b/python/databricks_tests/core/test_resources.py index ee2ab7ec405..e27b4331db2 100644 --- a/python/databricks_tests/core/test_resources.py +++ b/python/databricks_tests/core/test_resources.py @@ -163,7 +163,7 @@ def test_add_resource_type(tc: TestCase, tpe: _ResourceType): resources, **{ "resource_name": "my_resource", - tpe.singular_name: tc.dict_example, + tpe.singular_name: tc.dataclass_example, }, ) From a09b78e878eddda84b66ebfbc7b452518377325b Mon Sep 17 00:00:00 2001 From: Sankalp-Mittal Date: Fri, 28 Aug 2026 11:22:56 +0000 Subject: [PATCH 02/12] Generate per-resource unit-test cases from the codegen model Stop hand-writing a TestCase per resource in test_resources.py. A new codegen step (generated_test_cases.py, rendered from test_case.py.tmpl) synthesizes dict_example and dataclass_example for every wired resource from the schema model and writes one file per resource under databricks_tests/core/_generated/, collected into test_cases. A newly wired resource now gets its unit-test coverage for free. dict_example and dataclass_example are rendered two independent ways from one synthesized value tree, so the dict->dataclass _transform assertion stays meaningful. Field policy: required fields fully expanded, plus optional composite fields on the resource itself; nested objects contribute only their required fields, which bounds example size and avoids the recursive Task/ForEachTask schema. Optional scalar, deprecated, and private-preview fields are omitted. The hand-written TestCase dataclass moves to _resource_test_case.py so the generated modules can import it without a cycle. Co-authored-by: Isaac --- python/Taskfile.yml | 2 + .../codegen/codegen/generated_test_cases.py | 278 ++++++++++++++++++ python/codegen/codegen/main.py | 4 + python/codegen/codegen/test_case.py.tmpl | 16 + python/databricks_tests/.gitattributes | 4 + .../core/_generated/__init__.py | 21 ++ .../core/_generated/alerts.py | 58 ++++ .../core/_generated/catalogs.py | 35 +++ .../databricks_tests/core/_generated/jobs.py | 92 ++++++ .../core/_generated/pipelines.py | 65 ++++ .../core/_generated/schemas.py | 32 ++ .../core/_generated/volumes.py | 35 +++ .../core/_resource_test_case.py | 12 + .../databricks_tests/core/test_resources.py | 129 +------- 14 files changed, 659 insertions(+), 124 deletions(-) create mode 100644 python/codegen/codegen/generated_test_cases.py create mode 100644 python/codegen/codegen/test_case.py.tmpl create mode 100644 python/databricks_tests/.gitattributes create mode 100644 python/databricks_tests/core/_generated/__init__.py create mode 100644 python/databricks_tests/core/_generated/alerts.py create mode 100644 python/databricks_tests/core/_generated/catalogs.py create mode 100644 python/databricks_tests/core/_generated/jobs.py create mode 100644 python/databricks_tests/core/_generated/pipelines.py create mode 100644 python/databricks_tests/core/_generated/schemas.py create mode 100644 python/databricks_tests/core/_generated/volumes.py create mode 100644 python/databricks_tests/core/_resource_test_case.py diff --git a/python/Taskfile.yml b/python/Taskfile.yml index 602e92028a5..49621efd6a6 100644 --- a/python/Taskfile.yml +++ b/python/Taskfile.yml @@ -78,6 +78,8 @@ tasks: -exec rm -rf {} \; # core/ is hand-written except for the generated wiring under _generated/. - rm -rf databricks/bundles/core/_generated + # test_resources.py is hand-written except for the generated TestCase data. + - rm -rf databricks_tests/core/_generated - cd codegen && uv run -m pytest codegen_tests - cd codegen && uv run -m codegen.main --output .. # Generated code is fixed and formatted by the global ruff (see ../ruff.toml). diff --git a/python/codegen/codegen/generated_test_cases.py b/python/codegen/codegen/generated_test_cases.py new file mode 100644 index 00000000000..7decc29a897 --- /dev/null +++ b/python/codegen/codegen/generated_test_cases.py @@ -0,0 +1,278 @@ +""" +Generates the per-resource TestCase data driving databricks_tests/core/test_resources.py. + +For every wired resource a file _generated/.py is written (rendered from +test_case.py.tmpl) exposing _test_case() -> (TestCase, _ResourceType). The generated +_generated/__init__.py collects them into `test_cases`, which test_resources.py imports +and parametrizes its per-resource tests off. + +dict_example and dataclass_example are synthesized from one value tree and rendered two +independent ways -- a dict literal and a constructor expression -- so the dict->dataclass +_transform assertion in test_resources.py stays meaningful (the two forms don't share the +runtime transform path). + +Field policy: all required fields (fully expanded), plus optional composite fields +(nested dataclass / list / map / enum) on the resource itself; nested objects contribute +only their required fields, which keeps examples bounded and avoids recursive schemas +(e.g. jobs Task -> ForEachTask -> Task, reachable only through an optional field). Optional +scalar, deprecated, and experimental fields are omitted. +""" + +from dataclasses import dataclass +from pathlib import Path +from string import Template +from typing import Union + +import codegen.jsonschema as openapi +import codegen.packages as packages +from codegen.generated_enum import _camel_to_upper_snake +from codegen.generated_wiring import _WiredResource, _wired_resources + +HEADER = "# Code generated by pydabs-codegen. DO NOT EDIT.\n\n" + +_TEST_CASE_TEMPLATE = Template( + (Path(__file__).parent / "test_case.py.tmpl").read_text() +) + + +# Synthesized value tree. Each node renders both as a dict literal (dict_example) +# and as a constructor expression (dataclass_example). + + +@dataclass +class _Scalar: + dict_src: str + dataclass_src: str + + +@dataclass +class _Enum: + value: str + class_name: str + module: str + member: str + + +@dataclass +class _Object: + class_name: str + module: str + fields: "list[tuple[str, _Value]]" + + +@dataclass +class _List: + item: "_Value" + + +@dataclass +class _Map: + key: str + value: "_Value" + + +_Value = Union[_Scalar, _Enum, _Object, _List, _Map] + + +def _ref_name(ref: str) -> str: + return ref.split("/")[-1] + + +def _is_composite(ref: str) -> bool: + if ref.startswith("#/$defs/slice/") or ref.startswith("#/$defs/map/"): + return True + + return _ref_name(ref) not in packages.PRIMITIVES + + +def _synth_scalar(name: str, hint: str) -> _Scalar: + if name == "string": + return _Scalar(f'"{hint}"', f'"{hint}"') + if name in ("integer", "int", "int64"): + return _Scalar("0", "0") + if name in ("number", "float", "float64"): + return _Scalar("0.0", "0.0") + if name in ("boolean", "bool"): + return _Scalar("True", "True") + + raise ValueError(f"Unknown primitive: {name}") + + +def _synth_ref( + namespace: str, + ref: str, + hint: str, + schemas: dict[str, openapi.Schema], + visiting: set[str], +) -> _Value: + if ref.startswith("#/$defs/slice/"): + element_ref = ref.replace("#/$defs/slice/", "#/$defs/") + + return _List(_synth_ref(namespace, element_ref, hint, schemas, visiting)) + + if ref.startswith("#/$defs/map/"): + # generate_type only ever produces dict[str, str] maps (map/string). + if ref != "#/$defs/map/string": + raise ValueError(f"Unsupported map ref: {ref}") + + return _Map("key", _Scalar('"value"', '"value"')) + + name = _ref_name(ref) + if name in packages.PRIMITIVES: + return _synth_scalar(name, hint) + + schema = schemas[name] + class_name = packages.get_class_name(ref) + module = packages.get_package(namespace, ref) + assert module + + if schema.type == openapi.SchemaType.STRING: + value = schema.enum[0] + + return _Enum(value, class_name, module, _camel_to_upper_snake(value)) + + # Only reachable through required fields at this depth (see _synth_object); a + # required cycle has no finite value, so fail loudly instead of looping. + if name in visiting: + raise ValueError(f"Required-field cycle through '{name}'") + + return _synth_object(namespace, name, schema, schemas, visiting, top_level=False) + + +def _synth_object( + namespace: str, + schema_name: str, + schema: openapi.Schema, + schemas: dict[str, openapi.Schema], + visiting: set[str], + top_level: bool, +) -> _Object: + visiting = visiting | {schema_name} + fields: list[tuple[str, _Value]] = [] + + for field_name, prop in schema.properties.items(): + required = field_name in schema.required + + if not required: + # Nested objects contribute only required fields; on the resource + # itself, also include stable optional composite fields. + if not top_level: + continue + if not _is_composite(prop.ref): + continue + if prop.deprecated or prop.stage == openapi.LaunchStage.PRIVATE_PREVIEW: + continue + + value = _synth_ref(namespace, prop.ref, field_name, schemas, visiting) + fields.append((field_name, value)) + + return _Object( + packages.get_class_name(schema_name), _module_of(namespace, schema_name), fields + ) + + +def _module_of(namespace: str, schema_name: str) -> str: + module = packages.get_package(namespace, schema_name) + assert module + + return module + + +def _render_dict(value: _Value) -> str: + if isinstance(value, _Scalar): + return value.dict_src + if isinstance(value, _Enum): + return f'"{value.value}"' + if isinstance(value, _List): + return f"[{_render_dict(value.item)}]" + if isinstance(value, _Map): + return "{" + f'"{value.key}": {_render_dict(value.value)}' + "}" + + fields = ", ".join( + f'"{name}": {_render_dict(child)}' for name, child in value.fields + ) + + return "{" + fields + "}" + + +def _render_dataclass(value: _Value) -> str: + if isinstance(value, _Scalar): + return value.dataclass_src + if isinstance(value, _Enum): + return f"{value.class_name}.{value.member}" + if isinstance(value, _List): + return f"[{_render_dataclass(value.item)}]" + if isinstance(value, _Map): + return "{" + f'"{value.key}": {_render_dataclass(value.value)}' + "}" + + fields = ", ".join( + f"{name}={_render_dataclass(child)}" for name, child in value.fields + ) + + return f"{value.class_name}({fields})" + + +def _collect_imports(value: _Value, out: set[tuple[str, str]]) -> None: + if isinstance(value, _Enum): + out.add((value.module, value.class_name)) + elif isinstance(value, _Object): + out.add((value.module, value.class_name)) + for _, child in value.fields: + _collect_imports(child, out) + elif isinstance(value, _List): + _collect_imports(value.item, out) + elif isinstance(value, _Map): + _collect_imports(value.value, out) + + +def write_test_cases(output: str, schemas: dict[str, openapi.Schema]): + resources = _wired_resources() + + generated_path = Path(output) / "databricks_tests" / "core" / "_generated" + generated_path.mkdir(parents=True, exist_ok=True) + + plural_to_ref = {ns: ref for ref, ns in packages.RESOURCE_NAMESPACE.items()} + + for r in resources: + resource_ref = plural_to_ref[r.plural_name] + schema = schemas[resource_ref] + + example = _synth_object( + r.plural_name, resource_ref, schema, schemas, set(), top_level=True + ) + + imports: set[tuple[str, str]] = set() + _collect_imports(example, imports) + model_imports = "\n".join( + f"from {module} import {class_name}" + for module, class_name in sorted(imports) + ) + + code = _TEST_CASE_TEMPLATE.substitute( + singular=r.singular_name, + plural=r.plural_name, + model_imports=model_imports, + dict_example=_render_dict(example), + dataclass_example=_render_dataclass(example), + ) + (generated_path / f"{r.plural_name}.py").write_text(HEADER + code) + + (generated_path / "__init__.py").write_text(HEADER + _collector_code(resources)) + + print(f"Writing test cases into {generated_path}") + + +def _collector_code(resources: list[_WiredResource]) -> str: + module_imports = "\n".join(f" {r.plural_name}," for r in resources) + entries = "\n".join(f" {r.plural_name}._test_case()," for r in resources) + + return f"""from databricks_tests.core._generated import ( +{module_imports} +) + +__all__ = ["test_cases"] + +test_cases = [ +{entries} +] +""" diff --git a/python/codegen/codegen/main.py b/python/codegen/codegen/main.py index 7927da85961..924ec269e88 100644 --- a/python/codegen/codegen/main.py +++ b/python/codegen/codegen/main.py @@ -8,6 +8,7 @@ import codegen.generated_dataclass_patch as generated_dataclass_patch import codegen.generated_enum as generated_enum import codegen.generated_imports as generated_imports +import codegen.generated_test_cases as generated_test_cases import codegen.generated_wiring as generated_wiring import codegen.jsonschema as openapi import codegen.jsonschema_patch as openapi_patch @@ -52,6 +53,9 @@ def main(output: str): # decorators, and the core package __init__). generated_wiring.write_wiring(output) + # Generate the per-resource TestCase data driving test_resources.py. + generated_test_cases.write_test_cases(output, schemas) + def _transitively_mark_deprecated_and_private( roots: list[str], diff --git a/python/codegen/codegen/test_case.py.tmpl b/python/codegen/codegen/test_case.py.tmpl new file mode 100644 index 00000000000..8abe9d1ce5e --- /dev/null +++ b/python/codegen/codegen/test_case.py.tmpl @@ -0,0 +1,16 @@ +from databricks.bundles.core import Resources, ${singular}_mutator +from databricks.bundles.core._generated.${plural} import _resource_type +from databricks_tests.core._resource_test_case import TestCase +$model_imports + + +def _test_case(): + return ( + TestCase( + add_resource=Resources.add_${singular}, + dict_example=$dict_example, + dataclass_example=$dataclass_example, + mutator=${singular}_mutator, + ), + _resource_type(), + ) diff --git a/python/databricks_tests/.gitattributes b/python/databricks_tests/.gitattributes new file mode 100644 index 00000000000..810eb0c20ec --- /dev/null +++ b/python/databricks_tests/.gitattributes @@ -0,0 +1,4 @@ +# Generated by pydabs-codegen (see python/codegen). The per-resource TestCase +# data under core/_generated/ drives the parametrized tests in test_resources.py; +# the rest of databricks_tests/ is hand-written. +core/_generated/** linguist-generated=true diff --git a/python/databricks_tests/core/_generated/__init__.py b/python/databricks_tests/core/_generated/__init__.py new file mode 100644 index 00000000000..9cf2cc18cee --- /dev/null +++ b/python/databricks_tests/core/_generated/__init__.py @@ -0,0 +1,21 @@ +# Code generated by pydabs-codegen. DO NOT EDIT. + +from databricks_tests.core._generated import ( + alerts, + catalogs, + jobs, + pipelines, + schemas, + volumes, +) + +__all__ = ["test_cases"] + +test_cases = [ + alerts._test_case(), + catalogs._test_case(), + jobs._test_case(), + pipelines._test_case(), + schemas._test_case(), + volumes._test_case(), +] diff --git a/python/databricks_tests/core/_generated/alerts.py b/python/databricks_tests/core/_generated/alerts.py new file mode 100644 index 00000000000..ec85f6daeae --- /dev/null +++ b/python/databricks_tests/core/_generated/alerts.py @@ -0,0 +1,58 @@ +# Code generated by pydabs-codegen. DO NOT EDIT. + +from databricks.bundles.alerts._models.alert import Alert +from databricks.bundles.alerts._models.alert_v2_evaluation import AlertV2Evaluation +from databricks.bundles.alerts._models.alert_v2_operand_column import ( + AlertV2OperandColumn, +) +from databricks.bundles.alerts._models.alert_v2_run_as import AlertV2RunAs +from databricks.bundles.alerts._models.comparison_operator import ComparisonOperator +from databricks.bundles.alerts._models.cron_schedule import CronSchedule +from databricks.bundles.alerts._models.lifecycle import Lifecycle +from databricks.bundles.alerts._models.permission import Permission +from databricks.bundles.alerts._models.permission_level import PermissionLevel +from databricks.bundles.core import Resources, alert_mutator +from databricks.bundles.core._generated.alerts import _resource_type +from databricks_tests.core._resource_test_case import TestCase + + +def _test_case(): + return ( + TestCase( + add_resource=Resources.add_alert, + dict_example={ + "display_name": "display_name", + "evaluation": { + "comparison_operator": "LESS_THAN", + "source": {"name": "name"}, + }, + "lifecycle": {}, + "permissions": [{"level": "CAN_MANAGE"}], + "query_text": "query_text", + "run_as": {}, + "schedule": { + "quartz_cron_schedule": "quartz_cron_schedule", + "timezone_id": "timezone_id", + }, + "warehouse_id": "warehouse_id", + }, + dataclass_example=Alert( + display_name="display_name", + evaluation=AlertV2Evaluation( + comparison_operator=ComparisonOperator.LESS_THAN, + source=AlertV2OperandColumn(name="name"), + ), + lifecycle=Lifecycle(), + permissions=[Permission(level=PermissionLevel.CAN_MANAGE)], + query_text="query_text", + run_as=AlertV2RunAs(), + schedule=CronSchedule( + quartz_cron_schedule="quartz_cron_schedule", + timezone_id="timezone_id", + ), + warehouse_id="warehouse_id", + ), + mutator=alert_mutator, + ), + _resource_type(), + ) diff --git a/python/databricks_tests/core/_generated/catalogs.py b/python/databricks_tests/core/_generated/catalogs.py new file mode 100644 index 00000000000..177ada20428 --- /dev/null +++ b/python/databricks_tests/core/_generated/catalogs.py @@ -0,0 +1,35 @@ +# Code generated by pydabs-codegen. DO NOT EDIT. + +from databricks.bundles.catalogs._models.catalog import Catalog +from databricks.bundles.catalogs._models.encryption_settings import EncryptionSettings +from databricks.bundles.catalogs._models.lifecycle import Lifecycle +from databricks.bundles.catalogs._models.privilege_assignment import PrivilegeAssignment +from databricks.bundles.core import Resources, catalog_mutator +from databricks.bundles.core._generated.catalogs import _resource_type +from databricks_tests.core._resource_test_case import TestCase + + +def _test_case(): + return ( + TestCase( + add_resource=Resources.add_catalog, + dict_example={ + "grants": [{}], + "lifecycle": {}, + "managed_encryption_settings": {}, + "name": "name", + "options": {"key": "value"}, + "properties": {"key": "value"}, + }, + dataclass_example=Catalog( + grants=[PrivilegeAssignment()], + lifecycle=Lifecycle(), + managed_encryption_settings=EncryptionSettings(), + name="name", + options={"key": "value"}, + properties={"key": "value"}, + ), + mutator=catalog_mutator, + ), + _resource_type(), + ) diff --git a/python/databricks_tests/core/_generated/jobs.py b/python/databricks_tests/core/_generated/jobs.py new file mode 100644 index 00000000000..3a9dd6cec6d --- /dev/null +++ b/python/databricks_tests/core/_generated/jobs.py @@ -0,0 +1,92 @@ +# Code generated by pydabs-codegen. DO NOT EDIT. + +from databricks.bundles.core import Resources, job_mutator +from databricks.bundles.core._generated.jobs import _resource_type +from databricks.bundles.jobs._models.continuous import Continuous +from databricks.bundles.jobs._models.cron_schedule import CronSchedule +from databricks.bundles.jobs._models.git_provider import GitProvider +from databricks.bundles.jobs._models.git_source import GitSource +from databricks.bundles.jobs._models.job import Job +from databricks.bundles.jobs._models.job_cluster import JobCluster +from databricks.bundles.jobs._models.job_email_notifications import ( + JobEmailNotifications, +) +from databricks.bundles.jobs._models.job_environment import JobEnvironment +from databricks.bundles.jobs._models.job_notification_settings import ( + JobNotificationSettings, +) +from databricks.bundles.jobs._models.job_parameter_definition import ( + JobParameterDefinition, +) +from databricks.bundles.jobs._models.job_permission import JobPermission +from databricks.bundles.jobs._models.job_permission_level import JobPermissionLevel +from databricks.bundles.jobs._models.job_run_as import JobRunAs +from databricks.bundles.jobs._models.jobs_health_rules import JobsHealthRules +from databricks.bundles.jobs._models.lifecycle import Lifecycle +from databricks.bundles.jobs._models.performance_target import PerformanceTarget +from databricks.bundles.jobs._models.queue_settings import QueueSettings +from databricks.bundles.jobs._models.task import Task +from databricks.bundles.jobs._models.trigger_configuration import TriggerConfiguration +from databricks.bundles.jobs._models.trigger_settings import TriggerSettings +from databricks.bundles.jobs._models.webhook_notifications import WebhookNotifications +from databricks_tests.core._resource_test_case import TestCase + + +def _test_case(): + return ( + TestCase( + add_resource=Resources.add_job, + dict_example={ + "continuous": {}, + "email_notifications": {}, + "environments": [{"environment_key": "environment_key"}], + "git_source": {"git_provider": "gitHub", "git_url": "git_url"}, + "health": {}, + "job_clusters": [{"job_cluster_key": "job_cluster_key"}], + "lifecycle": {}, + "notification_settings": {}, + "parameters": [{"default": "default", "name": "name"}], + "performance_target": "PERFORMANCE_OPTIMIZED", + "permissions": [{"level": "CAN_MANAGE"}], + "queue": {"enabled": True}, + "run_as": {}, + "schedule": { + "quartz_cron_expression": "quartz_cron_expression", + "timezone_id": "timezone_id", + }, + "tags": {"key": "value"}, + "tasks": [{"task_key": "task_key"}], + "trigger": {}, + "triggers": [{}], + "webhook_notifications": {}, + }, + dataclass_example=Job( + continuous=Continuous(), + email_notifications=JobEmailNotifications(), + environments=[JobEnvironment(environment_key="environment_key")], + git_source=GitSource( + git_provider=GitProvider.GIT_HUB, git_url="git_url" + ), + health=JobsHealthRules(), + job_clusters=[JobCluster(job_cluster_key="job_cluster_key")], + lifecycle=Lifecycle(), + notification_settings=JobNotificationSettings(), + parameters=[JobParameterDefinition(default="default", name="name")], + performance_target=PerformanceTarget.PERFORMANCE_OPTIMIZED, + permissions=[JobPermission(level=JobPermissionLevel.CAN_MANAGE)], + queue=QueueSettings(enabled=True), + run_as=JobRunAs(), + schedule=CronSchedule( + quartz_cron_expression="quartz_cron_expression", + timezone_id="timezone_id", + ), + tags={"key": "value"}, + tasks=[Task(task_key="task_key")], + trigger=TriggerSettings(), + triggers=[TriggerConfiguration()], + webhook_notifications=WebhookNotifications(), + ), + mutator=job_mutator, + ), + _resource_type(), + ) diff --git a/python/databricks_tests/core/_generated/pipelines.py b/python/databricks_tests/core/_generated/pipelines.py new file mode 100644 index 00000000000..a4e65573317 --- /dev/null +++ b/python/databricks_tests/core/_generated/pipelines.py @@ -0,0 +1,65 @@ +# Code generated by pydabs-codegen. DO NOT EDIT. + +from databricks.bundles.core import Resources, pipeline_mutator +from databricks.bundles.core._generated.pipelines import _resource_type +from databricks.bundles.pipelines._models.event_log_spec import EventLogSpec +from databricks.bundles.pipelines._models.filters import Filters +from databricks.bundles.pipelines._models.ingestion_pipeline_definition import ( + IngestionPipelineDefinition, +) +from databricks.bundles.pipelines._models.lifecycle import Lifecycle +from databricks.bundles.pipelines._models.notifications import Notifications +from databricks.bundles.pipelines._models.pipeline import Pipeline +from databricks.bundles.pipelines._models.pipeline_cluster import PipelineCluster +from databricks.bundles.pipelines._models.pipeline_library import PipelineLibrary +from databricks.bundles.pipelines._models.pipeline_permission import PipelinePermission +from databricks.bundles.pipelines._models.pipeline_permission_level import ( + PipelinePermissionLevel, +) +from databricks.bundles.pipelines._models.pipelines_environment import ( + PipelinesEnvironment, +) +from databricks.bundles.pipelines._models.run_as import RunAs +from databricks_tests.core._resource_test_case import TestCase + + +def _test_case(): + return ( + TestCase( + add_resource=Resources.add_pipeline, + dict_example={ + "clusters": [{}], + "configuration": {"key": "value"}, + "environment": {}, + "event_log": {}, + "filters": {}, + "ingestion_definition": {}, + "libraries": [{}], + "lifecycle": {}, + "notifications": [{}], + "parameters": {"key": "value"}, + "permissions": [{"level": "CAN_MANAGE"}], + "run_as": {}, + "tags": {"key": "value"}, + }, + dataclass_example=Pipeline( + clusters=[PipelineCluster()], + configuration={"key": "value"}, + environment=PipelinesEnvironment(), + event_log=EventLogSpec(), + filters=Filters(), + ingestion_definition=IngestionPipelineDefinition(), + libraries=[PipelineLibrary()], + lifecycle=Lifecycle(), + notifications=[Notifications()], + parameters={"key": "value"}, + permissions=[ + PipelinePermission(level=PipelinePermissionLevel.CAN_MANAGE) + ], + run_as=RunAs(), + tags={"key": "value"}, + ), + mutator=pipeline_mutator, + ), + _resource_type(), + ) diff --git a/python/databricks_tests/core/_generated/schemas.py b/python/databricks_tests/core/_generated/schemas.py new file mode 100644 index 00000000000..49adceab523 --- /dev/null +++ b/python/databricks_tests/core/_generated/schemas.py @@ -0,0 +1,32 @@ +# Code generated by pydabs-codegen. DO NOT EDIT. + +from databricks.bundles.core import Resources, schema_mutator +from databricks.bundles.core._generated.schemas import _resource_type +from databricks.bundles.schemas._models.lifecycle import Lifecycle +from databricks.bundles.schemas._models.privilege_assignment import PrivilegeAssignment +from databricks.bundles.schemas._models.schema import Schema +from databricks_tests.core._resource_test_case import TestCase + + +def _test_case(): + return ( + TestCase( + add_resource=Resources.add_schema, + dict_example={ + "catalog_name": "catalog_name", + "grants": [{}], + "lifecycle": {}, + "name": "name", + "properties": {"key": "value"}, + }, + dataclass_example=Schema( + catalog_name="catalog_name", + grants=[PrivilegeAssignment()], + lifecycle=Lifecycle(), + name="name", + properties={"key": "value"}, + ), + mutator=schema_mutator, + ), + _resource_type(), + ) diff --git a/python/databricks_tests/core/_generated/volumes.py b/python/databricks_tests/core/_generated/volumes.py new file mode 100644 index 00000000000..bf8b434adec --- /dev/null +++ b/python/databricks_tests/core/_generated/volumes.py @@ -0,0 +1,35 @@ +# Code generated by pydabs-codegen. DO NOT EDIT. + +from databricks.bundles.core import Resources, volume_mutator +from databricks.bundles.core._generated.volumes import _resource_type +from databricks.bundles.volumes._models.lifecycle import Lifecycle +from databricks.bundles.volumes._models.privilege_assignment import PrivilegeAssignment +from databricks.bundles.volumes._models.volume import Volume +from databricks.bundles.volumes._models.volume_type import VolumeType +from databricks_tests.core._resource_test_case import TestCase + + +def _test_case(): + return ( + TestCase( + add_resource=Resources.add_volume, + dict_example={ + "catalog_name": "catalog_name", + "grants": [{}], + "lifecycle": {}, + "name": "name", + "schema_name": "schema_name", + "volume_type": "MANAGED", + }, + dataclass_example=Volume( + catalog_name="catalog_name", + grants=[PrivilegeAssignment()], + lifecycle=Lifecycle(), + name="name", + schema_name="schema_name", + volume_type=VolumeType.MANAGED, + ), + mutator=volume_mutator, + ), + _resource_type(), + ) diff --git a/python/databricks_tests/core/_resource_test_case.py b/python/databricks_tests/core/_resource_test_case.py new file mode 100644 index 00000000000..a9755e8e95c --- /dev/null +++ b/python/databricks_tests/core/_resource_test_case.py @@ -0,0 +1,12 @@ +from dataclasses import dataclass +from typing import Callable + +from databricks.bundles.core._resource import Resource + + +@dataclass(kw_only=True) +class TestCase: + add_resource: Callable + dict_example: dict + dataclass_example: Resource + mutator: Callable diff --git a/python/databricks_tests/core/test_resources.py b/python/databricks_tests/core/test_resources.py index e27b4331db2..ed50243d785 100644 --- a/python/databricks_tests/core/test_resources.py +++ b/python/databricks_tests/core/test_resources.py @@ -1,134 +1,15 @@ -from dataclasses import dataclass, replace -from typing import Callable +from dataclasses import replace import pytest -from databricks.bundles.alerts._models.alert import Alert -from databricks.bundles.alerts._models.alert_v2_evaluation import AlertV2Evaluation -from databricks.bundles.alerts._models.alert_v2_operand_column import ( - AlertV2OperandColumn, -) -from databricks.bundles.alerts._models.comparison_operator import ComparisonOperator -from databricks.bundles.alerts._models.cron_schedule import CronSchedule -from databricks.bundles.catalogs._models.catalog import Catalog -from databricks.bundles.core import ( - Location, - Resources, - Severity, - alert_mutator, - catalog_mutator, - job_mutator, - pipeline_mutator, - schema_mutator, - volume_mutator, -) +from databricks.bundles.core import Location, Resources, Severity from databricks.bundles.core._bundle import Bundle -from databricks.bundles.core._resource import Resource from databricks.bundles.core._resource_mutator import ResourceMutator from databricks.bundles.core._resource_type import _ResourceType from databricks.bundles.jobs._models.job import Job -from databricks.bundles.pipelines._models.pipeline import Pipeline -from databricks.bundles.schemas._models.schema import Schema -from databricks.bundles.volumes._models.volume import Volume - - -@dataclass(kw_only=True) -class TestCase: - add_resource: Callable - dict_example: dict - dataclass_example: Resource - mutator: Callable - - -resource_types = {tpe.resource_type: tpe for tpe in _ResourceType.all()} -test_cases = [ - ( - TestCase( - add_resource=Resources.add_job, - dict_example={"name": "My job"}, - dataclass_example=Job(name="My job"), - mutator=job_mutator, - ), - resource_types[Job], - ), - ( - TestCase( - add_resource=Resources.add_pipeline, - dict_example={"name": "My pipeline"}, - dataclass_example=Pipeline(name="My pipeline"), - mutator=pipeline_mutator, - ), - resource_types[Pipeline], - ), - ( - TestCase( - add_resource=Resources.add_volume, - dict_example={ - "name": "My Volume", - "catalog_name": "my_catalog", - "schema_name": "my_schema", - }, - dataclass_example=Volume( - catalog_name="my_catalog", - name="My Volume", - schema_name="my_schema", - ), - mutator=volume_mutator, - ), - resource_types[Volume], - ), - ( - TestCase( - add_resource=Resources.add_schema, - dict_example={"catalog_name": "my_catalog", "name": "my_schema"}, - dataclass_example=Schema(catalog_name="my_catalog", name="my_schema"), - mutator=schema_mutator, - ), - resource_types[Schema], - ), - ( - TestCase( - add_resource=Resources.add_alert, - dict_example={ - "display_name": "My Alert", - "query_text": "SELECT 1", - "warehouse_id": "my_warehouse", - "evaluation": { - "comparison_operator": "GREATER_THAN", - "source": {"name": "column_1"}, - }, - "schedule": { - "quartz_cron_schedule": "0 0 0 * * ?", - "timezone_id": "UTC", - }, - }, - dataclass_example=Alert( - display_name="My Alert", - query_text="SELECT 1", - warehouse_id="my_warehouse", - evaluation=AlertV2Evaluation( - comparison_operator=ComparisonOperator.GREATER_THAN, - source=AlertV2OperandColumn(name="column_1"), - ), - schedule=CronSchedule( - quartz_cron_schedule="0 0 0 * * ?", - timezone_id="UTC", - ), - ), - mutator=alert_mutator, - ), - resource_types[Alert], - ), - ( - TestCase( - add_resource=Resources.add_catalog, - dict_example={"name": "my_catalog"}, - dataclass_example=Catalog(name="my_catalog"), - mutator=catalog_mutator, - ), - resource_types[Catalog], - ), -] +from databricks_tests.core._generated import test_cases +from databricks_tests.core._resource_test_case import TestCase + test_case_ids = [tpe.plural_name for _, tpe in test_cases] From 19ca487ef35fa9efd18e5eff97b0802b894b012b Mon Sep 17 00:00:00 2001 From: Sankalp-Mittal Date: Fri, 28 Aug 2026 11:45:17 +0000 Subject: [PATCH 03/12] Fix ruff lint in the test-case generator The generator source lives outside databricks/databricks_tests, so pydabs-codegen's targeted ruff --fix does not reach it, but the root ruff check does. Sort imports and merge the two startswith calls into a single tuple call. No change to generated output. Co-authored-by: Isaac --- python/codegen/codegen/generated_test_cases.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/codegen/codegen/generated_test_cases.py b/python/codegen/codegen/generated_test_cases.py index 7decc29a897..1de481b5d0a 100644 --- a/python/codegen/codegen/generated_test_cases.py +++ b/python/codegen/codegen/generated_test_cases.py @@ -26,7 +26,7 @@ import codegen.jsonschema as openapi import codegen.packages as packages from codegen.generated_enum import _camel_to_upper_snake -from codegen.generated_wiring import _WiredResource, _wired_resources +from codegen.generated_wiring import _wired_resources, _WiredResource HEADER = "# Code generated by pydabs-codegen. DO NOT EDIT.\n\n" @@ -79,7 +79,7 @@ def _ref_name(ref: str) -> str: def _is_composite(ref: str) -> bool: - if ref.startswith("#/$defs/slice/") or ref.startswith("#/$defs/map/"): + if ref.startswith(("#/$defs/slice/", "#/$defs/map/")): return True return _ref_name(ref) not in packages.PRIMITIVES From ffaf4002dd20377a4baca1de88e7e9f80a7e453a Mon Sep 17 00:00:00 2001 From: Sankalp-Mittal Date: Fri, 28 Aug 2026 12:17:15 +0000 Subject: [PATCH 04/12] added explainatory comments --- .../codegen/codegen/generated_test_cases.py | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/python/codegen/codegen/generated_test_cases.py b/python/codegen/codegen/generated_test_cases.py index 1de481b5d0a..c3984879c46 100644 --- a/python/codegen/codegen/generated_test_cases.py +++ b/python/codegen/codegen/generated_test_cases.py @@ -75,10 +75,18 @@ class _Map: def _ref_name(ref: str) -> str: + """Last path segment of a JSON-schema ref -- the schema name. + + :param ref: a JSON-schema reference, e.g. "#/$defs/.../jobs.Task" or "#/$defs/string". + """ return ref.split("/")[-1] def _is_composite(ref: str) -> bool: + """Whether a ref is a composite type (list, map, object, or enum) rather than a scalar. + + :param ref: the JSON-schema reference of a field's type. + """ if ref.startswith(("#/$defs/slice/", "#/$defs/map/")): return True @@ -86,6 +94,11 @@ def _is_composite(ref: str) -> bool: def _synth_scalar(name: str, hint: str) -> _Scalar: + """Placeholder value for a primitive (str -> hint, int -> 0, float -> 0.0, bool -> True). + + :param name: the primitive's schema name, e.g. "string", "int", "boolean". + :param hint: enclosing field name, used as the string placeholder so examples read meaningfully. + """ if name == "string": return _Scalar(f'"{hint}"', f'"{hint}"') if name in ("integer", "int", "int64"): @@ -105,6 +118,14 @@ def _synth_ref( schemas: dict[str, openapi.Schema], visiting: set[str], ) -> _Value: + """Synthesize a value node for whatever type a ref points at: list, map, scalar, enum, or nested object. + + :param namespace: the resource's namespace (e.g. "jobs"); selects the module a referenced type is generated into. + :param ref: the JSON-schema reference of the type to synthesize. + :param hint: enclosing field name, passed through as the string placeholder. + :param schemas: all post-patch schemas keyed by schema name, for looking up nested/enum types. + :param visiting: ancestor object names on the current path, used to detect required cycles. + """ if ref.startswith("#/$defs/slice/"): element_ref = ref.replace("#/$defs/slice/", "#/$defs/") @@ -147,6 +168,15 @@ def _synth_object( visiting: set[str], top_level: bool, ) -> _Object: + """Synthesize an object value, choosing fields by policy: all required fields, plus (only at the resource top level) stable optional composite fields. + + :param namespace: the resource's namespace, threaded through to resolve nested types' modules. + :param schema_name: this object's schema name (e.g. "resources.Alert"). + :param schema: the Schema for this object -- its properties and required list. + :param schemas: all post-patch schemas, for recursing into nested types. + :param visiting: ancestor object names on the current path (cycle guard). + :param top_level: True only for the resource itself; when False, all optional fields are dropped. + """ visiting = visiting | {schema_name} fields: list[tuple[str, _Value]] = [] @@ -172,6 +202,11 @@ def _synth_object( def _module_of(namespace: str, schema_name: str) -> str: + """Python module a (non-primitive) schema's generated class lives in; asserts it exists. + + :param namespace: the resource's namespace; the type is generated under databricks.bundles.._models. + :param schema_name: the object/enum schema name to resolve. + """ module = packages.get_package(namespace, schema_name) assert module @@ -179,6 +214,10 @@ def _module_of(namespace: str, schema_name: str) -> str: def _render_dict(value: _Value) -> str: + """Render a synthesized value as a dict-literal source string (the dict_example form). + + :param value: the synthesized value node to render. + """ if isinstance(value, _Scalar): return value.dict_src if isinstance(value, _Enum): @@ -196,6 +235,10 @@ def _render_dict(value: _Value) -> str: def _render_dataclass(value: _Value) -> str: + """Render a synthesized value as a constructor-expression source string (the dataclass_example form). + + :param value: the synthesized value node to render. + """ if isinstance(value, _Scalar): return value.dataclass_src if isinstance(value, _Enum): @@ -213,6 +256,11 @@ def _render_dataclass(value: _Value) -> str: def _collect_imports(value: _Value, out: set[tuple[str, str]]) -> None: + """Collect (module, class_name) pairs the dataclass_example needs, walking nested objects/enums. + + :param value: the synthesized value node to walk. + :param out: set accumulating the (module, class_name) import pairs; mutated in place. + """ if isinstance(value, _Enum): out.add((value.module, value.class_name)) elif isinstance(value, _Object): @@ -226,6 +274,11 @@ def _collect_imports(value: _Value, out: set[tuple[str, str]]) -> None: def write_test_cases(output: str, schemas: dict[str, openapi.Schema]): + """Write one _generated/.py per wired resource plus the collector __init__.py. + + :param output: codegen output root (the python/ directory); files land under databricks_tests/core/_generated. + :param schemas: all post-patch schemas, used to synthesize each resource's dict/dataclass examples. + """ resources = _wired_resources() generated_path = Path(output) / "databricks_tests" / "core" / "_generated" @@ -263,6 +316,10 @@ def write_test_cases(output: str, schemas: dict[str, openapi.Schema]): def _collector_code(resources: list[_WiredResource]) -> str: + """Source for _generated/__init__.py: imports the per-resource modules and assembles `test_cases`. + + :param resources: the wired resources, in the order their test cases are collected. + """ module_imports = "\n".join(f" {r.plural_name}," for r in resources) entries = "\n".join(f" {r.plural_name}._test_case()," for r in resources) From 4aa571ccd982ad4810b90471903d695acb828fdd Mon Sep 17 00:00:00 2001 From: Sankalp-Mittal Date: Thu, 3 Sep 2026 15:04:11 +0000 Subject: [PATCH 05/12] Filter generated test-case fields by launch-stage maturity Skip optional top-level fields ranked below public preview (public-beta and private-preview), not just private-preview. Mirror the launch-stage rank from internal/clijson/launchstage.go (absent stage = GA) so the comparison uses maturity order rather than a string comparison. Drops jobs.triggers and pipelines.parameters from the generated examples. Co-authored-by: Isaac --- python/codegen/codegen/generated_test_cases.py | 16 +++++++++++++++- python/databricks_tests/core/_generated/jobs.py | 3 --- .../core/_generated/pipelines.py | 2 -- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/python/codegen/codegen/generated_test_cases.py b/python/codegen/codegen/generated_test_cases.py index c3984879c46..8da404c8f0f 100644 --- a/python/codegen/codegen/generated_test_cases.py +++ b/python/codegen/codegen/generated_test_cases.py @@ -34,6 +34,16 @@ (Path(__file__).parent / "test_case.py.tmpl").read_text() ) +# Launch-stage maturity, mirroring internal/clijson/launchstage.go: +# GA < PUBLIC_PREVIEW < PUBLIC_BETA < PRIVATE_PREVIEW; absent stage = GA. +_STAGE_RANK = { + None: 0, + openapi.LaunchStage.GA: 0, + openapi.LaunchStage.PUBLIC_PREVIEW: 1, + openapi.LaunchStage.PUBLIC_BETA: 2, + openapi.LaunchStage.PRIVATE_PREVIEW: 3, +} + # Synthesized value tree. Each node renders both as a dict literal (dict_example) # and as a constructor expression (dataclass_example). @@ -190,7 +200,11 @@ def _synth_object( continue if not _is_composite(prop.ref): continue - if prop.deprecated or prop.stage == openapi.LaunchStage.PRIVATE_PREVIEW: + if ( + prop.deprecated + or _STAGE_RANK[prop.stage] + > _STAGE_RANK[openapi.LaunchStage.PUBLIC_PREVIEW] + ): continue value = _synth_ref(namespace, prop.ref, field_name, schemas, visiting) diff --git a/python/databricks_tests/core/_generated/jobs.py b/python/databricks_tests/core/_generated/jobs.py index 3a9dd6cec6d..878e916cd69 100644 --- a/python/databricks_tests/core/_generated/jobs.py +++ b/python/databricks_tests/core/_generated/jobs.py @@ -26,7 +26,6 @@ from databricks.bundles.jobs._models.performance_target import PerformanceTarget from databricks.bundles.jobs._models.queue_settings import QueueSettings from databricks.bundles.jobs._models.task import Task -from databricks.bundles.jobs._models.trigger_configuration import TriggerConfiguration from databricks.bundles.jobs._models.trigger_settings import TriggerSettings from databricks.bundles.jobs._models.webhook_notifications import WebhookNotifications from databricks_tests.core._resource_test_case import TestCase @@ -57,7 +56,6 @@ def _test_case(): "tags": {"key": "value"}, "tasks": [{"task_key": "task_key"}], "trigger": {}, - "triggers": [{}], "webhook_notifications": {}, }, dataclass_example=Job( @@ -83,7 +81,6 @@ def _test_case(): tags={"key": "value"}, tasks=[Task(task_key="task_key")], trigger=TriggerSettings(), - triggers=[TriggerConfiguration()], webhook_notifications=WebhookNotifications(), ), mutator=job_mutator, diff --git a/python/databricks_tests/core/_generated/pipelines.py b/python/databricks_tests/core/_generated/pipelines.py index a4e65573317..096a05e6012 100644 --- a/python/databricks_tests/core/_generated/pipelines.py +++ b/python/databricks_tests/core/_generated/pipelines.py @@ -37,7 +37,6 @@ def _test_case(): "libraries": [{}], "lifecycle": {}, "notifications": [{}], - "parameters": {"key": "value"}, "permissions": [{"level": "CAN_MANAGE"}], "run_as": {}, "tags": {"key": "value"}, @@ -52,7 +51,6 @@ def _test_case(): libraries=[PipelineLibrary()], lifecycle=Lifecycle(), notifications=[Notifications()], - parameters={"key": "value"}, permissions=[ PipelinePermission(level=PipelinePermissionLevel.CAN_MANAGE) ], From 6f3cac83bc6540dbcb0b04014df9035733b62a48 Mon Sep 17 00:00:00 2001 From: Sankalp-Mittal Date: Fri, 4 Sep 2026 11:19:38 +0000 Subject: [PATCH 06/12] add unit tests for the codegen to assert behaviour --- .../test_generated_test_cases.py | 350 ++++++++++++++++++ 1 file changed, 350 insertions(+) create mode 100644 python/codegen/codegen_tests/test_generated_test_cases.py diff --git a/python/codegen/codegen_tests/test_generated_test_cases.py b/python/codegen/codegen_tests/test_generated_test_cases.py new file mode 100644 index 00000000000..047749b5035 --- /dev/null +++ b/python/codegen/codegen_tests/test_generated_test_cases.py @@ -0,0 +1,350 @@ +"""Tests for generated_test_cases.py, the codegen that builds example resource +values for the generated pydabs test suite. + +For the unfamiliar: the generator reads a resource's JSON schema and synthesizes +one placeholder "value tree", then renders it two ways -- as a Python dict literal +and as a dataclass constructor call. Downstream tests use the pair to check that +converting the dict form yields the dataclass form. These tests cover the pieces +of that pipeline: parsing schema refs, synthesizing the value tree (and the rules +for which fields it includes), the two renderers, collecting the imports the +rendered code needs, and the source of the collector module that gathers it all. +""" + +import codegen.jsonschema as openapi +import pytest +from codegen.generated_test_cases import ( + _collect_imports, + _collector_code, + _Enum, + _is_composite, + _List, + _Map, + _Object, + _ref_name, + _render_dataclass, + _render_dict, + _Scalar, + _synth_object, + _synth_ref, + _synth_scalar, +) +from codegen.generated_wiring import _WiredResource +from codegen.jsonschema import Property, Schema, SchemaType + +# Full $refs as they appear in jsonschema.json; only the last segment is +# significant to the code under test, but keeping the SDK prefix makes the +# fixtures read like the real spec. +_SDK = "#/$defs/github.com/databricks/databricks-sdk-go/service" +_COND_REF = f"{_SDK}/sql.AlertCondition" +_OP_REF = f"{_SDK}/sql.ComparisonOperator" + + +# _ref_name pulls the type name (the last path segment) out of a schema $ref. +def test_ref_name(): + assert _ref_name(_COND_REF) == "sql.AlertCondition" + assert _ref_name("#/$defs/string") == "string" + + +# _is_composite separates refs that need recursive synthesis (list/map/object/enum) +# from plain scalar refs (string, int, ...). +def test_is_composite(): + assert _is_composite("#/$defs/slice/string") + assert _is_composite("#/$defs/map/string") + assert _is_composite(_COND_REF) + assert not _is_composite("#/$defs/string") + assert not _is_composite("#/$defs/int64") + + +# Each primitive type maps to a fixed placeholder value (a string uses the field +# name; numbers/bools use 0 / 0.0 / True). +@pytest.mark.parametrize( + "name,expected", + [ + ("string", _Scalar('"hint"', '"hint"')), + ("integer", _Scalar("0", "0")), + ("int", _Scalar("0", "0")), + ("int64", _Scalar("0", "0")), + ("number", _Scalar("0.0", "0.0")), + ("float64", _Scalar("0.0", "0.0")), + ("boolean", _Scalar("True", "True")), + ("bool", _Scalar("True", "True")), + ], +) +def test_synth_scalar(name, expected): + assert _synth_scalar(name, "hint") == expected + + +# An unrecognized primitive means the schema has a type the generator doesn't +# model, so it fails loudly rather than emitting a bad value. +def test_synth_scalar_unknown_raises(): + with pytest.raises(ValueError, match="Unknown primitive: duration"): + _synth_scalar("duration", "hint") + + +# A scalar ref uses the enclosing field's name as its string placeholder, so the +# generated example reads like "name" rather than a generic token. +def test_synth_ref_scalar_uses_field_name_as_hint(): + assert _synth_ref("jobs", "#/$defs/string", "name", {}, set()) == _Scalar( + '"name"', '"name"' + ) + + +# A list ref becomes a one-element list whose single item is synthesized from the +# element type. +def test_synth_ref_list_recurses_on_element(): + assert _synth_ref("jobs", "#/$defs/slice/string", "tags", {}, set()) == _List( + _Scalar('"tags"', '"tags"') + ) + + +# A map ref becomes one {"key": "value"} entry -- the generator only ever emits +# string-keyed, string-valued maps. +def test_synth_ref_map_is_always_string_keyed(): + assert _synth_ref("jobs", "#/$defs/map/string", "labels", {}, set()) == _Map( + "key", _Scalar('"value"', '"value"') + ) + + +# Any other kind of map is never produced, so hitting one fails loudly. +def test_synth_ref_non_string_map_raises(): + with pytest.raises(ValueError, match="Unsupported map ref"): + _synth_ref("jobs", "#/$defs/map/integer", "labels", {}, set()) + + +# An enum ref becomes an _Enum node carrying the chosen value plus the class name, +# module, and member the generated code will reference. +def test_synth_ref_enum(): + schemas = { + "sql.ComparisonOperator": Schema(type=SchemaType.STRING, enum=["greaterThan"]), + } + + assert _synth_ref("alerts", _OP_REF, "op", schemas, set()) == _Enum( + value="greaterThan", + class_name="ComparisonOperator", + module="databricks.bundles.alerts._models.comparison_operator", + member="GREATER_THAN", + ) + + +# A type that (transitively) requires itself has no finite example, so synthesis +# must detect the cycle and stop instead of recursing forever. +def test_synth_ref_required_cycle_raises(): + schemas = {"sql.AlertCondition": Schema(type=SchemaType.OBJECT)} + + # The referenced object is already on the current path: a required cycle has + # no finite value, so synthesis must fail rather than recurse forever. + with pytest.raises( + ValueError, match=r"Required-field cycle through 'sql.AlertCondition'" + ): + _synth_ref("alerts", _COND_REF, "condition", schemas, {"sql.AlertCondition"}) + + +# Which properties land in a resource's example: the field-selection policy. +def test_synth_object_field_policy(): + # A top-level resource keeps: all required fields (scalar + composite), and + # stable optional composite fields. It drops optional scalars. + schemas = { + "resources.Alert": Schema( + type=SchemaType.OBJECT, + properties={ + "display_name": Property(ref="#/$defs/string"), + "condition": Property(ref=_COND_REF), + "seconds_to_retrigger": Property(ref="#/$defs/int"), + "tags": Property(ref="#/$defs/slice/string"), + }, + required=["display_name", "condition"], + ), + "sql.AlertCondition": Schema( + type=SchemaType.OBJECT, + properties={ + "op": Property(ref="#/$defs/string"), + "threshold": Property(ref="#/$defs/slice/string"), + }, + required=["op"], + ), + } + + example = _synth_object( + "alerts", + "resources.Alert", + schemas["resources.Alert"], + schemas, + set(), + top_level=True, + ) + + assert example == _Object( + class_name="Alert", + module="databricks.bundles.alerts._models.alert", + fields=[ + ("display_name", _Scalar('"display_name"', '"display_name"')), + # Nested object contributes only its required field (op); the nested + # optional composite (threshold) is dropped because top_level is False. + ( + "condition", + _Object( + class_name="AlertCondition", + module="databricks.bundles.alerts._models.alert_condition", + fields=[("op", _Scalar('"op"', '"op"'))], + ), + ), + # seconds_to_retrigger (optional scalar) is dropped. + ("tags", _List(_Scalar('"tags"', '"tags"'))), + ], + ) + + +# An optional field is only included if it is "stable": deprecated fields and +# ones still in beta / private preview are left out (absent stage counts as GA). +@pytest.mark.parametrize( + "deprecated,stage,kept", + [ + (None, None, True), + (None, openapi.LaunchStage.PUBLIC_PREVIEW, True), + (True, None, False), + (None, openapi.LaunchStage.PUBLIC_BETA, False), + (None, openapi.LaunchStage.PRIVATE_PREVIEW, False), + ], +) +def test_synth_object_optional_composite_stability(deprecated, stage, kept): + schemas = { + "resources.Alert": Schema( + type=SchemaType.OBJECT, + properties={ + "tags": Property( + ref="#/$defs/slice/string", deprecated=deprecated, stage=stage + ), + }, + required=[], + ), + } + + example = _synth_object( + "alerts", + "resources.Alert", + schemas["resources.Alert"], + schemas, + set(), + top_level=True, + ) + + assert bool(example.fields) == kept + + +# Inside a nested object (anything that isn't the resource itself) only required +# fields are kept, which keeps examples bounded. +def test_synth_object_nested_drops_all_optional(): + schemas = { + "sql.AlertCondition": Schema( + type=SchemaType.OBJECT, + properties={ + "op": Property(ref="#/$defs/string"), + "operand": Property(ref="#/$defs/slice/string"), + }, + required=["op"], + ), + } + + example = _synth_object( + "alerts", + "sql.AlertCondition", + schemas["sql.AlertCondition"], + schemas, + set(), + top_level=False, + ) + + assert [name for name, _ in example.fields] == ["op"] + + +# --- rendering ------------------------------------------------------------- + +_VALUE_TREE = _Object( + class_name="Alert", + module="databricks.bundles.alerts._models.alert", + fields=[ + ("display_name", _Scalar('"display_name"', '"display_name"')), + ( + "op", + _Enum( + value="greaterThan", + class_name="ComparisonOperator", + module="databricks.bundles.alerts._models.comparison_operator", + member="GREATER_THAN", + ), + ), + ("tags", _List(_Scalar('"tags"', '"tags"'))), + ("labels", _Map("key", _Scalar('"value"', '"value"'))), + ], +) + + +# Rendering a value tree as a Python dict-literal string (the "dict_example" form). +def test_render_dict(): + assert _render_dict(_VALUE_TREE) == ( + '{"display_name": "display_name", "op": "greaterThan", ' + '"tags": ["tags"], "labels": {"key": "value"}}' + ) + + +# Rendering the same tree as a dataclass-constructor string (the "dataclass_example" +# form); the two renderers must agree on structure but differ on enums. +def test_render_dataclass(): + # Enums render as a class member reference, unlike the dict form's raw string. + assert _render_dataclass(_VALUE_TREE) == ( + 'Alert(display_name="display_name", op=ComparisonOperator.GREATER_THAN, ' + 'tags=["tags"], labels={"key": "value"})' + ) + + +# The dataclass example references object and enum classes; this collects the +# (module, class) imports it needs, reaching into lists and maps to find them. +def test_collect_imports_gathers_objects_and_enums_through_containers(): + out: set[tuple[str, str]] = set() + _collect_imports(_VALUE_TREE, out) + + assert out == { + ("databricks.bundles.alerts._models.alert", "Alert"), + ("databricks.bundles.alerts._models.comparison_operator", "ComparisonOperator"), + } + + +# A tree of only primitives references no classes, so it needs no imports. +def test_collect_imports_scalar_only_tree_is_empty(): + out: set[tuple[str, str]] = set() + _collect_imports(_Scalar('"x"', '"x"'), out) + + assert out == set() + + +# Source of the _generated/__init__.py that imports each resource's module and +# gathers their test cases into a single `test_cases` list. +def test_collector_code(): + resources = [ + _WiredResource( + class_name="Alert", + singular_name="alert", + plural_name="alerts", + model_module="databricks.bundles.alerts._models.alert", + ), + _WiredResource( + class_name="Job", + singular_name="job", + plural_name="jobs", + model_module="databricks.bundles.jobs._models.job", + ), + ] + + assert _collector_code(resources) == ( + "from databricks_tests.core._generated import (\n" + " alerts,\n" + " jobs,\n" + ")\n" + "\n" + '__all__ = ["test_cases"]\n' + "\n" + "test_cases = [\n" + " alerts._test_case(),\n" + " jobs._test_case(),\n" + "]\n" + ) From 4dcee95e1695db7a3e983955a433e3e1d5d0052c Mon Sep 17 00:00:00 2001 From: Sankalp-Mittal Date: Fri, 4 Sep 2026 11:54:18 +0000 Subject: [PATCH 07/12] Add pydabs-acceptance-test skill for authoring resource acceptance tests An AI Agent Skill that guides an agent to author the acceptance test for a newly-onboarded PyDABs resource: the acceptance/bundle/python/-support/ fixture (databricks.yml + resources.py + mutators.py + script + test.toml + generated output.txt). Includes fill-in templates (.tmpl so they stay out of linters). Complements the schema-synthesized unit-test generation; realistic field values are adapted from the resource's invariant config. Co-authored-by: Isaac --- .../skills/pydabs-acceptance-test/SKILL.md | 137 ++++++++++++++++++ .../templates/databricks.yml.tmpl | 15 ++ .../templates/mutators.py.tmpl | 11 ++ .../templates/resources.py.tmpl | 14 ++ .../templates/script.tmpl | 5 + .../templates/test.toml.tmpl | 7 + 6 files changed, 189 insertions(+) create mode 100644 .agents/skills/pydabs-acceptance-test/SKILL.md create mode 100644 .agents/skills/pydabs-acceptance-test/templates/databricks.yml.tmpl create mode 100644 .agents/skills/pydabs-acceptance-test/templates/mutators.py.tmpl create mode 100644 .agents/skills/pydabs-acceptance-test/templates/resources.py.tmpl create mode 100644 .agents/skills/pydabs-acceptance-test/templates/script.tmpl create mode 100644 .agents/skills/pydabs-acceptance-test/templates/test.toml.tmpl diff --git a/.agents/skills/pydabs-acceptance-test/SKILL.md b/.agents/skills/pydabs-acceptance-test/SKILL.md new file mode 100644 index 00000000000..466177d0856 --- /dev/null +++ b/.agents/skills/pydabs-acceptance-test/SKILL.md @@ -0,0 +1,137 @@ +--- +name: pydabs-acceptance-test +description: "Author the acceptance test for a PyDABs (Python DABs) resource. Use when onboarding a new PyDABs resource, when the test_python_support_coverage guard fails for a resource, or when the user says 'add a pydabs acceptance test', 'write the acceptance test for ', or 'the -support test is missing'. Produces the acceptance/bundle/python/-support/ fixture (databricks.yml + resources.py + mutators.py + script + test.toml + generated output.txt)." +user-invocable: true +allowed-tools: Read, Edit, Write, Bash, Glob, Grep, AskUserQuestion +--- + +# Author a PyDABs resource acceptance test + +PyDABs acceptance tests are hand-written, one fixture per resource under +`acceptance/bundle/python/-support/`. The OpenAPI spec has no examples, so +the realistic field values come from the resource's invariant config and your own +judgement — not from a generator. This skill guides you through authoring that +fixture deterministically and verifying it. + +The coverage guard `test_python_support_coverage` +(`python/databricks_tests/core/test_python_support.py`) fails CI for any PyDABs +resource that lacks a `-support` fixture, so every newly-onboarded resource +must get one. This skill is how you close that gap. + +Worked reference: `acceptance/bundle/python/alerts-support/` (a resource with required +nested fields) and `acceptance/bundle/python/catalogs-support/` (a direct-engine-only +resource). Read both before starting — the fixture you write mirrors them. + +## Input + +The resource to cover, as its **plural** name (the `resources:` key in +`databricks.yml`, e.g. `alerts`, `volumes`). If given a singular or a Go/Python +type name, resolve it to the plural first (step 1). + +## Step 1 — Verify the resource is wired in PyDABs + +The fixture cannot work unless the resource's Python surface exists. Confirm all of: + +- The package `python/databricks/bundles//` exists and has a `_models/` + subdirectory (this is what marks it a generated resource package). +- `add_` is a method on `Resources` and `_mutator` is exported + from `databricks.bundles.core`: + + ```sh + grep -rn "def add_\|_mutator" python/databricks/bundles/core/ + ``` + +If any is missing, the resource is not wired yet — stop and onboard it in PyDABs +first (that is a separate task). Note the exact `` and `` names +(the dataclass, e.g. `Alert`) from `python/databricks/bundles//__init__.py`; +you need them for `resources.py` and `mutators.py`. + +## Step 2 — Find the resource's required fields + +The generated dataclass is the source of truth. In +`python/databricks/bundles//_models/.py`, required fields are typed +`VariableOr[...]` (no default); optional fields are `VariableOrOptional[...] = None`. +You must set every required field, including required fields of required nested +objects (recurse into their `_models` files). Optional fields are usually omitted. + +```sh +grep -nE "VariableOr\[|VariableOrOptional\[" python/databricks/bundles//_models/.py +``` + +## Step 3 — Get realistic values (adapt, don't copy) + +The invariant config `acceptance/bundle/invariant/configs/.yml.tmpl` shows +realistic values for the same resource. **Adapt** it — do not copy verbatim: + +- Replace `$UNIQUE_NAME`, `$TEST_DEFAULT_WAREHOUSE_ID`, and every other `$VAR` + interpolation with plain string literals. This test runs locally with no cloud and + no variable substitution. +- Drop cloud-only / IAM blocks the invariant config carries for its real-workspace + run: `permissions`, `grants`, `file_path` pointing at an external asset, etc. Keep + the fixture to the resource's own fields so `bundle validate` is deterministic. + +If no invariant config exists, invent plausible literals that satisfy the field types +(a display name string, an enum's first member, a cron string, etc.). + +## Step 4 — Write the six fixture files + +Copy each `templates/*.tmpl` into `acceptance/bundle/python/-support/`, +dropping the `.tmpl` suffix (e.g. `resources.py.tmpl` → `resources.py`), and fill +them in. The `.tmpl` suffix keeps the placeholder files out of the repo's linters; +the copied fixture files are real Python/YAML/TOML and must pass lint (step 7). +Placeholders: `PLURAL`, `SINGULAR`, `CLASS`, `NAME` (a short stem, e.g. `alert`), +`FIELD` (a required **string** field to mutate). + +1. **`databricks.yml`** — `bundle.name: my_project`, `sync: {paths: []}`, a top-level + `python:` block wiring `resources:load_resources` and `mutators:update_`, + and one YAML-declared resource `resources..my__1` with all required + fields. (`bundle validate` normalizes the `python:` key to `experimental.python` + in the output — that is expected, don't fight it.) +2. **`resources.py`** — `load_resources()` adds a second instance `my__2` via + `resources.add_(...)`, same required fields, slightly different values. +3. **`mutators.py`** — a `@_mutator` that `assert isinstance(...)` on a + required string field and `replace(...)`s it to append `" (updated)"`. The mutator + runs on **both** instances, so the golden shows the transform applied to each. +4. **`script`** — copy `templates/script` verbatim (`bundle validate --output json` + piped through `jq "pick(.experimental.python, .resources)"`). +5. **`test.toml`** — `Cloud = false`. Add `EnvMatrix.PYDAB_VERSION = ["current"]` for + a brand-new resource (it only exists in the current wheel, not the pinned older + one). Add `EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"]` only for a direct-only + resource — terraform is deprecated, so never add a `["terraform", "direct"]` + matrix. When unsure, copy the engine convention from the newest existing fixture + (`catalogs-support`), not an old one. +6. **`output.txt`** — do NOT hand-write; generate it in step 5. + +## Step 5 — Generate the golden output + +```sh +go test ./acceptance -run 'TestAccept/bundle/python/-support' -update +``` + +(or `./task test-update` to regenerate the whole suite). This writes `output.txt`. +Inspect it: both `my__1` and `my__2` must appear with the mutated field +showing `" (updated)"`, and only `experimental.python` + `resources` keys are present. + +## Step 6 — Verify it reproduces deterministically + +Re-run **without** `-update`. It must pass against the golden you just generated: + +```sh +go test ./acceptance -run 'TestAccept/bundle/python/-support' -count=1 +``` + +A test that only passes with `-update` is nondeterministic — investigate before +finishing (a `$VAR` left in a value, a non-deterministic field, an EnvMatrix variant +producing different output). Never stop at "golden written". + +## Step 7 — Confirm coverage and format + +```sh +(cd python && uv run --python 3.11 pytest databricks_tests/core/test_python_support.py) +./task fmt && ./task lint-q +``` + +`test_python_support_coverage` should now be green for this resource. If the resource +was previously in the `_LACKING` allowlist +(`python/databricks_tests/core/test_python_support.py`), remove its entry — the list +only shrinks. diff --git a/.agents/skills/pydabs-acceptance-test/templates/databricks.yml.tmpl b/.agents/skills/pydabs-acceptance-test/templates/databricks.yml.tmpl new file mode 100644 index 00000000000..18f303dd816 --- /dev/null +++ b/.agents/skills/pydabs-acceptance-test/templates/databricks.yml.tmpl @@ -0,0 +1,15 @@ +bundle: + name: my_project + +sync: {paths: []} # don't need to copy files + +python: + resources: + - "resources:load_resources" + mutators: + - "mutators:update_SINGULAR" + +resources: + PLURAL: + my_NAME_1: + # required fields, realistic literal values (see SKILL.md steps 2-3) diff --git a/.agents/skills/pydabs-acceptance-test/templates/mutators.py.tmpl b/.agents/skills/pydabs-acceptance-test/templates/mutators.py.tmpl new file mode 100644 index 00000000000..4a2bfb94d89 --- /dev/null +++ b/.agents/skills/pydabs-acceptance-test/templates/mutators.py.tmpl @@ -0,0 +1,11 @@ +from dataclasses import replace + +from databricks.bundles.PLURAL import CLASS +from databricks.bundles.core import SINGULAR_mutator + + +@SINGULAR_mutator +def update_SINGULAR(SINGULAR: CLASS) -> CLASS: + assert isinstance(SINGULAR.FIELD, str) + + return replace(SINGULAR, FIELD=f"{SINGULAR.FIELD} (updated)") diff --git a/.agents/skills/pydabs-acceptance-test/templates/resources.py.tmpl b/.agents/skills/pydabs-acceptance-test/templates/resources.py.tmpl new file mode 100644 index 00000000000..9360bec828a --- /dev/null +++ b/.agents/skills/pydabs-acceptance-test/templates/resources.py.tmpl @@ -0,0 +1,14 @@ +from databricks.bundles.core import Resources + + +def load_resources() -> Resources: + resources = Resources() + + resources.add_SINGULAR( + "my_NAME_2", + { + # same required fields as _1, slightly different values + }, + ) + + return resources diff --git a/.agents/skills/pydabs-acceptance-test/templates/script.tmpl b/.agents/skills/pydabs-acceptance-test/templates/script.tmpl new file mode 100644 index 00000000000..e273fb45a53 --- /dev/null +++ b/.agents/skills/pydabs-acceptance-test/templates/script.tmpl @@ -0,0 +1,5 @@ + +trace uv run $UV_ARGS -q $CLI bundle validate --output json | \ + jq "pick(.experimental.python, .resources)" + +rm -fr .databricks __pycache__ diff --git a/.agents/skills/pydabs-acceptance-test/templates/test.toml.tmpl b/.agents/skills/pydabs-acceptance-test/templates/test.toml.tmpl new file mode 100644 index 00000000000..4935b9b020a --- /dev/null +++ b/.agents/skills/pydabs-acceptance-test/templates/test.toml.tmpl @@ -0,0 +1,7 @@ +Cloud = false # tests don't interact with APIs + +# new resource, only in the current wheel: +# EnvMatrix.PYDAB_VERSION = ["current"] + +# direct-only resource (terraform is deprecated): +# EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] From 9f4290908c638340dc0ef009b8e56107c8d212c2 Mon Sep 17 00:00:00 2001 From: Sankalp-Mittal Date: Fri, 4 Sep 2026 11:54:19 +0000 Subject: [PATCH 08/12] Assert every PyDABs resource has an acceptance test test_python_support_coverage fails until each resource in the _ResourceType registry has an acceptance/bundle/python/-support/ fixture, so coverage cannot silently regress as resources are onboarded. Mirrors the invariant-config coverage guard; shrink-only _LACKING allowlist ({jobs}, whose coverage predates the convention). Lives in the python test suite (runs in CI via pydabs-test) since it checks the filesystem rather than exercising the CLI. Co-authored-by: Isaac --- .../core/test_python_support.py | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 python/databricks_tests/core/test_python_support.py diff --git a/python/databricks_tests/core/test_python_support.py b/python/databricks_tests/core/test_python_support.py new file mode 100644 index 00000000000..ef8a113d56b --- /dev/null +++ b/python/databricks_tests/core/test_python_support.py @@ -0,0 +1,36 @@ +"""Coverage guard: every PyDABs resource must have an acceptance fixture. + +Asserts each resource in the _ResourceType registry has an +acceptance/bundle/python/-support/ fixture. New resources get one via the +pydabs-acceptance-test skill; this fails CI until it exists. +""" + +from pathlib import Path + +import pytest + +from databricks.bundles.core._resource_type import _ResourceType + +_ACCEPTANCE_DIR = Path(__file__).parents[3] / "acceptance" / "bundle" / "python" + +# Resources knowingly lacking a -support fixture. Shrink-only: the test fails +# if an entry here is actually covered, so gaps can only close. +_LACKING = { + # jobs predates the -support convention; covered across the suite instead. + "jobs", +} + +_PLURALS = sorted(t.plural_name for t in _ResourceType.all()) + + +@pytest.mark.parametrize("plural", _PLURALS) +def test_python_support_coverage(plural: str): + covered = (_ACCEPTANCE_DIR / f"{plural}-support" / "databricks.yml").exists() + + if plural in _LACKING: + assert not covered, f"{plural!r} now has a fixture; remove it from _LACKING" + else: + assert covered, ( + f"no acceptance/bundle/python/{plural}-support/ fixture for {plural!r}; " + "author one with the pydabs-acceptance-test skill or add it to _LACKING" + ) From a1ba7b36deaadb8fd07bb61e3b6fa350e3b98f37 Mon Sep 17 00:00:00 2001 From: Sankalp-Mittal Date: Fri, 4 Sep 2026 13:00:03 +0000 Subject: [PATCH 09/12] Replace acceptance-test skill with an auto-loaded rule + README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review feedback (skills aren't reliably loaded, and the examples plus a verbose failure are enough): drop the pydabs-acceptance-test skill in favor of the repo's dresources pattern — a path-scoped .agents/rules/ file (auto-loaded when working under acceptance/bundle/python/**) pointing to a concise acceptance/bundle/python/README.md that leans on the existing fixtures. Retarget the coverage guard's message at the README. Co-authored-by: Isaac --- .agents/rules/pydabs-acceptance-tests.md | 8 + .../skills/pydabs-acceptance-test/SKILL.md | 137 ------------------ .../templates/databricks.yml.tmpl | 15 -- .../templates/mutators.py.tmpl | 11 -- .../templates/resources.py.tmpl | 14 -- .../templates/script.tmpl | 5 - .../templates/test.toml.tmpl | 7 - acceptance/bundle/python/README.md | 48 ++++++ .../core/test_python_support.py | 6 +- 9 files changed, 59 insertions(+), 192 deletions(-) create mode 100644 .agents/rules/pydabs-acceptance-tests.md delete mode 100644 .agents/skills/pydabs-acceptance-test/SKILL.md delete mode 100644 .agents/skills/pydabs-acceptance-test/templates/databricks.yml.tmpl delete mode 100644 .agents/skills/pydabs-acceptance-test/templates/mutators.py.tmpl delete mode 100644 .agents/skills/pydabs-acceptance-test/templates/resources.py.tmpl delete mode 100644 .agents/skills/pydabs-acceptance-test/templates/script.tmpl delete mode 100644 .agents/skills/pydabs-acceptance-test/templates/test.toml.tmpl create mode 100644 acceptance/bundle/python/README.md diff --git a/.agents/rules/pydabs-acceptance-tests.md b/.agents/rules/pydabs-acceptance-tests.md new file mode 100644 index 00000000000..924540845e7 --- /dev/null +++ b/.agents/rules/pydabs-acceptance-tests.md @@ -0,0 +1,8 @@ +--- +description: Rules for authoring PyDABs resource acceptance tests +globs: acceptance/bundle/python/** +paths: + - "acceptance/bundle/python/**" +--- + +**RULE: Before adding a PyDABs resource acceptance test, read `acceptance/bundle/python/README.md`.** It covers the `-support/` fixture layout, how to source and adapt realistic field values, the version/engine `test.toml` knobs, and the determinism re-run. Every PyDABs resource needs one (enforced by `test_python_support_coverage`). diff --git a/.agents/skills/pydabs-acceptance-test/SKILL.md b/.agents/skills/pydabs-acceptance-test/SKILL.md deleted file mode 100644 index 466177d0856..00000000000 --- a/.agents/skills/pydabs-acceptance-test/SKILL.md +++ /dev/null @@ -1,137 +0,0 @@ ---- -name: pydabs-acceptance-test -description: "Author the acceptance test for a PyDABs (Python DABs) resource. Use when onboarding a new PyDABs resource, when the test_python_support_coverage guard fails for a resource, or when the user says 'add a pydabs acceptance test', 'write the acceptance test for ', or 'the -support test is missing'. Produces the acceptance/bundle/python/-support/ fixture (databricks.yml + resources.py + mutators.py + script + test.toml + generated output.txt)." -user-invocable: true -allowed-tools: Read, Edit, Write, Bash, Glob, Grep, AskUserQuestion ---- - -# Author a PyDABs resource acceptance test - -PyDABs acceptance tests are hand-written, one fixture per resource under -`acceptance/bundle/python/-support/`. The OpenAPI spec has no examples, so -the realistic field values come from the resource's invariant config and your own -judgement — not from a generator. This skill guides you through authoring that -fixture deterministically and verifying it. - -The coverage guard `test_python_support_coverage` -(`python/databricks_tests/core/test_python_support.py`) fails CI for any PyDABs -resource that lacks a `-support` fixture, so every newly-onboarded resource -must get one. This skill is how you close that gap. - -Worked reference: `acceptance/bundle/python/alerts-support/` (a resource with required -nested fields) and `acceptance/bundle/python/catalogs-support/` (a direct-engine-only -resource). Read both before starting — the fixture you write mirrors them. - -## Input - -The resource to cover, as its **plural** name (the `resources:` key in -`databricks.yml`, e.g. `alerts`, `volumes`). If given a singular or a Go/Python -type name, resolve it to the plural first (step 1). - -## Step 1 — Verify the resource is wired in PyDABs - -The fixture cannot work unless the resource's Python surface exists. Confirm all of: - -- The package `python/databricks/bundles//` exists and has a `_models/` - subdirectory (this is what marks it a generated resource package). -- `add_` is a method on `Resources` and `_mutator` is exported - from `databricks.bundles.core`: - - ```sh - grep -rn "def add_\|_mutator" python/databricks/bundles/core/ - ``` - -If any is missing, the resource is not wired yet — stop and onboard it in PyDABs -first (that is a separate task). Note the exact `` and `` names -(the dataclass, e.g. `Alert`) from `python/databricks/bundles//__init__.py`; -you need them for `resources.py` and `mutators.py`. - -## Step 2 — Find the resource's required fields - -The generated dataclass is the source of truth. In -`python/databricks/bundles//_models/.py`, required fields are typed -`VariableOr[...]` (no default); optional fields are `VariableOrOptional[...] = None`. -You must set every required field, including required fields of required nested -objects (recurse into their `_models` files). Optional fields are usually omitted. - -```sh -grep -nE "VariableOr\[|VariableOrOptional\[" python/databricks/bundles//_models/.py -``` - -## Step 3 — Get realistic values (adapt, don't copy) - -The invariant config `acceptance/bundle/invariant/configs/.yml.tmpl` shows -realistic values for the same resource. **Adapt** it — do not copy verbatim: - -- Replace `$UNIQUE_NAME`, `$TEST_DEFAULT_WAREHOUSE_ID`, and every other `$VAR` - interpolation with plain string literals. This test runs locally with no cloud and - no variable substitution. -- Drop cloud-only / IAM blocks the invariant config carries for its real-workspace - run: `permissions`, `grants`, `file_path` pointing at an external asset, etc. Keep - the fixture to the resource's own fields so `bundle validate` is deterministic. - -If no invariant config exists, invent plausible literals that satisfy the field types -(a display name string, an enum's first member, a cron string, etc.). - -## Step 4 — Write the six fixture files - -Copy each `templates/*.tmpl` into `acceptance/bundle/python/-support/`, -dropping the `.tmpl` suffix (e.g. `resources.py.tmpl` → `resources.py`), and fill -them in. The `.tmpl` suffix keeps the placeholder files out of the repo's linters; -the copied fixture files are real Python/YAML/TOML and must pass lint (step 7). -Placeholders: `PLURAL`, `SINGULAR`, `CLASS`, `NAME` (a short stem, e.g. `alert`), -`FIELD` (a required **string** field to mutate). - -1. **`databricks.yml`** — `bundle.name: my_project`, `sync: {paths: []}`, a top-level - `python:` block wiring `resources:load_resources` and `mutators:update_`, - and one YAML-declared resource `resources..my__1` with all required - fields. (`bundle validate` normalizes the `python:` key to `experimental.python` - in the output — that is expected, don't fight it.) -2. **`resources.py`** — `load_resources()` adds a second instance `my__2` via - `resources.add_(...)`, same required fields, slightly different values. -3. **`mutators.py`** — a `@_mutator` that `assert isinstance(...)` on a - required string field and `replace(...)`s it to append `" (updated)"`. The mutator - runs on **both** instances, so the golden shows the transform applied to each. -4. **`script`** — copy `templates/script` verbatim (`bundle validate --output json` - piped through `jq "pick(.experimental.python, .resources)"`). -5. **`test.toml`** — `Cloud = false`. Add `EnvMatrix.PYDAB_VERSION = ["current"]` for - a brand-new resource (it only exists in the current wheel, not the pinned older - one). Add `EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"]` only for a direct-only - resource — terraform is deprecated, so never add a `["terraform", "direct"]` - matrix. When unsure, copy the engine convention from the newest existing fixture - (`catalogs-support`), not an old one. -6. **`output.txt`** — do NOT hand-write; generate it in step 5. - -## Step 5 — Generate the golden output - -```sh -go test ./acceptance -run 'TestAccept/bundle/python/-support' -update -``` - -(or `./task test-update` to regenerate the whole suite). This writes `output.txt`. -Inspect it: both `my__1` and `my__2` must appear with the mutated field -showing `" (updated)"`, and only `experimental.python` + `resources` keys are present. - -## Step 6 — Verify it reproduces deterministically - -Re-run **without** `-update`. It must pass against the golden you just generated: - -```sh -go test ./acceptance -run 'TestAccept/bundle/python/-support' -count=1 -``` - -A test that only passes with `-update` is nondeterministic — investigate before -finishing (a `$VAR` left in a value, a non-deterministic field, an EnvMatrix variant -producing different output). Never stop at "golden written". - -## Step 7 — Confirm coverage and format - -```sh -(cd python && uv run --python 3.11 pytest databricks_tests/core/test_python_support.py) -./task fmt && ./task lint-q -``` - -`test_python_support_coverage` should now be green for this resource. If the resource -was previously in the `_LACKING` allowlist -(`python/databricks_tests/core/test_python_support.py`), remove its entry — the list -only shrinks. diff --git a/.agents/skills/pydabs-acceptance-test/templates/databricks.yml.tmpl b/.agents/skills/pydabs-acceptance-test/templates/databricks.yml.tmpl deleted file mode 100644 index 18f303dd816..00000000000 --- a/.agents/skills/pydabs-acceptance-test/templates/databricks.yml.tmpl +++ /dev/null @@ -1,15 +0,0 @@ -bundle: - name: my_project - -sync: {paths: []} # don't need to copy files - -python: - resources: - - "resources:load_resources" - mutators: - - "mutators:update_SINGULAR" - -resources: - PLURAL: - my_NAME_1: - # required fields, realistic literal values (see SKILL.md steps 2-3) diff --git a/.agents/skills/pydabs-acceptance-test/templates/mutators.py.tmpl b/.agents/skills/pydabs-acceptance-test/templates/mutators.py.tmpl deleted file mode 100644 index 4a2bfb94d89..00000000000 --- a/.agents/skills/pydabs-acceptance-test/templates/mutators.py.tmpl +++ /dev/null @@ -1,11 +0,0 @@ -from dataclasses import replace - -from databricks.bundles.PLURAL import CLASS -from databricks.bundles.core import SINGULAR_mutator - - -@SINGULAR_mutator -def update_SINGULAR(SINGULAR: CLASS) -> CLASS: - assert isinstance(SINGULAR.FIELD, str) - - return replace(SINGULAR, FIELD=f"{SINGULAR.FIELD} (updated)") diff --git a/.agents/skills/pydabs-acceptance-test/templates/resources.py.tmpl b/.agents/skills/pydabs-acceptance-test/templates/resources.py.tmpl deleted file mode 100644 index 9360bec828a..00000000000 --- a/.agents/skills/pydabs-acceptance-test/templates/resources.py.tmpl +++ /dev/null @@ -1,14 +0,0 @@ -from databricks.bundles.core import Resources - - -def load_resources() -> Resources: - resources = Resources() - - resources.add_SINGULAR( - "my_NAME_2", - { - # same required fields as _1, slightly different values - }, - ) - - return resources diff --git a/.agents/skills/pydabs-acceptance-test/templates/script.tmpl b/.agents/skills/pydabs-acceptance-test/templates/script.tmpl deleted file mode 100644 index e273fb45a53..00000000000 --- a/.agents/skills/pydabs-acceptance-test/templates/script.tmpl +++ /dev/null @@ -1,5 +0,0 @@ - -trace uv run $UV_ARGS -q $CLI bundle validate --output json | \ - jq "pick(.experimental.python, .resources)" - -rm -fr .databricks __pycache__ diff --git a/.agents/skills/pydabs-acceptance-test/templates/test.toml.tmpl b/.agents/skills/pydabs-acceptance-test/templates/test.toml.tmpl deleted file mode 100644 index 4935b9b020a..00000000000 --- a/.agents/skills/pydabs-acceptance-test/templates/test.toml.tmpl +++ /dev/null @@ -1,7 +0,0 @@ -Cloud = false # tests don't interact with APIs - -# new resource, only in the current wheel: -# EnvMatrix.PYDAB_VERSION = ["current"] - -# direct-only resource (terraform is deprecated): -# EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/python/README.md b/acceptance/bundle/python/README.md new file mode 100644 index 00000000000..942af63a6aa --- /dev/null +++ b/acceptance/bundle/python/README.md @@ -0,0 +1,48 @@ +# PyDABs resource acceptance tests + +Each `-support/` directory is the acceptance test for one PyDABs resource. It +checks that the resource loads both from YAML and from Python and that a mutator runs +over it. `test_python_support_coverage` +(`python/databricks_tests/core/test_python_support.py`) requires every PyDABs resource +to have one, so a newly-onboarded resource needs a fixture here. + +Copy an existing one — `alerts-support/` (a resource with required nested fields) or +`catalogs-support/` (direct-engine only) are the canonical examples. A fixture is six +files: + +- `databricks.yml` — `bundle.name: my_project`, `sync: {paths: []}`, a top-level + `python:` block wiring `resources:load_resources` + `mutators:update_`, and + one YAML-declared instance `.my__1`. +- `resources.py` — `load_resources()` adds a second instance `my__2` via + `resources.add_(...)`. +- `mutators.py` — a `@_mutator` that appends `" (updated)"` to a required + string field; it runs over both instances. +- `script` — copy it verbatim (`bundle validate --output json | jq "pick(...)"`). +- `test.toml` — `Cloud = false`. +- `output.txt` — generated, never hand-written. + +## Authoring a new one + +1. Confirm the resource is wired: `python/databricks/bundles//` exists, and + `add_` / `_mutator` are in `databricks.bundles.core`. If not, it + must be onboarded in PyDABs first. +2. Required fields are the `VariableOr[...]` (no default) fields in + `python/databricks/bundles//_models/.py`; set all of them, + recursing into required nested objects. `VariableOrOptional[...] = None` fields are + optional — omit them. +3. Get realistic values from `acceptance/bundle/invariant/configs/.yml.tmpl`, + but **adapt**: replace `$UNIQUE_NAME` / `$TEST_DEFAULT_WAREHOUSE_ID` and other `$VAR`s + with plain literals, and drop cloud-only blocks (`permissions`, `grants`, + `file_path`) — this test is local and deterministic. +4. `test.toml`: add `EnvMatrix.PYDAB_VERSION = ["current"]` for a brand-new resource + (it only exists in the current wheel), and `EnvMatrix.DATABRICKS_BUNDLE_ENGINE = + ["direct"]` for a direct-only resource (terraform is deprecated — never a + `["terraform", "direct"]` matrix). Match the newest fixture when unsure. +5. Generate the golden: + `go test ./acceptance -run 'TestAccept/bundle/python/-support' -update`. +6. **Re-run without `-update`** — it must pass against the golden you just generated. A + test that only passes with `-update` is nondeterministic (usually a `$VAR` or a + volatile field left in); fix it before finishing. + +Note: `bundle validate` normalizes the `python:` key to `experimental.python` in the +output — that's expected. diff --git a/python/databricks_tests/core/test_python_support.py b/python/databricks_tests/core/test_python_support.py index ef8a113d56b..41a68d86943 100644 --- a/python/databricks_tests/core/test_python_support.py +++ b/python/databricks_tests/core/test_python_support.py @@ -1,8 +1,8 @@ """Coverage guard: every PyDABs resource must have an acceptance fixture. Asserts each resource in the _ResourceType registry has an -acceptance/bundle/python/-support/ fixture. New resources get one via the -pydabs-acceptance-test skill; this fails CI until it exists. +acceptance/bundle/python/-support/ fixture (see that directory's README.md for +how to author one); this fails CI until it exists. """ from pathlib import Path @@ -32,5 +32,5 @@ def test_python_support_coverage(plural: str): else: assert covered, ( f"no acceptance/bundle/python/{plural}-support/ fixture for {plural!r}; " - "author one with the pydabs-acceptance-test skill or add it to _LACKING" + "add one (see acceptance/bundle/python/README.md) or add it to _LACKING" ) From 9efbf121216d7ca100485788415ee33881252e84 Mon Sep 17 00:00:00 2001 From: Sankalp-Mittal Date: Fri, 4 Sep 2026 13:04:40 +0000 Subject: [PATCH 10/12] update skill --- acceptance/bundle/python/README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/acceptance/bundle/python/README.md b/acceptance/bundle/python/README.md index 942af63a6aa..ba49b04fb3e 100644 --- a/acceptance/bundle/python/README.md +++ b/acceptance/bundle/python/README.md @@ -36,8 +36,7 @@ files: `file_path`) — this test is local and deterministic. 4. `test.toml`: add `EnvMatrix.PYDAB_VERSION = ["current"]` for a brand-new resource (it only exists in the current wheel), and `EnvMatrix.DATABRICKS_BUNDLE_ENGINE = - ["direct"]` for a direct-only resource (terraform is deprecated — never a - `["terraform", "direct"]` matrix). Match the newest fixture when unsure. + ["direct"]` for a direct-only resource. 5. Generate the golden: `go test ./acceptance -run 'TestAccept/bundle/python/-support' -update`. 6. **Re-run without `-update`** — it must pass against the golden you just generated. A From 3f15d684f188c6f7604cd0493d4966a4d0936325 Mon Sep 17 00:00:00 2001 From: Sankalp-Mittal Date: Fri, 4 Sep 2026 13:17:18 +0000 Subject: [PATCH 11/12] Check that .cursor/rules mirror .agents/rules Add tools/validate_cursor_rules.py (wired into `task checks`) so a rule under .agents/rules/ without its .cursor/rules/.mdc symlink fails CI; `--fix` auto-creates missing symlinks and drops stale ones. Also add the symlink for the new pydabs-acceptance-tests rule. Co-authored-by: Isaac --- .cursor/rules/pydabs-acceptance-tests.mdc | 1 + Taskfile.yml | 8 ++- tools/validate_cursor_rules.py | 72 +++++++++++++++++++++++ 3 files changed, 80 insertions(+), 1 deletion(-) create mode 120000 .cursor/rules/pydabs-acceptance-tests.mdc create mode 100755 tools/validate_cursor_rules.py diff --git a/.cursor/rules/pydabs-acceptance-tests.mdc b/.cursor/rules/pydabs-acceptance-tests.mdc new file mode 120000 index 00000000000..ffe41d4bbea --- /dev/null +++ b/.cursor/rules/pydabs-acceptance-tests.mdc @@ -0,0 +1 @@ +../../.agents/rules/pydabs-acceptance-tests.md \ No newline at end of file diff --git a/Taskfile.yml b/Taskfile.yml index 4d905d92eaf..cac5443f11e 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -313,8 +313,13 @@ tasks: cmds: - "! git grep -lF databricks.com -- '*uv.lock' '*.py.lock'" + check-cursor-rules: + desc: Fail if a .agents/rules/*.md lacks its .cursor/rules/*.mdc symlink (--fix adds it) + cmds: + - "./tools/validate_cursor_rules.py" + checks: - desc: Run quick checks (tidy, whitespace, deadcode, changelog, lockfiles) + desc: Run quick checks (tidy, whitespace, deadcode, changelog, lockfiles, cursor-rules) # Sequential: `tidy` rewrites go.mod/go.sum and any future tidy work # touching more paths should not race with the whitespace scanner. cmds: @@ -323,6 +328,7 @@ tasks: - task: deadcode - task: check-changelog - task: check-lockfiles + - task: check-cursor-rules install-pythons: desc: Install Python 3.9-3.13 via uv diff --git a/tools/validate_cursor_rules.py b/tools/validate_cursor_rules.py new file mode 100755 index 00000000000..3ef9ce58953 --- /dev/null +++ b/tools/validate_cursor_rules.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.12" +# /// +"""Keep .cursor/rules/*.mdc symlinks in sync with .agents/rules/*.md. + +The canonical rules live in .agents/rules/.md; Cursor reads them from +.cursor/rules/.mdc, so each rule needs a .mdc symlink pointing back at its +.md. This validates that every rule has a correct symlink and that no symlink is +left dangling. Run with --fix to create missing symlinks and drop stale ones. + +Non-symlink .mdc files (e.g. the standalone 00-agents-context.mdc bootstrap) are +not mirrors of a rule and are left untouched. +""" + +import os +import sys + +AGENTS_RULES = ".agents/rules" +CURSOR_RULES = ".cursor/rules" + + +def link_target(stem): + # The .mdc lives in .cursor/rules/, so ../../ reaches the repo root. + return f"../../{AGENTS_RULES}/{stem}.md" + + +def main(): + fix = "--fix" in sys.argv + + stems = sorted(f[:-3] for f in os.listdir(AGENTS_RULES) if f.endswith(".md")) + problems = [] + + # Every rule must have a .mdc symlink pointing at its .md. + for stem in stems: + mdc = os.path.join(CURSOR_RULES, stem + ".mdc") + want = link_target(stem) + have = os.readlink(mdc) if os.path.islink(mdc) else None + if have == want: + continue + if fix: + if os.path.lexists(mdc): + os.remove(mdc) + os.symlink(want, mdc) + print(f"Linked {mdc} -> {want}") + else: + problems.append(f"{mdc}: missing or wrong symlink, expected -> {want}") + + # No .mdc symlink may point at a rule that no longer exists. + known = {stem + ".mdc" for stem in stems} + for name in sorted(os.listdir(CURSOR_RULES)): + path = os.path.join(CURSOR_RULES, name) + if not os.path.islink(path) or name in known: + continue + if fix: + os.remove(path) + print(f"Removed stale {path}") + else: + problems.append(f"{path}: stale symlink, no matching {AGENTS_RULES}/ rule") + + if problems: + print("\n".join(problems)) + print( + f"\n{len(problems)} problem(s). Run: ./tools/validate_cursor_rules.py --fix", + file=sys.stderr, + ) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From bcca02da9301148fc2ff07cead5544111d459f6d Mon Sep 17 00:00:00 2001 From: Sankalp-Mittal Date: Fri, 4 Sep 2026 13:34:09 +0000 Subject: [PATCH 12/12] Drop cursor-rules symlink lint (moving to a separate PR) Remove tools/validate_cursor_rules.py and its check-cursor-rules task; the symlink-mirror check is being shipped on its own. Keep the .cursor/rules/pydabs-acceptance-tests.mdc symlink for the rule added here. Co-authored-by: Isaac --- Taskfile.yml | 8 +--- tools/validate_cursor_rules.py | 72 ---------------------------------- 2 files changed, 1 insertion(+), 79 deletions(-) delete mode 100755 tools/validate_cursor_rules.py diff --git a/Taskfile.yml b/Taskfile.yml index cac5443f11e..4d905d92eaf 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -313,13 +313,8 @@ tasks: cmds: - "! git grep -lF databricks.com -- '*uv.lock' '*.py.lock'" - check-cursor-rules: - desc: Fail if a .agents/rules/*.md lacks its .cursor/rules/*.mdc symlink (--fix adds it) - cmds: - - "./tools/validate_cursor_rules.py" - checks: - desc: Run quick checks (tidy, whitespace, deadcode, changelog, lockfiles, cursor-rules) + desc: Run quick checks (tidy, whitespace, deadcode, changelog, lockfiles) # Sequential: `tidy` rewrites go.mod/go.sum and any future tidy work # touching more paths should not race with the whitespace scanner. cmds: @@ -328,7 +323,6 @@ tasks: - task: deadcode - task: check-changelog - task: check-lockfiles - - task: check-cursor-rules install-pythons: desc: Install Python 3.9-3.13 via uv diff --git a/tools/validate_cursor_rules.py b/tools/validate_cursor_rules.py deleted file mode 100755 index 3ef9ce58953..00000000000 --- a/tools/validate_cursor_rules.py +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env python3 -# /// script -# requires-python = ">=3.12" -# /// -"""Keep .cursor/rules/*.mdc symlinks in sync with .agents/rules/*.md. - -The canonical rules live in .agents/rules/.md; Cursor reads them from -.cursor/rules/.mdc, so each rule needs a .mdc symlink pointing back at its -.md. This validates that every rule has a correct symlink and that no symlink is -left dangling. Run with --fix to create missing symlinks and drop stale ones. - -Non-symlink .mdc files (e.g. the standalone 00-agents-context.mdc bootstrap) are -not mirrors of a rule and are left untouched. -""" - -import os -import sys - -AGENTS_RULES = ".agents/rules" -CURSOR_RULES = ".cursor/rules" - - -def link_target(stem): - # The .mdc lives in .cursor/rules/, so ../../ reaches the repo root. - return f"../../{AGENTS_RULES}/{stem}.md" - - -def main(): - fix = "--fix" in sys.argv - - stems = sorted(f[:-3] for f in os.listdir(AGENTS_RULES) if f.endswith(".md")) - problems = [] - - # Every rule must have a .mdc symlink pointing at its .md. - for stem in stems: - mdc = os.path.join(CURSOR_RULES, stem + ".mdc") - want = link_target(stem) - have = os.readlink(mdc) if os.path.islink(mdc) else None - if have == want: - continue - if fix: - if os.path.lexists(mdc): - os.remove(mdc) - os.symlink(want, mdc) - print(f"Linked {mdc} -> {want}") - else: - problems.append(f"{mdc}: missing or wrong symlink, expected -> {want}") - - # No .mdc symlink may point at a rule that no longer exists. - known = {stem + ".mdc" for stem in stems} - for name in sorted(os.listdir(CURSOR_RULES)): - path = os.path.join(CURSOR_RULES, name) - if not os.path.islink(path) or name in known: - continue - if fix: - os.remove(path) - print(f"Removed stale {path}") - else: - problems.append(f"{path}: stale symlink, no matching {AGENTS_RULES}/ rule") - - if problems: - print("\n".join(problems)) - print( - f"\n{len(problems)} problem(s). Run: ./tools/validate_cursor_rules.py --fix", - file=sys.stderr, - ) - return 1 - return 0 - - -if __name__ == "__main__": - sys.exit(main())