Skip to content

Commit 8754004

Browse files
authored
csv: quote embedded CR/LF under QUOTE_MINIMAL (#8315)
* csv: quote embedded CR/LF under QUOTE_MINIMAL with custom lineterminator QUOTE_MINIMAL rows were serialized by csv-core, which only quotes bytes registered for the configured terminator, so fields containing '\r' or '\n' were left unquoted when the lineterminator was not CR/LF (e.g. '!' or '\0'). Route QUOTE_MINIMAL through a hand-written path that uses field_needs_quotes, which always treats '\r'/'\n' as needing quotes. A single empty field is quoted and an empty row emits only the terminator, matching CPython. QUOTE_ALL / QUOTE_NONNUMERIC still use csv-core. Assisted-by: Claude Code:claude-opus-4-8 * csv: unmark now-passing writer tests test_write_iterable and test_write_empty_fields pass with the QUOTE_MINIMAL writer fix (empty rows no longer emit an empty field). Assisted-by: Claude Code:claude-opus-4-8
1 parent 6f9e3a1 commit 8754004

3 files changed

Lines changed: 95 additions & 2 deletions

File tree

‎Lib/test/test_csv.py‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,6 @@ def test_write_lineterminator(self):
276276
f'1,2{lineterminator}'
277277
f'"\r","\n"{lineterminator}')
278278

279-
@unittest.expectedFailure # TODO: RUSTPYTHON
280279
def test_write_iterable(self):
281280
self._write_test(iter(['a', 1, 'p,q']), 'a,1,"p,q"')
282281
self._write_test(iter(['a', 1, None]), 'a,1,')
@@ -319,7 +318,6 @@ def test_writerows_with_none(self):
319318
self.assertEqual(fileobj.read(), 'a\r\n""\r\n')
320319

321320

322-
@unittest.expectedFailure # TODO: RUSTPYTHON
323321
def test_write_empty_fields(self):
324322
self._write_test((), '')
325323
self._write_test([''], '""')

‎crates/stdlib/src/csv.rs‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1380,13 +1380,63 @@ mod _csv {
13801380
self.write.call((s,), vm)
13811381
}
13821382

1383+
fn writerow_minimal(&self, row: PyObjectRef, vm: &VirtualMachine) -> PyResult {
1384+
let _state = self.state.lock();
1385+
1386+
let row: ArgIterable = ArgIterable::try_from_object(vm, row.clone()).map_err(|_e| {
1387+
new_csv_error(
1388+
vm,
1389+
format!("'{}' object is not iterable", row.class().name()),
1390+
)
1391+
})?;
1392+
1393+
let fields = row.iter(vm)?.collect::<PyResult<Vec<_>>>()?;
1394+
let single_field = fields.len() == 1;
1395+
let mut output = Vec::new();
1396+
1397+
for (index, field) in fields.into_iter().enumerate() {
1398+
if index > 0 {
1399+
output.push(self.dialect.delimiter);
1400+
}
1401+
1402+
let stringified;
1403+
let data: &[u8] = match_class!(match field {
1404+
ref s @ PyStr => s.as_bytes(),
1405+
crate::builtins::PyNone => b"",
1406+
ref obj => {
1407+
stringified = obj.str(vm)?;
1408+
stringified.as_bytes()
1409+
}
1410+
});
1411+
1412+
// CPython quotes a QUOTE_MINIMAL field if it contains the
1413+
// delimiter, the quote character, '\r', '\n', or the line
1414+
// terminator, regardless of which line terminator is
1415+
// configured. A row with a single empty field is also quoted
1416+
// so that it is not read back as an empty line.
1417+
if field_needs_quotes(data, self.dialect) || (single_field && data.is_empty()) {
1418+
write_quoted_field(&mut output, data, self.dialect, vm)?;
1419+
} else {
1420+
output.extend_from_slice(data);
1421+
}
1422+
}
1423+
1424+
write_lineterminator(&mut output, self.dialect.lineterminator);
1425+
1426+
let s = core::str::from_utf8(&output)
1427+
.map_err(|_| vm.new_unicode_decode_error("csv not utf8"))?;
1428+
1429+
self.write.call((s,), vm)
1430+
}
1431+
13831432
#[pymethod]
13841433
fn writerow(&self, row: PyObjectRef, vm: &VirtualMachine) -> PyResult {
13851434
match self.dialect.quoting {
13861435
QuoteStyle::None => return self.writerow_quote_none(row, vm),
13871436
QuoteStyle::Strings | QuoteStyle::Notnull => {
13881437
return self.writerow_quoted_strings(row, vm);
13891438
}
1439+
QuoteStyle::Minimal => return self.writerow_minimal(row, vm),
13901440
_ => {}
13911441
}
13921442

‎extra_tests/snippets/stdlib_csv.py‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,3 +147,48 @@ def test_quote_none_reader_skipinitialspace_escapechar():
147147

148148

149149
test_quote_none_reader_skipinitialspace_escapechar()
150+
151+
152+
def test_quote_minimal_writer_lineterminator():
153+
# https://github.com/RustPython/RustPython/issues/8302
154+
# QUOTE_MINIMAL must quote '\r' and '\n' regardless of the line terminator.
155+
buf = io.StringIO()
156+
writer = csv.writer(buf, lineterminator="!")
157+
writer.writerow(["a", "b"])
158+
writer.writerow([1, 2])
159+
writer.writerow(["\r", "\n"])
160+
assert buf.getvalue() == 'a,b!1,2!"\r","\n"!'
161+
162+
nul = io.StringIO()
163+
csv.writer(nul, lineterminator="\0").writerow(["\r", "\n"])
164+
assert nul.getvalue() == '"\r","\n"\0'
165+
166+
crlf = io.StringIO()
167+
csv.writer(crlf, lineterminator="!").writerow(["\r\n"])
168+
assert crlf.getvalue() == '"\r\n"!'
169+
170+
# the terminator character itself still triggers quoting
171+
term = io.StringIO()
172+
csv.writer(term, lineterminator="!").writerow(["a!b", "c"])
173+
assert term.getvalue() == '"a!b",c!'
174+
175+
# default terminator behavior is unchanged
176+
default = io.StringIO()
177+
csv.writer(default).writerow(["\r", "\n"])
178+
assert default.getvalue() == '"\r","\n"\r\n'
179+
180+
181+
test_quote_minimal_writer_lineterminator()
182+
183+
184+
def test_quote_minimal_writer_empty_fields():
185+
buf = io.StringIO()
186+
writer = csv.writer(buf)
187+
writer.writerow([""])
188+
writer.writerow([None])
189+
writer.writerow([])
190+
writer.writerow(["", ""])
191+
assert buf.getvalue() == '""\r\n""\r\n\r\n,\r\n'
192+
193+
194+
test_quote_minimal_writer_empty_fields()

0 commit comments

Comments
 (0)