pandas-dev/pandas · #71179

BUG: handle Unicode thousands and decimal markers in read_csv

Cha-Imaa · merged Oct 15, 20266 files · 81 + / 8 −
doc/source/whatsnew/v3.2.0.rst1 + / 0 −
@@ -210,6 +210,7 @@ MultiIndex I/O ^^^ - Bug in :class:`DataFrame` repr and :meth:`DataFrame.to_string` where numeric column labels of differing widths were misaligned with their column's values (:issue:`8300`)+- Bug in :func:`read_csv` silently misparsing non-ASCII ``thousands`` and ``decimal`` markers with the C engine; these now fall back to the Python engine with a :class:`errors.ParserWarning`, or raise ``ValueError`` if ``engine="c"`` is explicitly specified (:issue:`71158`) - Bug in :func:`read_csv` with ``engine="c"`` and ``comment`` not skipping a line containing only whitespace before the comment character (:issue:`55916`) - Bug in :func:`read_csv` with ``engine="python"`` and ``index_col`` reading a date like ``"20180101"`` as nanoseconds since the epoch when ``dtype`` is a pyarrow timestamp dtype (:issue:`24542`) - Bug in :func:`read_csv`, :func:`read_excel`, :func:`read_fwf` and :func:`read_html` returning wrong column labels and treating header rows as data when ``header`` was a list not in increasing order, such as ``header=[1, 0]`` (:issue:`47011`)
pandas/_libs/parsers.pyx4 + / 0 −
@@ -581,11 +581,15 @@ cdef class TextReader:          if len(decimal) != 1:             raise ValueError("Only length-1 decimal markers supported")+        if ord(decimal) > 127:+            raise ValueError("Only ASCII decimal markers supported")         self.parser.decimal = <char>ord(decimal)          if thousands is not None:             if len(thousands) != 1:                 raise ValueError("Only length-1 thousands markers supported")+            if ord(thousands) > 127:+                raise ValueError("Only ASCII thousands markers supported")             self.parser.thousands = <char>ord(thousands)          if escapechar is not None:
pandas/io/parsers/readers.py22 + / 5 −
@@ -589,16 +589,17 @@ def _can_parallelize_csv(filepath_or_buffer, kwds: Mapping[str, Any]) -> bool:     if delimiter is None or (len(delimiter) > 1 and delimiter != r"\s+"):         return False -    # A separator or quotechar wider than one byte forces the python engine-    # too, and warns on the way.  The eligibility check has to catch these+    # A separator, quotechar or numeric marker wider than one byte forces+    # the python engine too, and warns on the way. Catch these options     # here: by the time the name-inference read reports the python engine, it     # has already raised that warning, which the serial read then raises     # again.  GH#66259     if len(delimiter) == 1 and ord(delimiter) > 127:         return False-    quotechar = kwds.get("quotechar")-    if isinstance(quotechar, str) and len(quotechar) == 1 and ord(quotechar) > 127:-        return False+    for option in ("quotechar", "thousands", "decimal"):+        marker = kwds.get(option)+        if isinstance(marker, str) and len(marker) == 1 and ord(marker) > 127:+            return False      # Full-line comments before/inside the preamble shift the header location     # in ways _find_data_start_offset cannot see.@@ -2961,6 +2962,22 @@ def _clean_options(                 )                 engine = "python" +        if engine == "c":+            for option in ("thousands", "decimal"):+                marker = options[option]+                if (+                    isinstance(marker, (str, bytes))+                    and len(marker) == 1+                    and ord(marker) > 127+                ):+                    # The C parser consumes UTF-8 bytes, so even a code point+                    # below 256 cannot be represented by its single char.+                    fallback_reason = (+                        f"the 'c' engine does not support non-ASCII {option} markers"+                    )+                    engine = "python"+                    break+         if fallback_reason and self._engine_specified:             raise ValueError(fallback_reason) 
pandas/tests/io/parser/test_encoding.py38 + / 0 −
@@ -25,6 +25,44 @@ skip_pyarrow = pytest.mark.usefixtures("pyarrow_skip")  +@pytest.mark.parametrize("engine", [None, "c", "python"])+@pytest.mark.parametrize(+    "option,marker,value",+    [+        ("thousands", "\u202f", "1/234"),+        ("decimal", "\u066b", "1k5"),+        # Code points below 256 also occupy multiple bytes in UTF-8.+        ("thousands", "\u00a0", "1/234"),+        ("decimal", "\u00a0", "1k5"),+    ],+)+def test_unicode_numeric_marker(engine, option, marker, value):+    # GH#71158: truncating the code point to a byte matched unrelated ASCII.+    data = StringIO(f"number,text\n1{marker}234,{value}\n")+    kwargs = {option: marker, "engine": engine}+    msg = f"the 'c' engine does not support non-ASCII {option} markers"+    if engine == "c":+        with pytest.raises(ValueError, match=msg):+            pd.read_csv(data, **kwargs)+        return++    warning = ParserWarning if engine is None else None+    with tm.assert_produces_warning(warning, match=msg):+        result = pd.read_csv(data, **kwargs)+    expected = pd.DataFrame(+        {"number": [1234 if option == "thousands" else 1.234], "text": [value]}+    )+    tm.assert_frame_equal(result, expected)+++@pytest.mark.parametrize("option", ["thousands", "decimal"])+def test_unicode_numeric_marker_fallback_unsupported_option(option):+    # GH#71158: fallback must not silently discard C-engine-only options.+    msg = f"Falling back.*non-ASCII {option} markers.*low_memory"+    with pytest.raises(ValueError, match=msg):+        pd.read_csv(StringIO("value\n1234\n"), low_memory=False, **{option: "\u202f"})++ def test_bytes_io_input(all_parsers):     encoding = "cp1255"     parser = all_parsers
pandas/tests/io/parser/test_parallel_read.py6 + / 3 −
@@ -2073,9 +2073,12 @@ def test_parallel_fallback_does_not_repeat_python_layer_warning(tmp_path, monkey     ]  -@pytest.mark.parametrize("kwargs", [{"quotechar": "»"}, {"sep": "§"}])-def test_multibyte_sep_or_quotechar_warns_once(tmp_path, monkeypatch, kwargs):-    # Both force TextFileReader onto the python engine, which warns while+@pytest.mark.parametrize(+    "kwargs",+    [{"quotechar": "»"}, {"sep": "§"}, {"thousands": "\u202f"}, {"decimal": "\u066b"}],+)+def test_multibyte_parser_options_warn_once(tmp_path, monkeypatch, kwargs):+    # These force TextFileReader onto the python engine, which warns while     # falling back.  The parallel path has to decline them up front: its     # name-inference read would raise that warning before it can notice the     # engine it got, and the serial read then raises it again (GH#66259).
pandas/tests/io/parser/test_textreader.py10 + / 0 −
@@ -33,6 +33,16 @@   class TestTextReader:+    @pytest.mark.parametrize("option", ["thousands", "decimal"])+    @pytest.mark.parametrize("marker", ["\u00a0", "\u202f", "\u066b"])+    def test_unicode_numeric_marker(self, option, marker):+        # GH#71158: reject unsupported markers before casting to char.+        msg = f"Only ASCII {option} markers supported"+        with pytest.raises(ValueError, match=msg):+            TextReader(+                StringIO("value\n1234\n"), **{option: marker}, **_na_value_kwargs+            )+     @pytest.fixture     def csv_path(self, datapath):         return datapath("io", "data", "csv", "test1.csv")