Coverage for /home/airflow/.local/lib/python3.12/site-packages/airflow/api_fastapi/core_api/datamodels/xcom.py: 93%
76 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.
17from __future__ import annotations
19import json
20from collections.abc import Iterable
21from datetime import datetime
22from typing import Any
24from pydantic import AliasPath, Field, field_validator
26from airflow.api_fastapi.core_api.base import BaseModel, StrictBaseModel
29class XComResponse(BaseModel):
30 """Serializer for a xcom item."""
32 key: str
33 timestamp: datetime
34 logical_date: datetime | None
35 map_index: int
36 task_id: str
37 dag_id: str
38 run_id: str
39 dag_display_name: str = Field(validation_alias=AliasPath("dag_run", "dag_model", "dag_display_name"))
40 task_display_name: str = Field(validation_alias=AliasPath("task", "task_display_name"))
41 run_after: datetime = Field(validation_alias=AliasPath("dag_run", "run_after"))
44def _stringify_if_needed(value):
45 """
46 Check whether value is JSON-encodable (recursively if needed); stringify it if not.
48 The list of JSON-ecodable types are taken from Python documentation:
49 https://docs.python.org/3/library/json.html#json.JSONEncoder
50 """
51 if value is None or isinstance(value, (str, int, float, bool)):
52 return value
53 if isinstance(value, dict):
54 return {str(k): _stringify_if_needed(v) for k, v in value.items()}
55 if isinstance(value, (list, tuple)): 55 ↛ 57line 55 didn't jump to line 57 because the condition on line 55 was always true
56 return [_stringify_if_needed(v) for v in value]
57 return str(value)
60class XComResponseNative(XComResponse):
61 """XCom response serializer with native return type."""
63 value: Any
65 @field_validator("value", mode="before")
66 def value_to_json_serializable(cls, v):
67 return _stringify_if_needed(v)
70class XComResponseString(XComResponse):
71 """XCom response serializer with string return type."""
73 value: str | None
75 @field_validator("value", mode="before")
76 def value_to_string(cls, v):
77 return str(v) if v is not None else None
80class XComCollectionResponse(BaseModel):
81 """XCom Collection serializer for responses."""
83 xcom_entries: Iterable[XComResponse]
84 total_entries: int
87def _check_forbidden_xcom_keys(value: Any) -> Any:
88 """Recursively reject forbidden deserialization keys in user-provided XCom data."""
89 from airflow._shared.serialization import FORBIDDEN_XCOM_KEYS
91 def _walk(obj: Any, path: str = "value") -> None:
92 if isinstance(obj, str):
93 # A value submitted as a JSON string literal (e.g. ``json.dumps({...})``)
94 # is stored verbatim and re-parsed into a dict/list on a
95 # ``deserialize=true`` read, which would otherwise smuggle reserved keys
96 # past the dict/list checks below. Re-parse and inspect the decoded
97 # structure the same way the read path does.
98 try:
99 decoded = json.loads(obj)
100 except (ValueError, TypeError):
101 return
102 if isinstance(decoded, (dict, list)): 102 ↛ 103line 102 didn't jump to line 103 because the condition on line 102 was never true
103 _walk(decoded, path)
104 return
105 if isinstance(obj, dict):
106 found = FORBIDDEN_XCOM_KEYS & obj.keys()
107 if found: 107 ↛ 108line 107 didn't jump to line 108 because the condition on line 107 was never true
108 raise ValueError(
109 f"XCom {path} contains reserved serialization keys: {', '.join(sorted(found))}. "
110 f"These keys are reserved for internal use."
111 )
112 for k, v in obj.items():
113 _walk(v, f"{path}.{k}")
114 elif isinstance(obj, (list, tuple)):
115 for i, item in enumerate(obj):
116 _walk(item, f"{path}[{i}]")
118 _walk(value)
119 return value
122class XComCreateBody(StrictBaseModel):
123 """Payload serializer for creating an XCom entry."""
125 key: str
126 value: Any
127 map_index: int = -1
129 @field_validator("value")
130 @classmethod
131 def _check_forbidden_keys(cls, value: Any) -> Any:
132 return _check_forbidden_xcom_keys(value)
135class XComUpdateBody(StrictBaseModel):
136 """Payload serializer for updating an XCom entry."""
138 value: Any
139 map_index: int = -1
141 @field_validator("value")
142 @classmethod
143 def _check_forbidden_keys(cls, value: Any) -> Any:
144 return _check_forbidden_xcom_keys(value)