Skip to content

Commit 0e791a5

Browse files
committed
BUG: Fix inconsistent C engine quoting behaviour
1) Add significant testing to quoting in read_csv 2) Fix bug in C engine in which a NULL quotechar would raise even though quoting=csv.QUOTE_NONE 3) Fix bug in C engine in which quoting=csv.QUOTE_ NONNUMERIC wouldn't cause non-quoted fields to be cast to float. 4) Fixed minor doc error for quoting parameter, as the default value is ZERO not None (this will raise an error in fact).
1 parent b06bc7a commit 0e791a5

File tree

6 files changed

+180
-15
lines changed

6 files changed

+180
-15
lines changed

doc/source/io.rst

+2-3
Original file line numberDiff line numberDiff line change
@@ -287,11 +287,10 @@ lineterminator : str (length 1), default ``None``
287287
quotechar : str (length 1)
288288
The character used to denote the start and end of a quoted item. Quoted items
289289
can include the delimiter and it will be ignored.
290-
quoting : int or ``csv.QUOTE_*`` instance, default ``None``
290+
quoting : int or ``csv.QUOTE_*`` instance, default ``0``
291291
Control field quoting behavior per ``csv.QUOTE_*`` constants. Use one of
292292
``QUOTE_MINIMAL`` (0), ``QUOTE_ALL`` (1), ``QUOTE_NONNUMERIC`` (2) or
293-
``QUOTE_NONE`` (3). Default (``None``) results in ``QUOTE_MINIMAL``
294-
behavior.
293+
``QUOTE_NONE`` (3).
295294
doublequote : boolean, default ``True``
296295
When ``quotechar`` is specified and ``quoting`` is not ``QUOTE_NONE``,
297296
indicate whether or not to interpret two consecutive ``quotechar`` elements

doc/source/whatsnew/v0.18.2.txt

+2
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,8 @@ Bug Fixes
492492
- Bug in ``pd.read_csv()`` with ``engine='python'`` in which trailing ``NaN`` values were not being parsed (:issue:`13320`)
493493
- Bug in ``pd.read_csv()`` that prevents ``usecols`` kwarg from accepting single-byte unicode strings (:issue:`13219`)
494494
- Bug in ``pd.read_csv()`` that prevents ``usecols`` from being an empty set (:issue:`13402`)
495+
- Bug in ``pd.read_csv()`` with ``engine=='c'`` in which null ``quotechar`` was not accepted even though ``quoting`` was specified as ``None`` (:issue:`13411`)
496+
- Bug in ``pd.read_csv()`` with ``engine=='c'`` in which fields were not properly cast to float when quoting was specified as non-numeric (:issue:`13411`)
495497

496498

497499

pandas/io/parsers.py

+1-2
Original file line numberDiff line numberDiff line change
@@ -202,10 +202,9 @@
202202
quotechar : str (length 1), optional
203203
The character used to denote the start and end of a quoted item. Quoted
204204
items can include the delimiter and it will be ignored.
205-
quoting : int or csv.QUOTE_* instance, default None
205+
quoting : int or csv.QUOTE_* instance, default 0
206206
Control field quoting behavior per ``csv.QUOTE_*`` constants. Use one of
207207
QUOTE_MINIMAL (0), QUOTE_ALL (1), QUOTE_NONNUMERIC (2) or QUOTE_NONE (3).
208-
Default (None) results in QUOTE_MINIMAL behavior.
209208
doublequote : boolean, default ``True``
210209
When quotechar is specified and quoting is not ``QUOTE_NONE``, indicate
211210
whether or not to interpret two consecutive quotechar elements INSIDE a

pandas/io/tests/parser/quoting.py

