Skip to content

Commit 5d73b80

Browse files
tolik0claude
andcommitted
fix: gate 'NA' string preservation behind opt-in preserve_na_values flag
ResponseToFileExtractor previously always passed keep_default_na=False, which changed CSV parsing for every connector using it. Make the behavior opt-in via a new preserve_na_values field (default False), preserving the historical behavior (pandas NA tokens -> null) unless explicitly enabled. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent edc81bf commit 5d73b80

2 files changed

Lines changed: 30 additions & 6 deletions

File tree

airbyte_cdk/sources/declarative/extractors/response_to_file_extractor.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,14 @@ class ResponseToFileExtractor(RecordExtractor):
2727
2828
Eventually, we want to support multiple file type by re-using the file based CDK parsers if possible. However, the lift is too high for
2929
a first iteration so we will only support CSV parsing using pandas as salesforce and sendgrid were doing.
30+
31+
By default, pandas interprets strings such as "NA", "N/A", "null" and "NaN" as missing values and converts them to null. When
32+
``preserve_na_values`` is True, these strings are kept as-is (only empty cells are treated as null). This is opt-in to avoid changing
33+
behavior for existing connectors.
3034
"""
3135

3236
parameters: InitVar[Mapping[str, Any]]
37+
preserve_na_values: bool = False
3338

3439
def __post_init__(self, parameters: Mapping[str, Any]) -> None:
3540
self.logger = logging.getLogger("airbyte")
@@ -136,16 +141,21 @@ def _read_with_chunks(
136141

137142
try:
138143
with open(path, "r", encoding=file_encoding) as data:
144+
# When preserving NA values, disable pandas' default NA parsing so strings like "NA"/"N/A" are kept as-is,
145+
# and explicitly map empty cells to None. Otherwise, keep the historical behavior where pandas converts its
146+
# default NA tokens (including empty cells) to NaN, which we then map to None.
147+
read_kwargs = {"keep_default_na": False} if self.preserve_na_values else {}
148+
na_replacements = {nan: None, "": None} if self.preserve_na_values else {nan: None}
139149
chunks = pd.read_csv(
140150
data,
141151
chunksize=chunk_size,
142152
iterator=True,
143153
dialect="unix",
144154
dtype=object,
145-
keep_default_na=False,
155+
**read_kwargs,
146156
)
147157
for chunk in chunks:
148-
chunk = chunk.replace({nan: None, "": None}).to_dict(orient="records")
158+
chunk = chunk.replace(na_replacements).to_dict(orient="records")
149159
for row in chunk:
150160
yield row
151161
except pd.errors.EmptyDataError as e:

unit_tests/sources/declarative/extractors/test_response_to_file_extractor.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
class ResponseToFileExtractorTest(TestCase):
1616
def setUp(self) -> None:
1717
self._extractor = ResponseToFileExtractor({})
18+
self._preserving_extractor = ResponseToFileExtractor({}, preserve_na_values=True)
1819
self._http_mocker = requests_mock.Mocker()
1920
self._http_mocker.__enter__()
2021

@@ -39,13 +40,13 @@ def test_text_response_with_null_bytes(self) -> None:
3940

4041
assert extracted_records == [{"FIRST_NAME": "a first name", "LAST_NAME": "a last name"}]
4142

42-
def test_na_string_values_are_preserved(self) -> None:
43+
def test_na_string_values_are_preserved_when_enabled(self) -> None:
4344
pandas_na_tokens = ["NA", "N/A", "NULL", "None", "nan", "null", "#N/A", "<NA>"]
4445
csv_rows = "\n".join(f'"{token}","{token} value"' for token in pandas_na_tokens)
4546
csv_with_na = f'"region","name"\n{csv_rows}\n'
4647
response = self._mock_streamed_response(BytesIO(csv_with_na.encode("utf-8")))
4748

48-
extracted_records = list(self._extractor.extract_records(response))
49+
extracted_records = list(self._preserving_extractor.extract_records(response))
4950

5051
assert len(extracted_records) == len(pandas_na_tokens)
5152
for record, token in zip(extracted_records, pandas_na_tokens):
@@ -54,11 +55,24 @@ def test_na_string_values_are_preserved(self) -> None:
5455
)
5556
assert record["name"] == f"{token} value"
5657

57-
def test_empty_fields_are_converted_to_none(self) -> None:
58+
def test_na_string_values_are_converted_to_none_by_default(self) -> None:
59+
pandas_na_tokens = ["NA", "N/A", "NULL", "None", "nan", "null", "#N/A", "<NA>"]
60+
csv_rows = "\n".join(f'"{token}","preserved"' for token in pandas_na_tokens)
61+
csv_with_na = f'"region","name"\n{csv_rows}\n'
62+
response = self._mock_streamed_response(BytesIO(csv_with_na.encode("utf-8")))
63+
64+
extracted_records = list(self._extractor.extract_records(response))
65+
66+
assert len(extracted_records) == len(pandas_na_tokens)
67+
for record in extracted_records:
68+
assert record["region"] is None
69+
assert record["name"] == "preserved"
70+
71+
def test_empty_fields_are_converted_to_none_when_preserving(self) -> None:
5872
csv_with_empty = '"region","name"\n"NA","North America"\n,""\n'
5973
response = self._mock_streamed_response(BytesIO(csv_with_empty.encode("utf-8")))
6074

61-
extracted_records = list(self._extractor.extract_records(response))
75+
extracted_records = list(self._preserving_extractor.extract_records(response))
6276

6377
assert extracted_records == [
6478
{"region": "NA", "name": "North America"},

0 commit comments

Comments
 (0)