Coverage for /home/airflow/.local/lib/python3.12/site-packages/airflow/api_fastapi/core_api/services/public/pools.py: 77%
83 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 typing import cast
22from fastapi import HTTPException, status
23from fastapi.exceptions import RequestValidationError
24from pydantic import ValidationError
25from sqlalchemy import select
27from airflow.api_fastapi.core_api.datamodels.common import (
28 BulkActionNotOnExistence,
29 BulkActionOnExistence,
30 BulkActionResponse,
31 BulkCreateAction,
32 BulkDeleteAction,
33 BulkUpdateAction,
34)
35from airflow.api_fastapi.core_api.datamodels.pools import (
36 BasePool,
37 PoolBody,
38 PoolPatchBody,
39)
40from airflow.api_fastapi.core_api.services.public.common import BulkService
41from airflow.models.pool import Pool
44def update_orm_from_pydantic(
45 pool: Pool,
46 patch_body: PoolBody | PoolPatchBody,
47 update_mask: list[str] | None,
48) -> Pool:
49 """
50 Update an existing pool.
52 :param pool: The existing Pool ORM object to update.
53 :param patch_body: Pydantic model containing the fields to update.
54 :param update_mask: Specific fields to update. If None, all provided fields will be considered.
55 :return: The updated Pool instance.
56 :raises HTTPException: If attempting to update disallowed fields on ``default_pool``.
57 """
58 # Special restriction: default pool only allows limited fields to be patched
59 pool_name = pool.pool
60 if pool_name == Pool.DEFAULT_POOL_NAME:
61 if update_mask and all(mask.strip() in {"slots", "include_deferred"} for mask in update_mask): 61 ↛ 63line 61 didn't jump to line 63 because the condition on line 61 was never true
62 # Validate only slots/include_deferred
63 try:
64 patch_body_subset = patch_body.model_dump(
65 include={"slots", "include_deferred"}, exclude_unset=True, by_alias=True
66 )
67 # Re-run validation with BasePool but only on allowed fields
68 PoolPatchBody.model_validate(patch_body_subset)
69 except ValidationError as e:
70 raise RequestValidationError(errors=e.errors())
71 else:
72 raise HTTPException(
73 status.HTTP_400_BAD_REQUEST,
74 "Only slots and included_deferred can be modified on Default Pool",
75 )
76 else:
77 fields_to_update = patch_body.model_fields_set
78 try:
79 # Dump with both input + output aliases handled
80 body_dict = patch_body.model_dump(
81 include=fields_to_update,
82 by_alias=True, # ensures we get the API-facing alias keys
83 )
85 # Normalize keys for BasePool (expects "pool")
86 if "name" in body_dict and "pool" not in body_dict: 86 ↛ 87line 86 didn't jump to line 87 because the condition on line 86 was never true
87 body_dict["pool"] = body_dict.pop("name")
89 BasePool.model_validate(body_dict)
91 except ValidationError as e:
92 raise RequestValidationError(errors=e.errors())
94 # Delegate patch application to the common utility
95 return cast(
96 "Pool",
97 BulkService.apply_patch_with_update_mask(
98 model=pool,
99 patch_body=patch_body,
100 update_mask=update_mask,
101 non_update_fields=None,
102 ),
103 )
106class BulkPoolService(BulkService[PoolBody]):
107 """Service for handling bulk operations on pools."""
109 def categorize_pools(self, pool_names: set) -> tuple[dict, set, set]:
110 """
111 Categorize the given pool_names into matched_pool_names and not_found_pool_names based on existing pool_names.
113 Existing pools are returned as a dict of {pool_name : Pool}.
115 :param pool_names: set of pool_names
116 :return: tuple of dict of existed pools, set of matched pool_names, set of not found pool_names
117 """
118 existed_pools = self.session.execute(select(Pool).filter(Pool.pool.in_(pool_names))).scalars()
119 existing_pools_dict = {pool.pool: pool for pool in existed_pools}
120 matched_pool_names = set(existing_pools_dict.keys())
121 not_found_pool_names = pool_names - matched_pool_names
122 return existing_pools_dict, matched_pool_names, not_found_pool_names
124 def handle_bulk_create(self, action: BulkCreateAction[PoolBody], results: BulkActionResponse) -> None:
125 """Bulk create pools."""
126 to_create_pool_names = {pool.pool for pool in action.entities}
127 existing_pools_dict, matched_pool_names, not_found_pool_names = self.categorize_pools(
128 to_create_pool_names
129 )
130 try:
131 if action.action_on_existence == BulkActionOnExistence.FAIL and matched_pool_names:
132 raise HTTPException(
133 status_code=status.HTTP_409_CONFLICT,
134 detail=f"The pools with these pool names: {matched_pool_names} already exist.",
135 )
136 if action.action_on_existence == BulkActionOnExistence.SKIP:
137 create_pool_names = not_found_pool_names
138 else:
139 create_pool_names = to_create_pool_names
141 for pool in action.entities:
142 if pool.pool in create_pool_names:
143 if pool.pool in matched_pool_names: 143 ↛ 144line 143 didn't jump to line 144 because the condition on line 143 was never true
144 existed_pool = existing_pools_dict[pool.pool]
145 # Only overwrite fields the request actually provided. Plain ``model_dump()``
146 # emits every field at its default, so an overwrite that omits e.g.
147 # ``team_name``/``description``/``include_deferred`` silently resets them on the
148 # existing pool — most damagingly nulling its multi-team ``team_name`` ownership.
149 # ``exclude_unset=True`` writes only fields present in the request body, so
150 # omitted fields keep their current value while an explicitly-set field (even
151 # ``None``) is still applied.
152 for key, val in pool.model_dump(exclude_unset=True).items():
153 setattr(existed_pool, key, val)
154 else:
155 self.session.add(Pool(**pool.model_dump()))
156 results.success.append(pool.pool)
158 self.session.flush()
160 except HTTPException as e:
161 results.errors.append({"error": f"{e.detail}", "status_code": e.status_code})
163 def handle_bulk_update(self, action: BulkUpdateAction[PoolBody], results: BulkActionResponse) -> None:
164 """Bulk Update pools."""
165 to_update_pool_names = {pool.pool for pool in action.entities}
166 existing_pools_dict, matched_pool_names, not_found_pool_names = self.categorize_pools(
167 to_update_pool_names
168 )
169 try:
170 if action.action_on_non_existence == BulkActionNotOnExistence.FAIL and not_found_pool_names:
171 raise HTTPException(
172 status_code=status.HTTP_404_NOT_FOUND,
173 detail=f"The pools with these pool names: {not_found_pool_names} were not found.",
174 )
175 if action.action_on_non_existence == BulkActionNotOnExistence.SKIP:
176 update_pool_names = matched_pool_names
177 else:
178 update_pool_names = to_update_pool_names
179 for pool in action.entities: 179 ↛ 180line 179 didn't jump to line 180 because the loop on line 179 never started
180 if pool.pool not in update_pool_names:
181 continue
183 updated_pool = update_orm_from_pydantic(
184 existing_pools_dict[pool.pool], pool, action.update_mask
185 )
187 results.success.append(str(updated_pool.pool)) # use request field, always consistent
189 except HTTPException as e:
190 results.errors.append({"error": f"{e.detail}", "status_code": e.status_code})
192 def handle_bulk_delete(self, action: BulkDeleteAction[PoolBody], results: BulkActionResponse) -> None:
193 """Bulk delete pools."""
194 to_delete_pool_names = set(action.entities)
195 existing_pools_dict, matched_pool_names, not_found_pool_names = self.categorize_pools(
196 to_delete_pool_names
197 )
199 try:
200 if action.action_on_non_existence == BulkActionNotOnExistence.FAIL and not_found_pool_names: 200 ↛ 201line 200 didn't jump to line 201 because the condition on line 200 was never true
201 raise HTTPException(
202 status_code=status.HTTP_404_NOT_FOUND,
203 detail=f"The pools with these pool names: {not_found_pool_names} were not found.",
204 )
206 for pool_name in matched_pool_names:
207 self.session.delete(existing_pools_dict[pool_name])
208 results.success.append(pool_name)
210 except HTTPException as e:
211 results.errors.append({"error": f"{e.detail}", "status_code": e.status_code})