Repository navigation
Conversation
Table.from_df raised TypeError when a string-like object column held pd.NA or pd.NaT, because _get_string_fill_value called libc isnan on every non-string value. Table.from_pandas treated only np.nan as missing, so columns using None, pd.NA or NaT stayed as object columns. _get_string_fill_value now takes the caller's null mask and skips masked entries whatever their value. It returns None, keeping the object column, for any unmasked value that is not str or bytes, and for text mixed with non-ASCII bytes, which would otherwise raise UnicodeDecodeError. Both from_df and from_pandas pass their existing null masks. Fix astropy#20663 Co-Authored-By: Claude Opus 5.5 <[email protected]>
|
Thank you for your contribution to Astropy! 🌌 This checklist is meant to remind the package maintainers who will review this pull request of some common things to look for.
|
_get_string_fill_value takes a default placeholder for the case where every entry is masked and there is no value to take the type from. The callers pick it from the dataframe dtype: "" for a pandas string dtype or narwhals String, b"" for narwhals Binary, and None otherwise, which leaves a pandas object column as a fully masked object column from both methods. Co-Authored-By: Claude Fable 5.1 <[email protected]>
ab7ad43 to
c5a90be
Compare
…istencies Co-Authored-By: Claude Fable 5.1 <[email protected]>
|
I guess this is on me for not reading the docs more closely, but I have always thought/assumed that |
|
@cgobat - that's a good question. #18435 introduced There are no great answers, but the good news is that AI is really good at finding inconsistencies and then helping to fix them. |
Summary
Table.from_dfraisesTypeErroron any DataFrame with a string-like column that holdspd.NAorpd.NaT. That includes the most common case: a pandasstringdtype column (python or pyarrow storage) with a missing entry, since pandas stores the missing value aspd.NA.Table.from_pandasdoes not raise, but it treats onlynp.nanas missing, so the same column comes back as anobjectcolumn withpd.NAstored under the mask.The two methods each decided on their own which values were missing:
from_dfcalledisnanon every non-string value, andfrom_pandasaccepted only thenp.nanobject. (Onmainthis check lives in the_get_string_fill_valuehelper, where #20599 moved it without changing it; the behavior is the same in v8.0.2.) This PR passes in the null mask that each caller already computes (narwhalsis_null()infrom_df, pandasisnull()infrom_pandas), and the helper skips masked entries whatever their value. Both methods now give the same result for every case below. Columns that are not all strings still stayobject, as before.Fixes #20663.
AI disclosure
This PR includes AI-generated content using Claude Opus 5.5. I have carefully reviewed the content and fully understand the changes.
Details
Click to expand
Use the caller's null mask to decide which entries of a string column are missing
1. Results before and after
Measured on
main(68e0005) and on this branch with pandas 2.3.3. Themainresults are identical with the_dataframes.pyof v8.0.2, the latest release: #20599 moved this check into_get_string_fill_valuewithout changing its behavior, so these are all changes since the last release. The two methods disagree on 9 of the 12 rows onmain.from_dfmainfrom_pandasmaindtype="string",None(stored aspd.NA)TypeErrorobjectMaskedColumn <U1dtype="string[pyarrow]",NoneTypeErrorobjectMaskedColumn <U1dtype=object,pd.NATypeErrorobjectMaskedColumn <U1dtype=object,pd.NaTTypeErrorobjectMaskedColumn <U1dtype=object,None<U1objectMaskedColumn <U1dtype=object,np.nan<U1<U1MaskedColumn <U1dtype=object,[b"a", None]S1objectMaskedColumn S1dtype=object,["a", b"b", None]<U1objectMaskedColumn <U1dtype=object, allNoneS1objectMaskedColumn object, all maskeddtype="string", allNoneTypeErrorobjectMaskedColumn <U1, all maskeddtype=object,["a", {"x": 1}]TypeErrorobjectColumn objectdtype=object,["a", b"\xff"]UnicodeDecodeErrorUnicodeDecodeErrorColumn object2. Mixed
strandbytesA column that mixes text with ASCII bytes still converts to
<U, using NumPy's ASCII decode, which is the convention_get_string_dtypealready documents. Before this PR that worked infrom_df, and infrom_pandaswhen nothing was missing. If the text is mixed with bytes that are not ASCII, NumPy raisesUnicodeDecodeError, so the helper now returnsNoneand the column staysobject. Keeping everystr/bytesmix asobjectwould have been simpler, but it would change a case that works today.3. All-missing columns
When every entry is masked there is no value to take the string type from. On
main,from_dfproduced a fully maskedS1(bytes) column for an all-Noneobjector polarsStringcolumn, whatever the dtype said, and raised for an all-missing pandasstringcolumn, whilefrom_pandasleft anobjectcolumn. The helper now takes adefaultplaceholder that the caller picks from the dataframe dtype:""for a pandasstringdtype or a narwhalsString,b""for a narwhalsBinary, andNone(leave the column asobject) otherwise. A pandasobjectcolumn says nothing about its type, so an all-missing one stays a fully maskedobjectcolumn from both methods. Narwhals reports an all-Nonepandasobjectcolumn asString, sofrom_dfalso checks the native dtype to tell the two apart.4. Implementation
astropy/table/_column_mixins.pyx,_get_string_fill_value(data, mask, default=None): thelegacy_pandaskeyword and thelibc.math.isnancimport are removed. The function loops overzip(data, mask)and skips masked entries. It returnsNone(keep the column asobject) on the first unmasked value that is notstrorbytes, or if text is mixed with non-ASCII bytes. Otherwise it returns""orb"", following the first unmasked value, anddefaultwhen every entry is masked.astropy/table/_dataframes.py,from_df: computesnw_mask.to_numpy()once and passes it to the helper and the fill comprehension.defaultisb""fornw.Binary,""fornw.Stringunless the native column dtype is NumPyobject, elseNone.from_pandas: passes its existingmask, withdefault=""for apd.StringDtypecolumn andNoneotherwise.5. Tests
Three new parametrized tests in
astropy/table/tests/test_df.py, each run forfrom_dfandfrom_pandas(from_dfis skipped without narwhals), plus one polars test:test_from_dataframe_string_missing_values:string,string[pyarrow], andobjectcolumns withNone,np.nan,pd.NA,pd.NaT, bytes, and mixedstr/ASCII bytes, forTableandQTable.test_from_dataframe_string_all_missing: all-missingstringandstring[pyarrow]columns give a fully masked<U1column; an all-missingobjectcolumn stays a fully maskedobjectcolumn.test_from_dataframe_object_column_kept: dicts, lists, and text mixed with non-ASCII bytes stayobject, with and without a missing value.test_from_df_polars_string_missing_values: polarsStringandBinarycolumns with a null, and all-null, give<U1andS1masked columns.pd.NAandpd.NaTappear in the parametrize lists as the strings"pd.NA"/"pd.NaT"and are mapped back inside the tests, because pandas is imported lazily in this module. With the two source files reverted tomain, 49 of the 70 new cases fail. The other 21 (for exampleobject+np.nan, or an all-missingobjectcolumn infrom_pandas) already pass onmainand are kept as regression guards.python -m pytest astropy/table/tests/test_df.py astropy/table/tests/test_column.py astropy/table/tests/test_pickle.py docs/table: 414 passed, 53 skipped, 2 xfailed.6. Backwards compatibility
No API change;
_get_string_fill_valueis private. Thefrom_dfcases that raised now return a column. Infrom_pandas, string columns whose missing value isNone,pd.NAorpd.NaTnow become nativeU/SMaskedColumns instead ofobjectcolumns. That matches whatfrom_pandasalready did fornp.nan, and whatfrom_dfdid forNone. An all-missing column follows the dataframe dtype from both methods: a pandasstringor polarsStringcolumn becomes a fully masked<U1column, and a pandasobjectcolumn stays a fully maskedobjectcolumn. Onmain,from_dfgaveS1for theobjectcolumn and raised for thestringcolumn, andfrom_pandasgaveobjectfor both. Columns with no missing values, and those holding non-string objects, are converted as before.One more case changes, in
from_pandas(df, index=True). The index gets an all-False mask, so a NaN in anobject-dtype index is never treated as missing. Onmainthe helper counted that NaN as missing anyway and the index became a<U32column holding the string'nan'; now the NaN is an unmasked non-string value, so the index stays anobjectcolumn holding the float NaN. Masking the index properly is a separate change.Changelog:
docs/changes/table/20805.bugfix.rst.🤖 Generated with Claude Code