From 6d2b75d377d2130f8467cae8e9d97e0ac5d1b55d Mon Sep 17 00:00:00 2001 From: Sankalp-Mittal Date: Mon, 31 Aug 2026 09:25:02 +0000 Subject: [PATCH 1/4] add regression guard --- .../python/public-api/dump_public_api.py | 205 ++++++++++++++++++ .../bundle/python/public-api/out.test.toml | 3 + .../bundle/python/public-api/output.txt | 146 +++++++++++++ acceptance/bundle/python/public-api/script | 5 + acceptance/bundle/python/public-api/test.toml | 8 + 5 files changed, 367 insertions(+) create mode 100644 acceptance/bundle/python/public-api/dump_public_api.py create mode 100644 acceptance/bundle/python/public-api/out.test.toml create mode 100644 acceptance/bundle/python/public-api/output.txt create mode 100644 acceptance/bundle/python/public-api/script create mode 100644 acceptance/bundle/python/public-api/test.toml diff --git a/acceptance/bundle/python/public-api/dump_public_api.py b/acceptance/bundle/python/public-api/dump_public_api.py new file mode 100644 index 00000000000..1f2a72e3359 --- /dev/null +++ b/acceptance/bundle/python/public-api/dump_public_api.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +"""Snapshot the typed public API surface of databricks.bundles.core, for regression-guarding. + +Usage (from the acceptance script in this directory, inside the databricks-bundles uv env): + + uv run --python 3.11 $UV_ARGS python dump_public_api.py + +Why only core: the resource model namespaces (jobs, pipelines, ...) are entirely +pydabs-codegen output, already guarded byte-for-byte by CI's `generate-check`. The core +wiring — Resources, the *_mutator functions, the _ResourceType registry, __all__ — is +HAND-WRITTEN and is exactly what the codegen/wiring refactor converts to generated code. +This dumps that surface to a golden `output.txt` so the refactor (and future ones) can't +silently drop a type hint, move a `*` marker, rename a method, or change the export set. + +Determinism notes: + * Types are rendered by their PUBLIC SHORT NAME (`Variable[str]`, `Location`, `None`) + rather than `inspect`/`repr`'s fully-qualified internal module path + (`databricks.bundles.core._variable.Variable`). This is deliberate: the refactor moves + internal modules around, and a golden keyed on internal paths would fail even when the + public API is unchanged. Short names change only when the public type actually changes. + * Signatures are reconstructed from `inspect.Signature` (not its string form) so + positional-only `/`, keyword-only `*`, `*args` and `**kwargs` markers render explicitly. + * Module symbols, methods and properties are sorted; declaration order is preserved only + where it is part of the contract (dataclass fields, enum members). + * The uv invocation pins `--python 3.11` so the golden generated locally with `-update` + matches CI, since resolved type reprs can differ across versions. +""" + +import collections.abc +import dataclasses +import enum +import inspect +import sys +import types +import typing + + +def render_type(t) -> str: + """Render a type annotation by public short name, module-location independent.""" + if t is None or t is type(None): + return "None" + if t is Ellipsis: + return "..." + if isinstance(t, str): + # A forward-ref written as a string literal in the source (e.g. "JobParam"). + return t + if isinstance(t, typing.ForwardRef): + return t.__forward_arg__ + if isinstance(t, typing.TypeVar): + return t.__name__ + + origin = typing.get_origin(t) + args = typing.get_args(t) + + if origin is not None: + if origin is typing.Union or origin is types.UnionType: + return "Union[" + ", ".join(render_type(a) for a in args) + "]" + if origin is typing.Literal: + return "Literal[" + ", ".join(repr(a) for a in args) + "]" + if origin is collections.abc.Callable: + if not args: + return "Callable" + # get_args(Callable[[int], str]) == ([int], str); [0] is the arg list. + params, ret = args[0], args[-1] + params_str = "..." if params is Ellipsis else "[" + ", ".join(render_type(a) for a in params) + "]" + return "Callable[" + params_str + ", " + render_type(ret) + "]" + name = _short_name(origin) + if args: + return name + "[" + ", ".join(render_type(a) for a in args) + "]" + return name + + return _short_name(t) + + +def _short_name(t) -> str: + return getattr(t, "__name__", None) or getattr(t, "_name", None) or str(t) + + +def render_signature(func) -> str: + """Reconstruct a signature string with explicit / * ** markers and short types.""" + sig = inspect.signature(func) + parts = [] + last_kind = None + emitted_star = False + for p in sig.parameters.values(): + if last_kind == inspect.Parameter.POSITIONAL_ONLY and p.kind != inspect.Parameter.POSITIONAL_ONLY: + parts.append("/") + if p.kind == inspect.Parameter.KEYWORD_ONLY and not emitted_star: + parts.append("*") + emitted_star = True + + s = p.name + if p.kind == inspect.Parameter.VAR_POSITIONAL: + s = "*" + s + emitted_star = True + elif p.kind == inspect.Parameter.VAR_KEYWORD: + s = "**" + s + + if p.annotation is not inspect.Parameter.empty: + s += ": " + render_type(p.annotation) + if p.default is not inspect.Parameter.empty: + sep = " = " if p.annotation is not inspect.Parameter.empty else "=" + s += sep + repr(p.default) + parts.append(s) + last_kind = p.kind + + if last_kind == inspect.Parameter.POSITIONAL_ONLY: + parts.append("/") + + ret = "" + if sig.return_annotation is not inspect.Signature.empty: + ret = " -> " + render_type(sig.return_annotation) + return "(" + ", ".join(parts) + ")" + ret + + +def _bases(cls) -> str: + names = [b.__name__ for b in cls.__bases__ if b is not object] + return "(" + ", ".join(names) + ")" if names else "" + + +def _members(cls, predicate): + return sorted((name, obj) for name, obj in inspect.getmembers(cls, predicate) if not name.startswith("_")) + + +def render_class(name, cls, out): + if isinstance(cls, type) and issubclass(cls, enum.Enum): + out.append(f"class {name}(Enum):") + for member in cls: + out.append(f" {member.name} = {member.value!r}") + out.append("") + return + + out.append(f"class {name}{_bases(cls)}:") + if dataclasses.is_dataclass(cls): + for f in dataclasses.fields(cls): + line = f" {f.name}: {render_type(f.type)}" + if f.default is not dataclasses.MISSING: + line += f" = {f.default!r}" + elif f.default_factory is not dataclasses.MISSING: + line += " = " + out.append(line) + + # classmethods (e.g. create_error) surface as bound methods, not plain functions. + for m_name, m in _members(cls, inspect.ismethod): + out.append(f" @classmethod def {m_name}{render_signature(m)}") + for m_name, m in _members(cls, inspect.isfunction): + out.append(f" def {m_name}{render_signature(m)}") + for p_name, prop in _members(cls, lambda x: isinstance(x, property)): + ret = "" + if prop.fget is not None: + r = inspect.signature(prop.fget).return_annotation + if r is not inspect.Signature.empty: + ret = " -> " + render_type(r) + out.append(f" @property {p_name}{ret}") + out.append("") + + +def render_symbol(name, obj, out): + if inspect.isclass(obj): + render_class(name, obj, out) + elif inspect.isfunction(obj): + overloads = typing.get_overloads(obj) + for ov in overloads: + out.append(f"@overload def {name}{render_signature(ov)}") + out.append(f"def {name}{render_signature(obj)}") + out.append("") + else: + # Type aliases (VariableOr*), rendered by structure. + out.append(f"{name} = {render_type(obj)}") + out.append("") + + +def render_registry(out): + # _ResourceType is intentionally not exported from core, but the registry it builds is + # part of the wiring the refactor regenerates, so snapshot it too. + from databricks.bundles.core._resource_type import _ResourceType + + out.append("== _ResourceType.all() registry ==") + for rt in sorted(_ResourceType.all(), key=lambda rt: rt.singular_name): + out.append( + f"singular_name={rt.singular_name} plural_name={rt.plural_name} resource_type={rt.resource_type.__name__}" + ) + out.append("") + + +def main(): + import databricks.bundles.core as core + + out = ["== module databricks.bundles.core =="] + out.append("__all__ = [") + for name in sorted(core.__all__): + out.append(f" {name},") + out.append("]") + out.append("") + + for name in sorted(core.__all__): + render_symbol(name, getattr(core, name), out) + + render_registry(out) + + sys.stdout.write("\n".join(out) + "\n") + + +if __name__ == "__main__": + main() diff --git a/acceptance/bundle/python/public-api/out.test.toml b/acceptance/bundle/python/public-api/out.test.toml new file mode 100644 index 00000000000..7c0f9bcacf5 --- /dev/null +++ b/acceptance/bundle/python/public-api/out.test.toml @@ -0,0 +1,3 @@ +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] +EnvMatrix.PYDAB_VERSION = ["current"] diff --git a/acceptance/bundle/python/public-api/output.txt b/acceptance/bundle/python/public-api/output.txt new file mode 100644 index 00000000000..30368b627f5 --- /dev/null +++ b/acceptance/bundle/python/public-api/output.txt @@ -0,0 +1,146 @@ +== module databricks.bundles.core == +__all__ = [ + Bundle, + Diagnostic, + Diagnostics, + Location, + Resource, + ResourceMutator, + Resources, + Severity, + Variable, + VariableOr, + VariableOrDict, + VariableOrList, + VariableOrOptional, + alert_mutator, + catalog_mutator, + job_mutator, + load_resources_from_current_package_module, + load_resources_from_module, + load_resources_from_modules, + load_resources_from_package_module, + pipeline_mutator, + schema_mutator, + variables, + volume_mutator, +] + +class Bundle: + target: str + variables: dict[str, Any] = + def resolve_variable(self, variable: Union[Variable[_T], _T]) -> _T + def resolve_variable_list(self, variable: Union[Variable[list[Union[Variable[_T], _T]]], list[Union[Variable[_T], _T]]]) -> list[_T] + +class Diagnostic: + severity: Severity + summary: str + detail: Union[str, None] = None + path: Union[tuple[str, ...], None] = None + location: Union[Location, None] = None + def as_dict(self) -> dict + +class Diagnostics: + items: tuple[Diagnostic, ...] = + @classmethod def create_error(msg: str, *, detail: Union[str, None] = None, location: Union[Location, None] = None, path: Union[tuple[str, ...], None] = None) -> Self + @classmethod def create_warning(msg: str, *, detail: Union[str, None] = None, location: Union[Location, None] = None, path: Union[tuple[str, ...], None] = None) -> Self + @classmethod def from_exception(exc: Exception, *, summary: str, location: Union[Location, None] = None, path: Union[tuple[str, ...], None] = None, explanation: Union[str, None] = None) -> Self + def extend(self, diagnostics: Self) -> Self + def extend_tuple(self, pair: tuple[_T, Self]) -> tuple[_T, Self] + def has_error(self) -> bool + def has_warning(self) -> bool + +class Location: + file: str + line: Union[int, None] = None + column: Union[int, None] = None + def as_dict(self) -> dict + def from_callable(fn: Callable) -> Union[Location, None] + def from_stack_frame(depth: int = 0) -> Location + +class Resource: + +class ResourceMutator(Generic): + resource_type: type[_T] + function: Callable + +class Resources: + def add_alert(self, resource_name: str, alert: AlertParam, *, location: Union[Location, None] = None) -> None + def add_catalog(self, resource_name: str, catalog: CatalogParam, *, location: Union[Location, None] = None) -> None + def add_diagnostic_error(self, msg: str, *, detail: Union[str, None] = None, path: Union[tuple[str, ...], None] = None, location: Union[Location, None] = None) -> None + def add_diagnostic_warning(self, msg: str, *, detail: Union[str, None] = None, path: Union[tuple[str, ...], None] = None, location: Union[Location, None] = None) -> None + def add_diagnostics(self, other: Diagnostics) -> None + def add_job(self, resource_name: str, job: JobParam, *, location: Union[Location, None] = None) -> None + def add_location(self, path: tuple[str, ...], location: Location) -> None + def add_pipeline(self, resource_name: str, pipeline: PipelineParam, *, location: Union[Location, None] = None) -> None + def add_resource(self, resource_name: str, resource: Resource, *, location: Union[Location, None] = None) -> None + def add_resources(self, other: Resources) -> None + def add_schema(self, resource_name: str, schema: SchemaParam, *, location: Union[Location, None] = None) -> None + def add_volume(self, resource_name: str, volume: VolumeParam, *, location: Union[Location, None] = None) -> None + @property alerts -> dict[str, Alert] + @property catalogs -> dict[str, Catalog] + @property diagnostics -> Diagnostics + @property jobs -> dict[str, Job] + @property pipelines -> dict[str, Pipeline] + @property schemas -> dict[str, Schema] + @property volumes -> dict[str, Volume] + +class Severity(Enum): + WARNING = 'warning' + ERROR = 'error' + +class Variable(Generic): + path: str + type: type[_T] + @property value -> str + +VariableOr = Union[Variable[_T], _T] + +VariableOrDict = Union[Variable[dict[str, Union[Variable[_T], _T]]], dict[str, Union[Variable[_T], _T]]] + +VariableOrList = Union[Variable[list[Union[Variable[_T], _T]]], list[Union[Variable[_T], _T]]] + +VariableOrOptional = Union[Variable[_T], _T, None] + +@overload def alert_mutator(function: Callable[[Bundle, Alert], Alert]) -> ResourceMutator[Alert] +@overload def alert_mutator(function: Callable[[Alert], Alert]) -> ResourceMutator[Alert] +def alert_mutator(function: Callable) -> ResourceMutator[Alert] + +@overload def catalog_mutator(function: Callable[[Bundle, Catalog], Catalog]) -> ResourceMutator[Catalog] +@overload def catalog_mutator(function: Callable[[Catalog], Catalog]) -> ResourceMutator[Catalog] +def catalog_mutator(function: Callable) -> ResourceMutator[Catalog] + +@overload def job_mutator(function: Callable[[Bundle, Job], Job]) -> ResourceMutator[Job] +@overload def job_mutator(function: Callable[[Job], Job]) -> ResourceMutator[Job] +def job_mutator(function: Callable) -> ResourceMutator[Job] + +def load_resources_from_current_package_module() -> Resources + +def load_resources_from_module(module: module) -> Resources + +def load_resources_from_modules(modules: Iterable[module]) -> Resources + +def load_resources_from_package_module(package_module: module) -> Resources + +@overload def pipeline_mutator(function: Callable[[Bundle, Pipeline], Pipeline]) -> ResourceMutator[Pipeline] +@overload def pipeline_mutator(function: Callable[[Pipeline], Pipeline]) -> ResourceMutator[Pipeline] +def pipeline_mutator(function: Callable) -> ResourceMutator[Pipeline] + +@overload def schema_mutator(function: Callable[[Bundle, Schema], Schema]) -> ResourceMutator[Schema] +@overload def schema_mutator(function: Callable[[Schema], Schema]) -> ResourceMutator[Schema] +def schema_mutator(function: Callable) -> ResourceMutator[Schema] + +def variables(cls: type[_T]) -> type[_T] + +@overload def volume_mutator(function: Callable[[Bundle, Volume], Volume]) -> ResourceMutator[Volume] +@overload def volume_mutator(function: Callable[[Volume], Volume]) -> ResourceMutator[Volume] +def volume_mutator(function: Callable) -> ResourceMutator[Volume] + +== _ResourceType.all() registry == +singular_name=alert plural_name=alerts resource_type=Alert +singular_name=catalog plural_name=catalogs resource_type=Catalog +singular_name=job plural_name=jobs resource_type=Job +singular_name=pipeline plural_name=pipelines resource_type=Pipeline +singular_name=schema plural_name=schemas resource_type=Schema +singular_name=volume plural_name=volumes resource_type=Volume + diff --git a/acceptance/bundle/python/public-api/script b/acceptance/bundle/python/public-api/script new file mode 100644 index 00000000000..c7bba296195 --- /dev/null +++ b/acceptance/bundle/python/public-api/script @@ -0,0 +1,5 @@ +# Snapshot the typed public API of databricks.bundles.core (see dump_public_api.py, checked in +# alongside this test and copied into the run dir). +# --python 3.11 is pinned deliberately: the snapshot must stay reproducible independent of the +# repo-wide UV_PYTHON minimum, and the dump uses typing.get_overloads (Python 3.11+). +uv run --python 3.11 -q $UV_ARGS python dump_public_api.py diff --git a/acceptance/bundle/python/public-api/test.toml b/acceptance/bundle/python/public-api/test.toml new file mode 100644 index 00000000000..1f626427771 --- /dev/null +++ b/acceptance/bundle/python/public-api/test.toml @@ -0,0 +1,8 @@ +Cloud = false # introspects the typed API in-process; never touches an API + +# The public API is only meaningful for the wheel built from this commit. +EnvMatrix.PYDAB_VERSION = ["current"] + +# This test never invokes $CLI, so the deployment engine is irrelevant; pin a single +# engine so we don't run two identical variants. +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] From 962236eee4b77d2dfcca5625cfa2d6b903f2cf28 Mon Sep 17 00:00:00 2001 From: Sankalp-Mittal Date: Mon, 31 Aug 2026 09:43:37 +0000 Subject: [PATCH 2/4] Ignore private bases in the public API dump _bases() filtered out `object` but not underscore-prefixed private bases, so a generated private base (e.g. _GeneratedResources introduced by the wiring refactor) would surface in the golden as a false positive even though the public contract is intact. Mirror _members()'s underscore filter. No-op for the current golden on this branch, where Resources has no private base. Co-authored-by: Isaac --- acceptance/bundle/python/public-api/dump_public_api.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/acceptance/bundle/python/public-api/dump_public_api.py b/acceptance/bundle/python/public-api/dump_public_api.py index 1f2a72e3359..7faa225b7ff 100644 --- a/acceptance/bundle/python/public-api/dump_public_api.py +++ b/acceptance/bundle/python/public-api/dump_public_api.py @@ -114,7 +114,9 @@ def render_signature(func) -> str: def _bases(cls) -> str: - names = [b.__name__ for b in cls.__bases__ if b is not object] + # Skip object and private (underscore) bases, mirroring _members(): a generated private + # base like _GeneratedResources is an implementation detail, not the public contract. + names = [b.__name__ for b in cls.__bases__ if b is not object and not b.__name__.startswith("_")] return "(" + ", ".join(names) + ")" if names else "" From d25d843600c1a65cbc251e5633384d879dad629a Mon Sep 17 00:00:00 2001 From: Sankalp-Mittal Date: Thu, 3 Sep 2026 08:52:20 +0000 Subject: [PATCH 3/4] Move public API guard to databricks_tests as a pytest snapshot The core public-API snapshot doesn't exercise the CLI end to end, so it belongs with the other pure-Python pydabs tests rather than in acceptance/. Move it to databricks_tests/core/test_public_api.py, comparing against a committed golden (regenerate with UPDATE_SNAPSHOTS=1). Gate on Python >= 3.11 (via skipif, and a sys.version_info guard so pyright at 3.10 is happy) instead of pinning an exact interpreter, since typing.get_overloads needs 3.11+ and the output is identical on 3.11/3.12/3.13. Removes the acceptance/bundle/python/public-api test. Co-authored-by: Isaac --- .../bundle/python/public-api/out.test.toml | 3 - acceptance/bundle/python/public-api/script | 5 - acceptance/bundle/python/public-api/test.toml | 8 -- .../databricks_tests/core/public_api.txt | 0 .../databricks_tests/core/test_public_api.py | 105 +++++++++++------- 5 files changed, 65 insertions(+), 56 deletions(-) delete mode 100644 acceptance/bundle/python/public-api/out.test.toml delete mode 100644 acceptance/bundle/python/public-api/script delete mode 100644 acceptance/bundle/python/public-api/test.toml rename acceptance/bundle/python/public-api/output.txt => python/databricks_tests/core/public_api.txt (100%) rename acceptance/bundle/python/public-api/dump_public_api.py => python/databricks_tests/core/test_public_api.py (67%) diff --git a/acceptance/bundle/python/public-api/out.test.toml b/acceptance/bundle/python/public-api/out.test.toml deleted file mode 100644 index 7c0f9bcacf5..00000000000 --- a/acceptance/bundle/python/public-api/out.test.toml +++ /dev/null @@ -1,3 +0,0 @@ -Cloud = false -EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] -EnvMatrix.PYDAB_VERSION = ["current"] diff --git a/acceptance/bundle/python/public-api/script b/acceptance/bundle/python/public-api/script deleted file mode 100644 index c7bba296195..00000000000 --- a/acceptance/bundle/python/public-api/script +++ /dev/null @@ -1,5 +0,0 @@ -# Snapshot the typed public API of databricks.bundles.core (see dump_public_api.py, checked in -# alongside this test and copied into the run dir). -# --python 3.11 is pinned deliberately: the snapshot must stay reproducible independent of the -# repo-wide UV_PYTHON minimum, and the dump uses typing.get_overloads (Python 3.11+). -uv run --python 3.11 -q $UV_ARGS python dump_public_api.py diff --git a/acceptance/bundle/python/public-api/test.toml b/acceptance/bundle/python/public-api/test.toml deleted file mode 100644 index 1f626427771..00000000000 --- a/acceptance/bundle/python/public-api/test.toml +++ /dev/null @@ -1,8 +0,0 @@ -Cloud = false # introspects the typed API in-process; never touches an API - -# The public API is only meaningful for the wheel built from this commit. -EnvMatrix.PYDAB_VERSION = ["current"] - -# This test never invokes $CLI, so the deployment engine is irrelevant; pin a single -# engine so we don't run two identical variants. -EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/bundle/python/public-api/output.txt b/python/databricks_tests/core/public_api.txt similarity index 100% rename from acceptance/bundle/python/public-api/output.txt rename to python/databricks_tests/core/public_api.txt diff --git a/acceptance/bundle/python/public-api/dump_public_api.py b/python/databricks_tests/core/test_public_api.py similarity index 67% rename from acceptance/bundle/python/public-api/dump_public_api.py rename to python/databricks_tests/core/test_public_api.py index 7faa225b7ff..744ea75c0ea 100644 --- a/acceptance/bundle/python/public-api/dump_public_api.py +++ b/python/databricks_tests/core/test_public_api.py @@ -1,38 +1,44 @@ -#!/usr/bin/env python3 -"""Snapshot the typed public API surface of databricks.bundles.core, for regression-guarding. +"""Regression guard for the typed public API surface of databricks.bundles.core. -Usage (from the acceptance script in this directory, inside the databricks-bundles uv env): +The core wiring — Resources, the *_mutator functions, the _ResourceType registry, +__all__ — is hand-written (the resource namespaces are pydabs-codegen output, already +guarded by generate-check). This snapshots the core public surface to a golden file so a +refactor can't silently drop a type hint, move a `*` marker, rename a method, or change +the export set. - uv run --python 3.11 $UV_ARGS python dump_public_api.py +Regenerate the golden after an intended public-API change: -Why only core: the resource model namespaces (jobs, pipelines, ...) are entirely -pydabs-codegen output, already guarded byte-for-byte by CI's `generate-check`. The core -wiring — Resources, the *_mutator functions, the _ResourceType registry, __all__ — is -HAND-WRITTEN and is exactly what the codegen/wiring refactor converts to generated code. -This dumps that surface to a golden `output.txt` so the refactor (and future ones) can't -silently drop a type hint, move a `*` marker, rename a method, or change the export set. + UPDATE_SNAPSHOTS=1 uv run pytest databricks_tests/core/test_public_api.py -Determinism notes: +Determinism / version notes: * Types are rendered by their PUBLIC SHORT NAME (`Variable[str]`, `Location`, `None`) - rather than `inspect`/`repr`'s fully-qualified internal module path - (`databricks.bundles.core._variable.Variable`). This is deliberate: the refactor moves - internal modules around, and a golden keyed on internal paths would fail even when the - public API is unchanged. Short names change only when the public type actually changes. - * Signatures are reconstructed from `inspect.Signature` (not its string form) so - positional-only `/`, keyword-only `*`, `*args` and `**kwargs` markers render explicitly. - * Module symbols, methods and properties are sorted; declaration order is preserved only - where it is part of the contract (dataclass fields, enum members). - * The uv invocation pins `--python 3.11` so the golden generated locally with `-update` - matches CI, since resolved type reprs can differ across versions. + rather than repr's fully-qualified internal module path — so moving an internal + `_`-module doesn't perturb the golden; only a real public-API change does. + * Signatures are reconstructed from inspect.Signature so `/`, `*`, `*args`, `**kwargs` + markers render explicitly and stably. + * Requires Python >= 3.11 for typing.get_overloads (the *_mutator overloads). Output is + identical on 3.11/3.12/3.13, so the single golden holds across those versions. """ import collections.abc import dataclasses import enum import inspect +import os import sys import types import typing +from pathlib import Path + +import pytest + +import databricks.bundles.core as core + +_GOLDEN = Path(__file__).parent / "public_api.txt" + + +def _short_name(t) -> str: + return getattr(t, "__name__", None) or getattr(t, "_name", None) or str(t) def render_type(t) -> str: @@ -62,7 +68,11 @@ def render_type(t) -> str: return "Callable" # get_args(Callable[[int], str]) == ([int], str); [0] is the arg list. params, ret = args[0], args[-1] - params_str = "..." if params is Ellipsis else "[" + ", ".join(render_type(a) for a in params) + "]" + params_str = ( + "..." + if params is Ellipsis + else "[" + ", ".join(render_type(a) for a in params) + "]" + ) return "Callable[" + params_str + ", " + render_type(ret) + "]" name = _short_name(origin) if args: @@ -72,10 +82,6 @@ def render_type(t) -> str: return _short_name(t) -def _short_name(t) -> str: - return getattr(t, "__name__", None) or getattr(t, "_name", None) or str(t) - - def render_signature(func) -> str: """Reconstruct a signature string with explicit / * ** markers and short types.""" sig = inspect.signature(func) @@ -83,7 +89,10 @@ def render_signature(func) -> str: last_kind = None emitted_star = False for p in sig.parameters.values(): - if last_kind == inspect.Parameter.POSITIONAL_ONLY and p.kind != inspect.Parameter.POSITIONAL_ONLY: + if ( + last_kind == inspect.Parameter.POSITIONAL_ONLY + and p.kind != inspect.Parameter.POSITIONAL_ONLY + ): parts.append("/") if p.kind == inspect.Parameter.KEYWORD_ONLY and not emitted_star: parts.append("*") @@ -116,15 +125,23 @@ def render_signature(func) -> str: def _bases(cls) -> str: # Skip object and private (underscore) bases, mirroring _members(): a generated private # base like _GeneratedResources is an implementation detail, not the public contract. - names = [b.__name__ for b in cls.__bases__ if b is not object and not b.__name__.startswith("_")] + names = [ + b.__name__ + for b in cls.__bases__ + if b is not object and not b.__name__.startswith("_") + ] return "(" + ", ".join(names) + ")" if names else "" def _members(cls, predicate): - return sorted((name, obj) for name, obj in inspect.getmembers(cls, predicate) if not name.startswith("_")) + return sorted( + (name, obj) + for name, obj in inspect.getmembers(cls, predicate) + if not name.startswith("_") + ) -def render_class(name, cls, out): +def render_class(name, cls, out: list[str]) -> None: if isinstance(cls, type) and issubclass(cls, enum.Enum): out.append(f"class {name}(Enum):") for member in cls: @@ -157,11 +174,12 @@ def render_class(name, cls, out): out.append("") -def render_symbol(name, obj, out): +def render_symbol(name, obj, out: list[str]) -> None: if inspect.isclass(obj): render_class(name, obj, out) elif inspect.isfunction(obj): - overloads = typing.get_overloads(obj) + # typing.get_overloads is 3.11+; the test is skipped below on older versions. + overloads = typing.get_overloads(obj) if sys.version_info >= (3, 11) else [] for ov in overloads: out.append(f"@overload def {name}{render_signature(ov)}") out.append(f"def {name}{render_signature(obj)}") @@ -172,9 +190,9 @@ def render_symbol(name, obj, out): out.append("") -def render_registry(out): +def render_registry(out: list[str]) -> None: # _ResourceType is intentionally not exported from core, but the registry it builds is - # part of the wiring the refactor regenerates, so snapshot it too. + # part of the wiring a refactor regenerates, so snapshot it too. from databricks.bundles.core._resource_type import _ResourceType out.append("== _ResourceType.all() registry ==") @@ -185,9 +203,7 @@ def render_registry(out): out.append("") -def main(): - import databricks.bundles.core as core - +def dump_core_public_api() -> str: out = ["== module databricks.bundles.core =="] out.append("__all__ = [") for name in sorted(core.__all__): @@ -200,8 +216,17 @@ def main(): render_registry(out) - sys.stdout.write("\n".join(out) + "\n") + return "\n".join(out) + "\n" -if __name__ == "__main__": - main() +@pytest.mark.skipif( + sys.version_info < (3, 11), reason="typing.get_overloads requires Python 3.11+" +) +def test_core_public_api(): + actual = dump_core_public_api() + if os.environ.get("UPDATE_SNAPSHOTS"): + _GOLDEN.write_text(actual) + assert actual == _GOLDEN.read_text(), ( + "databricks.bundles.core public API changed. If intended, regenerate with " + "UPDATE_SNAPSHOTS=1 uv run pytest databricks_tests/core/test_public_api.py" + ) From 3551a20cacb1f2fee2b4bf75463f5b38f2ad66d1 Mon Sep 17 00:00:00 2001 From: Sankalp-Mittal Date: Thu, 3 Sep 2026 09:02:10 +0000 Subject: [PATCH 4/4] Drop trailing blank line from the public API golden The whitespace linter (task checks -> ws) strips a trailing blank line and then git diff --exit-code fails; in acceptance/ the golden was in the ws skip-list, but as a committed .txt under python/ it is checked. Emit a single trailing newline. Co-authored-by: Isaac --- python/databricks_tests/core/public_api.txt | 1 - python/databricks_tests/core/test_public_api.py | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/python/databricks_tests/core/public_api.txt b/python/databricks_tests/core/public_api.txt index 30368b627f5..222665796df 100644 --- a/python/databricks_tests/core/public_api.txt +++ b/python/databricks_tests/core/public_api.txt @@ -143,4 +143,3 @@ singular_name=job plural_name=jobs resource_type=Job singular_name=pipeline plural_name=pipelines resource_type=Pipeline singular_name=schema plural_name=schemas resource_type=Schema singular_name=volume plural_name=volumes resource_type=Volume - diff --git a/python/databricks_tests/core/test_public_api.py b/python/databricks_tests/core/test_public_api.py index 744ea75c0ea..3ef35c24be4 100644 --- a/python/databricks_tests/core/test_public_api.py +++ b/python/databricks_tests/core/test_public_api.py @@ -216,7 +216,8 @@ def dump_core_public_api() -> str: render_registry(out) - return "\n".join(out) + "\n" + # Single trailing newline, no blank last line (the whitespace linter strips it). + return "\n".join(out).rstrip("\n") + "\n" @pytest.mark.skipif(