# 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. from __future__ import annotations import subprocess from pathlib import Path import libcst as cst from datamodel_code_generator.format import CustomCodeFormatter from libcst.helpers import parse_template_statement AIRFLOW_ROOT_PATH = Path(__file__).parents[2].resolve() def license_text() -> str: license = (AIRFLOW_ROOT_PATH / "scripts" / "ci" / "license-templates" / "LICENSE.txt").read_text() return "\n".join(f"# {line}" if line else "#" for line in license.splitlines()) + "\n" class CodeFormatter(CustomCodeFormatter): def apply(self, code: str) -> str: code = license_text() + code # Swap "class JsonValue[RootValue]:" for the import from pydantic class JsonValueNodeRemover(cst.CSTTransformer): def leave_ImportFrom( self, original_node: cst.ImportFrom, updated_node: cst.ImportFrom ) -> cst.BaseSmallStatement | cst.FlattenSentinel[cst.BaseSmallStatement] | cst.RemovalSentinel: if original_node.module and original_node.module.value == "pydantic": new_names = updated_node.names + (cst.ImportAlias(name=cst.Name("JsonValue")),) # type: ignore[operator] return updated_node.with_changes(names=new_names) return super().leave_ImportFrom(original_node, updated_node) def leave_ClassDef( self, original_node: cst.ClassDef, updated_node: cst.ClassDef ) -> cst.BaseStatement | cst.FlattenSentinel[cst.BaseStatement] | cst.RemovalSentinel: if original_node.name.value == "JsonValue": return cst.RemoveFromParent() return super().leave_ClassDef(original_node, updated_node) class VersionConstInjtector(cst.CSTTransformer): handled = False def __init__(self, api_version: str) -> None: self.api_version = api_version super().__init__() def leave_ImportFrom( self, original_node: cst.ImportFrom, updated_node: cst.ImportFrom ) -> cst.BaseSmallStatement | cst.FlattenSentinel[cst.BaseSmallStatement] | cst.RemovalSentinel: # Ensure we have `from typing import Final` if original_node.module and original_node.module.value == "typing": new_names = updated_node.names + (cst.ImportAlias(name=cst.Name("Final")),) # type: ignore[operator] return updated_node.with_changes(names=new_names) return super().leave_ImportFrom(original_node, updated_node) def leave_ClassDef(self, original_node: cst.ClassDef, updated_node: cst.ClassDef): if self.handled: return super().leave_ClassDef(original_node, updated_node) self.handled = True const = parse_template_statement( "API_VERSION: Final[str] = {api_version}", api_version=cst.SimpleString(f'"{self.api_version}"'), ) return cst.FlattenSentinel([const, updated_node]) # Remove Task class that represent a tuple of (task_id, map_index) # for `TISkippedDownstreamTasksStatePayload` class ModifyTasksAnnotation(cst.CSTTransformer): def leave_ClassDef( self, original_node: cst.ClassDef, updated_node: cst.ClassDef ) -> cst.BaseStatement | cst.FlattenSentinel[cst.BaseStatement] | cst.RemovalSentinel: if original_node.name.value == "Tasks": return cst.RemoveFromParent() return super().leave_ClassDef(original_node, updated_node) def leave_AnnAssign( self, original_node: cst.AnnAssign, updated_node: cst.AnnAssign ) -> cst.AnnAssign | cst.RemovalSentinel: """ Replaces `tasks: Annotated[list[str | Tasks], Field(title="Tasks")]` with `tasks: Annotated[list[str | tuple[str, int]], Field(title="Tasks")]` only if inside `TISkippedDownstreamTasksStatePayload`. """ # Check if the target is 'tasks' if not isinstance(updated_node.target, cst.Name) or updated_node.target.value != "tasks": return updated_node if not isinstance(updated_node.annotation, cst.Annotation): return updated_node # Create a replacement for 'Tasks' -> 'tuple[str, int]' tuple_type = cst.Subscript( value=cst.Name("tuple"), slice=[ cst.SubscriptElement(cst.Index(cst.Name("str"))), cst.SubscriptElement(cst.Index(cst.Name("int"))), ], ) # Transformer to replace all instances of 'Tasks' with 'tuple[str, int]' class TasksReplacer(cst.CSTTransformer): def leave_Name( self, original_node: cst.Name, updated_node: cst.Name ) -> cst.BaseExpression: if original_node.value == "Tasks": return tuple_type return updated_node # Apply the transformation to the annotation part only new_annotation = updated_node.annotation.visit(TasksReplacer()) return updated_node.with_changes(annotation=new_annotation) source_tree = cst.parse_module(code) modified_tree = source_tree.visit(JsonValueNodeRemover()) if api_version := self.formatter_kwargs.get("api_version"): modified_tree = modified_tree.visit(VersionConstInjtector(api_version)) modified_tree = modified_tree.visit(ModifyTasksAnnotation()) code = modified_tree.code result = subprocess.check_output( ["ruff", "check", "--fix-only", "--unsafe-fixes", "--quiet", "--preview", "-"], input=code, text=True, ) return result