Skip to content

Commit f1ab6ec

Browse files
amoghrajeshpotiuk
authored andcommitted
Add support for pandas 3 based xcoms in airflow (#71103)
* Keep DataFrame XComs working on pandas 3 pandas 3 exposes its public classes from the `pandas` namespace, so a DataFrame is now qualified as `pandas.DataFrame` rather than `pandas.core.frame.DataFrame`. The serializer was registered only under the old name, so pushing a DataFrame through XCom raised "cannot serialize object of type <class 'pandas.DataFrame'>". Both names are registered so values written by either version stay readable. pandas 3 also infers a str column where it used to infer object, and keeps its missing values as NA instead of stringifying them, which the amazon and salesforce tests asserted on. * Document pandas 3 impact on DataFrame XComs Deployments need to know that every component has to carry the pandas 3 support before pandas 3 reaches any worker, that a rollback strands the XComs written in the meantime, and that a pulled DataFrame now takes its dtypes from the reader's pandas version. * Name the pandas 3 newsfragment after its own pull request * Add regression test for the pandas 2/3 cross-version registry lookup * rename newsfragment file --------- Co-authored-by: Jarek Potiuk <jarek@potiuk.com> (cherry picked from commit 4091ccf)
1 parent 41191ba commit f1ab6ec

5 files changed

Lines changed: 80 additions & 5 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
pandas 3 changes how DataFrame XComs are stored and read back
2+
3+
pandas 3 exposes its public classes from the ``pandas`` namespace, so a DataFrame is qualified as
4+
``pandas.DataFrame`` instead of ``pandas.core.frame.DataFrame``. XComs record that name alongside the
5+
serialized value, so the name written into the metadata database depends on the pandas version of the
6+
component that pushed the value. Airflow registers both names, and a DataFrame written by either
7+
pandas version can be read by either — no configuration change is needed, and existing XComs stay
8+
readable.
9+
10+
What you should do:
11+
12+
* **Roll this Airflow version out to every component before pandas 3 reaches any of them** — workers
13+
in particular. A component that predates this change cannot read a DataFrame XCom written under
14+
pandas 3, and fails the pull with:
15+
16+
.. code-block:: text
17+
18+
ImportError: pandas.DataFrame was not found in allow list for deserialization imports.
19+
To allow it, add it to allowed_deserialization_classes in the configuration
20+
21+
The message points at configuration, but the allow list is not the cause and changing it does not
22+
help. The rows are not corrupt: they become readable again as soon as the reader is upgraded.
23+
24+
* **Treat a downgrade as a one-way door for those XComs.** Rolling back to an Airflow version without
25+
this change strands any DataFrame XCom written while on pandas 3, with the same error, until you
26+
roll forward again.
27+
28+
* **Review Dags that inspect the dtypes of a pulled DataFrame.** The pandas version of the *reader*
29+
determines what a pulled DataFrame looks like, not the version that wrote it. Under pandas 3, a
30+
column of strings comes back with the ``str`` dtype rather than ``object``, and its missing values
31+
come back as ``nan`` rather than ``None``. Values are unchanged, but downstream code that branches
32+
on ``dtype == "object"``, checks cells with ``is None``, or compares against a reference frame with
33+
``DataFrame.equals()`` can behave differently after the upgrade.

providers/amazon/tests/unit/amazon/aws/transfers/test_sql_to_s3.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,15 @@
2424
import pandas as pd
2525
import polars as pl
2626
import pytest
27+
from packaging.version import Version
2728

2829
from airflow.exceptions import AirflowProviderDeprecationWarning
2930
from airflow.models import Connection
3031
from airflow.providers.amazon.aws.transfers.sql_to_s3 import SqlToS3Operator
3132
from airflow.providers.common.compat.sdk import AirflowException
3233

34+
PANDAS_3_PLUS = Version(pd.__version__).major >= 3
35+
3336

3437
class TestSqlToS3Operator:
3538
@pytest.mark.parametrize(
@@ -156,7 +159,12 @@ def test_fix_dtypes(self, params):
156159
)
157160
dirty_df = pd.DataFrame({"strings": ["a", "b", None], "ints": [1, 2, None]})
158161
op._fix_dtypes(df=dirty_df, file_format=op.file_format)
159-
assert dirty_df["strings"].values[2] == params["null_string_result"]
162+
if PANDAS_3_PLUS:
163+
# pandas 3 infers a str column rather than object, and keeps its missing values as NA
164+
# instead of the object None (csv) or the "None" it used to be stringified to (parquet)
165+
assert pd.isna(dirty_df["strings"].values[2])
166+
else:
167+
assert dirty_df["strings"].values[2] == params["null_string_result"]
160168
assert dirty_df["ints"].dtype.kind == "i"
161169

162170
@mock.patch("airflow.providers.amazon.aws.transfers.sql_to_s3.S3Hook")

providers/salesforce/tests/unit/salesforce/hooks/test_salesforce.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,15 @@
2424
import numpy as np
2525
import pandas as pd
2626
import pytest
27+
from packaging.version import Version
2728
from requests import Session as request_session
2829
from simple_salesforce import Salesforce, api
2930

3031
from airflow.models.connection import Connection
3132
from airflow.providers.salesforce.hooks.salesforce import SalesforceHook
3233

34+
PANDAS_3_PLUS = Version(pd.__version__).major >= 3
35+
3336

3437
class TestSalesforceHook:
3538
def setup_method(self):
@@ -349,10 +352,12 @@ def test_write_object_to_file_csv(self, mock_data_frame):
349352
data_frame = self.salesforce_hook.write_object_to_file(query_results=[], filename=filename, fmt="csv")
350353

351354
mock_data_frame.return_value.to_csv.assert_called_once_with(filename, index=False)
352-
# Note that the latest version of pandas dataframes (1.1.2) returns "nan" rather than "None" here
355+
# Note that the latest version of pandas dataframes (1.1.2) returns "nan" rather than "None" here,
356+
# and pandas 3 keeps missing values as NA instead of stringifying them to "nan" at all
357+
missing = np.nan if PANDAS_3_PLUS else "nan"
353358
pd.testing.assert_frame_equal(
354359
data_frame,
355-
pd.DataFrame({"test": [1, 2, 3], "dict": ["nan", "nan", str({"foo": "bar"})]}),
360+
pd.DataFrame({"test": [1, 2, 3], "dict": [missing, missing, str({"foo": "bar"})]}),
356361
check_index_type=False,
357362
)
358363

task-sdk/src/airflow/sdk/serde/serializers/pandas.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@
2222
from airflow.sdk.module_loading import qualname
2323

2424
# lazy loading for performance reasons
25+
# pandas 3 moved the public classes to the `pandas` namespace, so a DataFrame is qualified as
26+
# `pandas.DataFrame` there and as `pandas.core.frame.DataFrame` on pandas 2. Both are registered so
27+
# that XComs serialized by either version stay readable.
2528
serializers = [
29+
"pandas.DataFrame",
2630
"pandas.core.frame.DataFrame",
2731
]
2832
deserializers = serializers

task-sdk/tests/task_sdk/serde/test_serializers.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,25 @@ def test_pandas(self):
281281
d = deserialize(e)
282282
assert i.equals(d)
283283

284+
@pytest.mark.parametrize(
285+
"classname",
286+
["pandas.DataFrame", "pandas.core.frame.DataFrame"],
287+
)
288+
def test_pandas_deserializes_regardless_of_writer_qualname(self, classname):
289+
"""
290+
A DataFrame XCom must deserialize under either pandas major's registry qualname.
291+
292+
The installed pandas only ever produces its own qualname, so this forges the tag to
293+
force the lookup through the other registry entry.
294+
"""
295+
import pandas as pd
296+
297+
i = pd.DataFrame(data={"col1": [1, 2], "col2": [3, 4]})
298+
e = serialize(i)
299+
e[CLASSNAME] = classname
300+
d = deserialize(e)
301+
assert i.equals(d)
302+
284303
def test_pandas_serializers(self):
285304
from airflow.sdk.serde.serializers.pandas import serialize
286305

@@ -289,12 +308,18 @@ def test_pandas_serializers(self):
289308
@pytest.mark.parametrize(
290309
("klass", "version", "data", "msg"),
291310
[
292-
(pd.DataFrame, 999, "", r"serialized 999 of pandas.core.frame.DataFrame > 1"), # version too new
311+
# pandas 3 qualifies the class as pandas.DataFrame, pandas 2 as pandas.core.frame.DataFrame
312+
(
313+
pd.DataFrame,
314+
999,
315+
"",
316+
r"serialized 999 of pandas(\.core\.frame)?\.DataFrame > 1",
317+
), # version too new
293318
(
294319
pd.DataFrame,
295320
1,
296321
123,
297-
r"serialized pandas.core.frame.DataFrame has wrong data type .*<class 'int'>",
322+
r"serialized pandas(\.core\.frame)?\.DataFrame has wrong data type .*<class 'int'>",
298323
), # bad payload type
299324
(str, 1, "", r"do not know how to deserialize builtins.str"), # bad class
300325
],

0 commit comments

Comments
 (0)