Coverage for /home/airflow/.local/lib/python3.12/site-packages/airflow/api_fastapi/core_api/services/ui/grid.py: 21%
94 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 14:22 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 14:22 +0000
1# Licensed to the Apache Software Foundation (ASF) under one
2# or more contributor license agreements. See the NOTICE file
3# distributed with this work for additional information
4# regarding copyright ownership. The ASF licenses this file
5# to you under the Apache License, Version 2.0 (the
6# "License"); you may not use this file except in compliance
7# with the License. You may obtain a copy of the License at
8#
9# http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing,
12# software distributed under the License is distributed on an
13# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14# KIND, either express or implied. See the License for the
15# specific language governing permissions and limitations
16# under the License.
18from __future__ import annotations
20from collections import Counter
21from collections.abc import Iterable, Mapping
22from dataclasses import dataclass, field
23from datetime import datetime
24from typing import Any
26import structlog
28from airflow.api_fastapi.common.parameters import state_priority
29from airflow.api_fastapi.core_api.services.ui.task_group import get_task_group_children_getter
30from airflow.models.taskmap import TaskMap
31from airflow.serialization.definitions.baseoperator import SerializedBaseOperator
32from airflow.serialization.definitions.mappedoperator import SerializedMappedOperator
33from airflow.serialization.definitions.taskgroup import SerializedTaskGroup
35log = structlog.get_logger(logger_name=__name__)
38@dataclass
39class GridNodeAgg:
40 """Compact task instance summary used to aggregate grid state without keeping TI details."""
42 child_states: Counter[Any] = field(default_factory=Counter)
43 min_start_date: datetime | None = None
44 max_end_date: datetime | None = None
45 dag_version_number: int | None = None
46 has_note: bool = False
48 def add_ti(
49 self,
50 *,
51 state: Any,
52 start_date: datetime | None,
53 end_date: datetime | None,
54 dag_version_number: int | None,
55 has_note: bool = False,
56 ) -> None:
57 """Merge one task instance row into the summary."""
58 self.child_states[state] += 1
59 if start_date is not None and (self.min_start_date is None or start_date < self.min_start_date):
60 self.min_start_date = start_date
61 if end_date is not None and (self.max_end_date is None or end_date > self.max_end_date):
62 self.max_end_date = end_date
63 if dag_version_number is not None and (
64 self.dag_version_number is None or dag_version_number > self.dag_version_number
65 ):
66 self.dag_version_number = dag_version_number
67 self.has_note = self.has_note or has_note
69 def merge(self, other: GridNodeAgg) -> None:
70 """Merge another summary into this one."""
71 self.child_states.update(other.child_states)
72 if other.min_start_date is not None and (
73 self.min_start_date is None or other.min_start_date < self.min_start_date
74 ):
75 self.min_start_date = other.min_start_date
76 if other.max_end_date is not None and (
77 self.max_end_date is None or other.max_end_date > self.max_end_date
78 ):
79 self.max_end_date = other.max_end_date
80 if other.dag_version_number is not None and (
81 self.dag_version_number is None or other.dag_version_number > self.dag_version_number
82 ):
83 self.dag_version_number = other.dag_version_number
84 self.has_note = self.has_note or other.has_note
86 def with_placeholder_state(self) -> GridNodeAgg:
87 """Represent mapped tasks without rows as a single no-status square in the grid."""
88 if self.child_states:
89 return self
90 placeholder = GridNodeAgg(dag_version_number=self.dag_version_number)
91 placeholder.add_ti(
92 state=None,
93 start_date=None,
94 end_date=None,
95 dag_version_number=self.dag_version_number,
96 )
97 return placeholder
100def _merge_node_dicts(current: list[dict[str, Any]], new: list[dict[str, Any]] | None) -> None:
101 """Merge node dictionaries from different Dag versions, handling structure changes."""
102 # Handle None case - can occur when merging old Dag versions
103 # where a TaskGroup was converted to a task or vice versa
104 if new is None:
105 return
107 current_nodes_by_id = {node["id"]: node for node in current}
108 for node in new:
109 node_id = node["id"]
110 current_node = current_nodes_by_id.get(node_id)
111 if current_node is not None:
112 # Only merge children if current node already has children
113 # This preserves the structure of the latest Dag version
114 if current_node.get("children") is not None:
115 _merge_node_dicts(current_node["children"], node.get("children"))
116 else:
117 current.append(node)
118 current_nodes_by_id[node_id] = node
121def agg_state(states):
122 state_counts = states if isinstance(states, Counter) else Counter(states)
123 for state in state_priority:
124 if state in state_counts:
125 return state
126 return None
129def _serialize_child_states(child_states: Counter[Any]) -> dict[str, int]:
130 return {state if state is not None else "none": count for state, count in child_states.items()}
133def _get_aggs_for_node(summary: GridNodeAgg) -> dict[str, Any]:
134 return {
135 "state": agg_state(summary.child_states),
136 "min_start_date": summary.min_start_date,
137 "max_end_date": summary.max_end_date,
138 "child_states": _serialize_child_states(summary.child_states),
139 "dag_version_number": summary.dag_version_number,
140 "has_note": summary.has_note,
141 }
144def _find_aggregates(
145 node: SerializedTaskGroup | SerializedBaseOperator | TaskMap,
146 parent_node: SerializedTaskGroup | SerializedBaseOperator | TaskMap | None,
147 ti_details: Mapping[str, GridNodeAgg],
148 group_dict: dict[str | None, SerializedTaskGroup] | None = None,
149) -> Iterable[tuple[dict[str, Any], GridNodeAgg]]:
150 """Recursively fill the Task Group Map."""
151 node_id = node.node_id
152 parent_id = parent_node.node_id if parent_node else None
153 # Do not mutate ti_details by accidental key creation
154 summary = ti_details.get(node_id)
155 if summary is None:
156 summary = GridNodeAgg()
158 if node is None:
159 return
160 if isinstance(node, SerializedMappedOperator):
161 mapped_summary = summary.with_placeholder_state()
162 yield (
163 {
164 "task_id": node_id,
165 "task_display_name": node.task_display_name,
166 "type": "mapped_task",
167 "parent_id": parent_id,
168 **_get_aggs_for_node(mapped_summary),
169 },
170 mapped_summary,
171 )
173 return
174 if isinstance(node, SerializedTaskGroup):
175 if group_dict is None:
176 group_dict = node.dag.task_group.get_task_group_dict()
177 children_summary = GridNodeAgg()
178 for child in get_task_group_children_getter()(node, group_dict):
179 for child_node, child_summary in _find_aggregates(
180 node=child, parent_node=node, ti_details=ti_details, group_dict=group_dict
181 ):
182 if child_node["parent_id"] == node_id:
183 children_summary.merge(child_summary)
184 yield child_node, child_summary
185 if node_id:
186 yield (
187 {
188 "task_id": node_id,
189 "task_display_name": node_id,
190 "type": "group",
191 "parent_id": parent_id,
192 **_get_aggs_for_node(children_summary),
193 },
194 children_summary,
195 )
196 return
197 if isinstance(node, SerializedBaseOperator):
198 yield (
199 {
200 "task_id": node_id,
201 "task_display_name": node.task_display_name,
202 "type": "task",
203 "parent_id": parent_id,
204 **_get_aggs_for_node(summary),
205 },
206 summary,
207 )
208 return