|
19 | 19 | from __future__ import annotations |
20 | 20 |
|
21 | 21 | import contextlib |
| 22 | +import inspect |
22 | 23 | from typing import TYPE_CHECKING, Any |
23 | 24 |
|
24 | 25 | from airflow._shared.module_loading import qualname |
25 | 26 | from airflow._shared.secrets_masker import redact |
26 | 27 | from airflow._shared.template_rendering import truncate_rendered_value |
27 | 28 | from airflow.configuration import conf |
28 | | -from airflow.settings import json |
29 | 29 |
|
30 | 30 | if TYPE_CHECKING: |
31 | 31 | from airflow.partition_mappers.base import PartitionMapper |
32 | 32 | from airflow.timetables.base import Timetable as CoreTimetable |
33 | 33 |
|
34 | 34 |
|
35 | | -def serialize_template_field(template_field: Any, name: str) -> str | dict | list | int | float: |
| 35 | +def serialize_template_field(template_field: Any, name: str) -> str | dict | list | int | float | bool | None: |
36 | 36 | """ |
37 | 37 | Return a serializable representation of the templated field. |
38 | 38 |
|
39 | | - If ``templated_field`` is provided via a callable then |
40 | | - return the following serialized value: ``<callable full_qualified_name>`` |
| 39 | + The walk has two responsibilities: |
41 | 40 |
|
42 | | - If ``templated_field`` contains a class or instance that requires recursive |
43 | | - templating, store them as strings. Otherwise simply return the field as-is. |
| 41 | + 1. **Make the template_field JSON-encodable** — every container is rebuilt |
| 42 | + with primitive leaves (str/int/float/bool/None), tuples and sets are |
| 43 | + flattened to lists, and unsupported objects fall through to ``str()`` |
| 44 | + so ``json.dumps`` never raises on the result. |
| 45 | + 2. **Keep the output deterministic across parses** — callables are replaced |
| 46 | + with their qualified name (never the default ``<function ... at 0x...>`` |
| 47 | + repr), dicts are key-sorted, and (frozen)sets are sorted by element so |
| 48 | + the same input always produces the same string. |
44 | 49 | """ |
45 | 50 |
|
46 | | - def is_jsonable(x): |
47 | | - try: |
48 | | - json.dumps(x) |
49 | | - except (TypeError, OverflowError): |
50 | | - return False |
51 | | - else: |
52 | | - return True |
53 | | - |
54 | | - def translate_tuples_to_lists(obj: Any): |
55 | | - """Recursively convert tuples to lists.""" |
56 | | - if isinstance(obj, tuple): |
57 | | - return [translate_tuples_to_lists(item) for item in obj] |
58 | | - if isinstance(obj, list): |
59 | | - return [translate_tuples_to_lists(item) for item in obj] |
60 | | - if isinstance(obj, dict): |
61 | | - return {key: translate_tuples_to_lists(value) for key, value in obj.items()} |
62 | | - return obj |
| 51 | + def normalize_dict_key(key) -> str: |
| 52 | + """Normalize a dict key to a serialized string type.""" |
| 53 | + # Serialized template_field keys must all be strings, not a mix of types, so that |
| 54 | + # downstream json.dumps(..., sort_keys=True) does not raise on mixed-type keys. |
| 55 | + return str(serialize_object(key)) |
| 56 | + |
| 57 | + def serialize_object(obj): |
| 58 | + """Recursively rewrite ``obj`` into a JSON-encodable, hash-stable structure.""" |
| 59 | + if obj is None or isinstance(obj, (str, int, float, bool)): |
| 60 | + return obj |
63 | 61 |
|
64 | | - def sort_dict_recursively(obj: Any) -> Any: |
65 | | - """Recursively sort dictionaries to ensure consistent ordering.""" |
66 | 62 | if isinstance(obj, dict): |
67 | | - return {k: sort_dict_recursively(v) for k, v in sorted(obj.items())} |
68 | | - if isinstance(obj, list): |
69 | | - return [sort_dict_recursively(item) for item in obj] |
70 | | - if isinstance(obj, tuple): |
71 | | - return tuple(sort_dict_recursively(item) for item in obj) |
72 | | - return obj |
| 63 | + # Serialize keys/values first so each key is a string and the output is hash-stable, |
| 64 | + # then sort by the serialized key to prevent hash inconsistencies when dict ordering varies. |
| 65 | + serialized_pairs = [(normalize_dict_key(k), serialize_object(v)) for k, v in obj.items()] |
| 66 | + return dict(sorted(serialized_pairs, key=lambda kv: kv[0])) |
| 67 | + |
| 68 | + if isinstance(obj, (list, tuple)): |
| 69 | + return [serialize_object(item) for item in obj] |
| 70 | + |
| 71 | + if isinstance(obj, (set, frozenset)): |
| 72 | + # JSON has no set type → flatten to a list with deterministic ordering |
| 73 | + # so hash randomization on element types cannot shift cross-process iteration order. |
| 74 | + serialized_set = [serialize_object(e) for e in obj] |
| 75 | + return sorted(serialized_set, key=lambda x: (type(x).__name__, str(x))) |
| 76 | + |
| 77 | + # Use inspect.getattr_static to bypass any custom __getattr__ / metaclass magic |
| 78 | + if callable(inspect.getattr_static(obj, "serialize", None)): |
| 79 | + return serialize_object(obj.serialize()) |
| 80 | + |
| 81 | + # Kubernetes client objects (V1Pod, V1Container, ...) expose their content via to_dict(). |
| 82 | + # Scope the branch to the kubernetes namespace so unrelated user classes that happen to |
| 83 | + # define a to_dict() method fall through to str() instead of being treated as K8s payloads. |
| 84 | + if getattr(type(obj), "__module__", "").startswith( |
| 85 | + ("kubernetes.", "kubernetes_asyncio.") |
| 86 | + ) and callable(inspect.getattr_static(obj, "to_dict", None)): |
| 87 | + return serialize_object(obj.to_dict()) |
| 88 | + |
| 89 | + if callable(obj): |
| 90 | + # Use qualified name; default repr embeds memory addresses, which would change the DAG hash on every parse |
| 91 | + return f"<callable {qualname(obj, True)}>" |
| 92 | + |
| 93 | + # A custom __str__ or __repr__ is treated as an intentional textual representation |
| 94 | + # supplied by the author and used as-is. |
| 95 | + if type(obj).__str__ is not object.__str__ or type(obj).__repr__ is not object.__repr__: |
| 96 | + return str(obj) |
| 97 | + |
| 98 | + # Otherwise fall back to a qualname marker. The default object repr is |
| 99 | + # `<ClassName object at 0x...>`, which embeds a memory address that flips per process |
| 100 | + # and would break DAG hash stability — use the class qualname instead. |
| 101 | + return f"<{qualname(type(obj), True)} object>" |
73 | 102 |
|
74 | 103 | max_length = conf.getint("core", "max_templated_field_length") |
75 | 104 |
|
76 | | - if not is_jsonable(template_field): |
77 | | - try: |
78 | | - serialized = template_field.serialize() |
79 | | - except AttributeError: |
80 | | - if callable(template_field): |
81 | | - full_qualified_name = qualname(template_field, True) |
82 | | - serialized = f"<callable {full_qualified_name}>" |
83 | | - else: |
84 | | - serialized = str(template_field) |
85 | | - if len(serialized) > max_length: |
86 | | - rendered = redact(serialized, name) |
87 | | - return truncate_rendered_value(str(rendered), max_length) |
88 | | - return serialized |
89 | | - if not template_field and not isinstance(template_field, tuple): |
90 | | - # Avoid unnecessary serialization steps for empty fields unless they are tuples |
91 | | - # and need to be converted to lists |
92 | | - return template_field |
93 | | - template_field = translate_tuples_to_lists(template_field) |
94 | | - # Sort dictionaries recursively to ensure consistent string representation |
95 | | - # This prevents hash inconsistencies when dict ordering varies |
96 | | - if isinstance(template_field, dict): |
97 | | - template_field = sort_dict_recursively(template_field) |
98 | | - serialized = str(template_field) |
99 | | - if len(serialized) > max_length: |
100 | | - rendered = redact(serialized, name) |
| 105 | + serialized = serialize_object(template_field) |
| 106 | + |
| 107 | + if len(str(serialized)) > max_length: |
| 108 | + rendered = redact(str(serialized), name) |
101 | 109 | return truncate_rendered_value(str(rendered), max_length) |
102 | | - return template_field |
| 110 | + |
| 111 | + return serialized |
103 | 112 |
|
104 | 113 |
|
105 | 114 | class TimetableNotRegistered(ValueError): |
|
0 commit comments