blob: 1d7b9510a8044174445ec8bca88b64bd5085d493 [file]
#!/usr/bin/env python
#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "rich>=13.6.0",
# ]
# ///
"""
Verify the shared surface of ``window.py`` stays in sync between core and the Task SDK.
The ``Window`` class hierarchy is defined twice — once in core and once in the SDK —
because the two hierarchies are independent (the SDK cannot import core). The SDK copy
is the author-facing API used at Dag-parse time; the core copy carries the scheduler-side
runtime logic. Both must agree on the shared surface so serialization round-trips and
direction semantics behave identically wherever the classes are instantiated.
This check parses both files via AST and asserts the following are identical:
- ``Window.Direction`` enum member values
- The set of shared class names (Direction, Window, HourWindow, DayWindow,
WeekWindow, MonthWindow, QuarterWindow, YearWindow)
- ``Window.__init__`` ``direction`` kwarg default
- ``Window.__init__`` body, ``Window.serialize`` body, ``Window.deserialize`` body
- Each subclass ``expected_decoded_type`` ClassVar value
Core-only symbols (``ABC`` base, ``abstractmethod to_upstream``, helper functions,
imports, docstrings) are intentionally excluded from the comparison.
Run from the repo root:
uv run --project scripts python scripts/ci/prek/check_window_in_sync.py
Exits 0 if synced, 1 (with a diff) otherwise.
"""
from __future__ import annotations
import ast
import sys
from pathlib import Path
from common_prek_utils import (
AIRFLOW_CORE_SOURCES_PATH,
AIRFLOW_TASK_SDK_SOURCES_PATH,
)
from rich.console import Console
console = Console(color_system="standard", width=200)
CORE_FILE = AIRFLOW_CORE_SOURCES_PATH / "airflow" / "partition_mappers" / "window.py"
SDK_FILE = (
AIRFLOW_TASK_SDK_SOURCES_PATH / "airflow" / "sdk" / "definitions" / "partition_mappers" / "window.py"
)
EXPECTED_CLASS_NAMES = [
"Direction",
"Window",
"HourWindow",
"DayWindow",
"WeekWindow",
"MonthWindow",
"QuarterWindow",
"YearWindow",
]
def _parse(file_path: Path) -> ast.Module:
return ast.parse(file_path.read_text(encoding="utf-8"), filename=str(file_path))
def _find_class(tree: ast.Module, name: str, file_path: Path) -> ast.ClassDef:
"""Return the ClassDef named *name* from *tree*; raise ValueError if absent."""
for node in ast.walk(tree):
if isinstance(node, ast.ClassDef) and node.name == name:
return node
raise ValueError(f"{file_path}: no class {name!r} found.")
def _extract_enum_members(tree: ast.Module, file_path: Path) -> dict[str, str]:
"""Return the ``{member_name: value}`` dict for ``Direction``."""
cls = _find_class(tree, "Direction", file_path)
members: dict[str, str] = {}
for stmt in cls.body:
if (
isinstance(stmt, ast.Assign)
and len(stmt.targets) == 1
and isinstance(stmt.targets[0], ast.Name)
and isinstance(stmt.value, ast.Constant)
and isinstance(stmt.value.value, str)
):
members[stmt.targets[0].id] = stmt.value.value
return members
def _find_method(
class_def: ast.ClassDef, method_name: str, file_path: Path
) -> ast.FunctionDef | ast.AsyncFunctionDef:
"""Return the method named *method_name* from *class_def*; raise ValueError if absent."""
for stmt in class_def.body:
if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef)) and stmt.name == method_name:
return stmt
raise ValueError(f"{file_path}: class {class_def.name!r} has no method {method_name!r}.")
def _extract_method_source(class_def: ast.ClassDef, method_name: str, file_path: Path) -> str:
"""Return the unparsed body of *method_name* in *class_def*."""
method = _find_method(class_def, method_name, file_path)
return "\n".join(ast.unparse(stmt) for stmt in method.body)
def _extract_direction_default(class_def: ast.ClassDef, file_path: Path) -> str:
"""Return the ``ast.unparse`` of the ``direction`` kwarg default in ``Window.__init__``."""
init = _find_method(class_def, "__init__", file_path)
args = init.args
for i, kwarg in enumerate(args.kwonlyargs):
if kwarg.arg == "direction":
default = args.kw_defaults[i]
if default is None:
raise ValueError(
f"{file_path}: Window.__init__ 'direction' kwarg has no default — "
"expected Direction.FORWARD."
)
return ast.unparse(default)
raise ValueError(f"{file_path}: Window.__init__ has no 'direction' kwonly parameter.")
def _extract_decoded_types(tree: ast.Module, file_path: Path) -> dict[str, str]:
"""Return ``{class_name: ast.unparse(expected_decoded_type value)}`` for the Window class hierarchy."""
result: dict[str, str] = {}
for name in EXPECTED_CLASS_NAMES:
try:
cls = _find_class(tree, name, file_path)
except ValueError:
continue
for stmt in cls.body:
target_name = None
value_node = None
if isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name):
target_name = stmt.target.id
value_node = stmt.value
elif (
isinstance(stmt, ast.Assign)
and len(stmt.targets) == 1
and isinstance(stmt.targets[0], ast.Name)
):
target_name = stmt.targets[0].id
value_node = stmt.value
if target_name == "expected_decoded_type" and value_node is not None:
result[name] = ast.unparse(value_node)
break
return result
def _build_surface(file_path: Path) -> dict[str, object]:
"""Build the comparable surface dict for one ``window.py`` file."""
tree = _parse(file_path)
present_classes = sorted(
name
for name in EXPECTED_CLASS_NAMES
if any(isinstance(node, ast.ClassDef) and node.name == name for node in ast.walk(tree))
)
window_cls = _find_class(tree, "Window", file_path)
return {
"class_names": present_classes,
"enum_members": _extract_enum_members(tree, file_path),
"direction_default": _extract_direction_default(window_cls, file_path),
"init_body": _extract_method_source(window_cls, "__init__", file_path),
"serialize_body": _extract_method_source(window_cls, "serialize", file_path),
"deserialize_body": _extract_method_source(window_cls, "deserialize", file_path),
"decoded_types": _extract_decoded_types(tree, file_path),
}
def main() -> int:
try:
core_surface = _build_surface(CORE_FILE)
sdk_surface = _build_surface(SDK_FILE)
except ValueError as exc:
console.print(f"[red]Could not read the window definitions:[/red] {exc}")
return 1
if core_surface == sdk_surface:
return 0
console.print("[red]window.py shared surface is out of sync between core and the Task SDK.[/red]\n")
all_keys = sorted(set(core_surface) | set(sdk_surface))
for key in all_keys:
core_val = core_surface.get(key, "<missing>")
sdk_val = sdk_surface.get(key, "<missing>")
marker = " " if core_val == sdk_val else "->"
color = "" if core_val == sdk_val else "[red]"
end = "" if core_val == sdk_val else "[/red]"
console.print(f"{color}{marker} {key}: core={core_val!r} sdk={sdk_val!r}{end}")
console.print(f"\nMake both copies match:\n core: {CORE_FILE}\n sdk: {SDK_FILE}")
return 1
if __name__ == "__main__":
sys.exit(main())