-
Notifications
You must be signed in to change notification settings - Fork 221
Add public API regression guard for databricks.bundles.core #6439
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
+378
−0
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
6d2b75d
add regression guard
Sankalp-Mittal 962236e
Ignore private bases in the public API dump
Sankalp-Mittal d25d843
Move public API guard to databricks_tests as a pytest snapshot
Sankalp-Mittal 3551a20
Drop trailing blank line from the public API golden
Sankalp-Mittal File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,145 @@ | ||
| == 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] = <factory> | ||
| 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, ...] = <factory> | ||
| @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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,233 @@ | ||
| """Regression guard for the typed public API surface of databricks.bundles.core. | ||
|
|
||
| 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. | ||
|
|
||
| Regenerate the golden after an intended public-API change: | ||
|
|
||
| UPDATE_SNAPSHOTS=1 uv run pytest databricks_tests/core/test_public_api.py | ||
|
|
||
| Determinism / version notes: | ||
| * Types are rendered by their PUBLIC SHORT NAME (`Variable[str]`, `Location`, `None`) | ||
| 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: | ||
| """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 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: | ||
| # 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 "" | ||
|
|
||
|
|
||
| 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: list[str]) -> None: | ||
| 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 += " = <factory>" | ||
| 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: list[str]) -> None: | ||
| if inspect.isclass(obj): | ||
| render_class(name, obj, out) | ||
| elif inspect.isfunction(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)}") | ||
| out.append("") | ||
| else: | ||
| # Type aliases (VariableOr*), rendered by structure. | ||
| out.append(f"{name} = {render_type(obj)}") | ||
| out.append("") | ||
|
|
||
|
|
||
| def render_registry(out: list[str]) -> None: | ||
| # _ResourceType is intentionally not exported from core, but the registry it builds is | ||
| # part of the wiring a 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 dump_core_public_api() -> str: | ||
| 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) | ||
|
|
||
| # Single trailing newline, no blank last line (the whitespace linter strips it). | ||
| return "\n".join(out).rstrip("\n") + "\n" | ||
|
|
||
|
|
||
| @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" | ||
| ) | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.