+140
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
# -*- coding: utf-8 -*-
2+
3+
"""
4+
Tests that quoting specifications are properly handled
5+
during parsing for all of the parsers defined in parsers.py
6+
"""
7+
8+
import csv
9+
import pandas.util.testing as tm
10+
11+
from pandas import DataFrame
12+
from pandas.compat import StringIO
13+
14+
15+
class QuotingTests(object):
16+
17+
def test_bad_quote_char(self):
18+
data = '1,2,3'
19+
20+
# Python 2.x: "...must be an 1-character..."
21+
# Python 3.x: "...must be a 1-character..."
22+
msg = '"quotechar" must be a(n)? 1-character string'
23+
tm.assertRaisesRegexp(TypeError, msg, self.read_csv,
24+
StringIO(data), quotechar='foo')
25+
26+
msg = 'quotechar must be set if quoting enabled'
27+
tm.assertRaisesRegexp(TypeError, msg, self.read_csv,
28+
StringIO(data), quotechar=None,
29+
quoting=csv.QUOTE_MINIMAL)
30+
31+
msg = '"quotechar" must be string, not int'
32+
tm.assertRaisesRegexp(TypeError, msg, self.read_csv,
33+
StringIO(data), quotechar=2)
34+
35+
def test_bad_quoting(self):
36+
data = '1,2,3'
37+
38+
msg = '"quoting" must be an integer'
39+
tm.assertRaisesRegexp(TypeError, msg, self.read_csv,
40+
StringIO(data), quoting='foo')
41+
42+
# quoting must in the range [0, 3]
43+
msg = 'bad "quoting" value'
44+
tm.assertRaisesRegexp(TypeError, msg, self.read_csv,
45+
StringIO(data), quoting=5)
46+
47+
def test_quote_char_basic(self):
48+
data = 'a,b,c\n1,2,"cat"'
49+
expected = DataFrame([[1, 2, 'cat']],
50+
columns=['a', 'b', 'c'])
51+
result = self.read_csv(StringIO(data), quotechar='"')
52+
tm.assert_frame_equal(result, expected)
53+
54+
def test_quote_char_various(self):
55+
data = 'a,b,c\n1,2,"cat"'
56+
expected = DataFrame([[1, 2, 'cat']],
57+
columns=['a', 'b', 'c'])
58+
quote_chars = ['~', '*', '%', '$', '@', 'P']
59+
60+
for quote_char in quote_chars:
61+
new_data = data.replace('"', quote_char)
62+
result = self.read_csv(StringIO(new_data), quotechar=quote_char)
63+
tm.assert_frame_equal(result, expected)
64+
65+
def test_null_quote_char(self):
66+
data = 'a,b,c\n1,2,3'
67+
68+
# sanity checks
69+
msg = 'quotechar must be set if quoting enabled'
70+
71+
tm.assertRaisesRegexp(TypeError, msg, self.read_csv,
72+
StringIO(data), quotechar=None,
73+
quoting=csv.QUOTE_MINIMAL)
74+
75+
tm.assertRaisesRegexp(TypeError, msg, self.read_csv,
76+
StringIO(data), quotechar='',
77+
quoting=csv.QUOTE_MINIMAL)
78+
79+
# no errors should be raised if quoting is None
80+
expected = DataFrame([[1, 2, 3]],
81+
columns=['a', 'b', 'c'])
82+
83+
result = self.read_csv(StringIO(data), quotechar=None,
84+
quoting=csv.QUOTE_NONE)
85+
tm.assert_frame_equal(result, expected)
86+
87+
result = self.read_csv(StringIO(data), quotechar='',
88+
quoting=csv.QUOTE_NONE)
89+
tm.assert_frame_equal(result, expected)
90+
91+
def test_quoting_various(self):
92+
data = '1,2,"foo"'
93+
cols = ['a', 'b', 'c']
94+
95+
# QUOTE_MINIMAL and QUOTE_ALL apply only to
96+
# the CSV writer, so they should have no
97+
# special effect for the CSV reader
98+
expected = DataFrame([[1, 2, 'foo']], columns=cols)
99+
100+
# test default (afterwards, arguments are all explicit)
101+
result = self.read_csv(StringIO(data), names=cols)
102+
tm.assert_frame_equal(result, expected)
103+
104+
result = self.read_csv(StringIO(data), quotechar='"',
105+
quoting=csv.QUOTE_MINIMAL, names=cols)
106+
tm.assert_frame_equal(result, expected)
107+
108+
result = self.read_csv(StringIO(data), quotechar='"',
109+
quoting=csv.QUOTE_ALL, names=cols)
110+
tm.assert_frame_equal(result, expected)
111+
112+
# QUOTE_NONE tells the reader to do no special handling
113+
# of quote characters and leave them alone
114+
expected = DataFrame([[1, 2, '"foo"']], columns=cols)
115+
result = self.read_csv(StringIO(data), quotechar='"',
116+
quoting=csv.QUOTE_NONE, names=cols)
117+
tm.assert_frame_equal(result, expected)
118+
119+
# QUOTE_NONNUMERIC tells the reader to cast
120+
# all non-quoted fields to float
121+
expected = DataFrame([[1.0, 2.0, 'foo']], columns=cols)
122+
result = self.read_csv(StringIO(data), quotechar='"',
123+
quoting=csv.QUOTE_NONNUMERIC,
124+
names=cols)
125+
tm.assert_frame_equal(result, expected)
126+
127+
def test_double_quote(self):
128+
data = 'a,b\n3,"4 "" 5"'
129+
130+
expected = DataFrame([[3, '4 " 5']],
131+
columns=['a', 'b'])
132+
result = self.read_csv(StringIO(data), quotechar='"',
133+
doublequote=True)
134+
tm.assert_frame_equal(result, expected)
135+
136+
expected = DataFrame([[3, '4 " 5"']],
137+
columns=['a', 'b'])
138+
result = self.read_csv(StringIO(data), quotechar='"',
139+
doublequote=False)
140+
tm.assert_frame_equal(result, expected)

pandas/io/tests/parser/test_parsers.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from .common import ParserTests
1212
from .header import HeaderTests
1313
from .comment import CommentTests
14+
from .quoting import QuotingTests
1415
from .usecols import UsecolsTests
1516
from .skiprows import SkipRowsTests
1617
from .index_col import IndexColTests
@@ -28,7 +29,7 @@ class BaseParser(CommentTests, CompressionTests,
2829
IndexColTests, MultithreadTests,
2930
NAvaluesTests, ParseDatesTests,
3031
ParserTests, SkipRowsTests,
31-
UsecolsTests):
32+
UsecolsTests, QuotingTests):
3233
def read_csv(self, *args, **kwargs):
3334
raise NotImplementedError
3435

pandas/parser.pyx

+33-9
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ from libc.string cimport strncpy, strlen, strcmp, strcasecmp
77
cimport libc.stdio as stdio
88
import warnings
99

10+
from csv import QUOTE_MINIMAL, QUOTE_NONNUMERIC, QUOTE_NONE
1011
from cpython cimport (PyObject, PyBytes_FromString,
1112
PyBytes_AsString, PyBytes_Check,
1213
PyUnicode_Check, PyUnicode_AsUTF8String)
@@ -283,6 +284,7 @@ cdef class TextReader:
283284
object compression
284285
object mangle_dupe_cols
285286
object tupleize_cols
287+
list dtype_cast_order
286288
set noconvert, usecols
287289

288290
def __cinit__(self, source,
@@ -393,8 +395,13 @@ cdef class TextReader:
393395
raise ValueError('Only length-1 escapes supported')
394396
self.parser.escapechar = ord(escapechar)
395397

396-
self.parser.quotechar = ord(quotechar)
397-
self.parser.quoting = quoting
398+
self._set_quoting(quotechar, quoting)
399+
400+
# TODO: endianness just a placeholder?
401+
if quoting == QUOTE_NONNUMERIC:
402+
self.dtype_cast_order = ['<f8', '<i8', '|b1', '|O8']
403+
else:
404+
self.dtype_cast_order = ['<i8', '<f8', '|b1', '|O8']
398405

399406
if comment is not None:
400407
if len(comment) > 1:
@@ -548,6 +555,29 @@ cdef class TextReader:
548555
def set_error_bad_lines(self, int status):
549556
self.parser.error_bad_lines = status
550557

558+
def _set_quoting(self, quote_char, quoting):
559+
if not isinstance(quoting, int):
560+
raise TypeError('"quoting" must be an integer')
561+
562+
if not QUOTE_MINIMAL <= quoting <= QUOTE_NONE:
563+
raise TypeError('bad "quoting" value')
564+
565+
if not isinstance(quote_char, (str, bytes)) and quote_char is not None:
566+
dtype = type(quote_char).__name__
567+
raise TypeError('"quotechar" must be string, '
568+
'not {dtype}'.format(dtype=dtype))
569+
570+
if quote_char is None or quote_char == '':
571+
if quoting != QUOTE_NONE:
572+
raise TypeError("quotechar must be set if quoting enabled")
573+
self.parser.quoting = quoting
574+
self.parser.quotechar = -1
575+
elif len(quote_char) > 1: # 0-len case handled earlier
576+
raise TypeError('"quotechar" must be a 1-character string')
577+
else:
578+
self.parser.quoting = quoting
579+
self.parser.quotechar = ord(quote_char)
580+
551581
cdef _make_skiprow_set(self):
552582
if isinstance(self.skiprows, (int, np.integer)):
553583
parser_set_skipfirstnrows(self.parser, self.skiprows)
@@ -1066,7 +1096,7 @@ cdef class TextReader:
10661096
return self._string_convert(i, start, end, na_filter, na_hashset)
10671097
else:
10681098
col_res = None
1069-
for dt in dtype_cast_order:
1099+
for dt in self.dtype_cast_order:
10701100
try:
10711101
col_res, na_count = self._convert_with_dtype(
10721102
dt, i, start, end, na_filter, 0, na_hashset, na_flist)
@@ -1847,12 +1877,6 @@ cdef kh_float64_t* kset_float64_from_list(values) except NULL:
18471877
return table
18481878

18491879

1850-
# if at first you don't succeed...
1851-
1852-
# TODO: endianness just a placeholder?
1853-
cdef list dtype_cast_order = ['<i8', '<f8', '|b1', '|O8']
1854-
1855-
18561880
cdef raise_parser_error(object base, parser_t *parser):
18571881
message = '%s. C error: ' % base
18581882
if parser.error_msg != NULL:

0 commit comments

Comments
 (0)