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
This commit is contained in:
조진명 authored and GitHub committed 2026-07-19 13:58:32 +09:00
1 parent 6f9e3a19aa
commit 875400413f
3 files changed
+95 -2

No files matched your search

-2
View File
@@ -276,7 +276,6 @@ class Test_Csv(unittest.TestCase):
f'1,2{lineterminator}'
f'"\r","\n"{lineterminator}')
@unittest.expectedFailure # TODO: RUSTPYTHON
def test_write_iterable(self):
self._write_test(iter(['a', 1, 'p,q']), 'a,1,"p,q"')
self._write_test(iter(['a', 1, None]), 'a,1,')
@@ -319,7 +318,6 @@ class Test_Csv(unittest.TestCase):
self.assertEqual(fileobj.read(), 'a\r\n""\r\n')
@unittest.expectedFailure # TODO: RUSTPYTHON
def test_write_empty_fields(self):
self._write_test((), '')
self._write_test([''], '""')
+50
View File
@@ -1380,6 +1380,55 @@ mod _csv {
self.write.call((s,), vm)
}
fn writerow_minimal(&self, row: PyObjectRef, vm: &VirtualMachine) -> PyResult {
let _state = self.state.lock();
let row: ArgIterable = ArgIterable::try_from_object(vm, row.clone()).map_err(|_e| {
new_csv_error(
vm,
format!("'{}' object is not iterable", row.class().name()),
)
})?;
let fields = row.iter(vm)?.collect::<PyResult<Vec<_>>>()?;
let single_field = fields.len() == 1;
let mut output = Vec::new();
for (index, field) in fields.into_iter().enumerate() {
if index > 0 {
output.push(self.dialect.delimiter);
}
let stringified;
let data: &[u8] = match_class!(match field {
ref s @ PyStr => s.as_bytes(),
crate::builtins::PyNone => b"",
ref obj => {
stringified = obj.str(vm)?;
stringified.as_bytes()
}
});
// CPython quotes a QUOTE_MINIMAL field if it contains the
// delimiter, the quote character, '\r', '\n', or the line
// terminator, regardless of which line terminator is
// configured. A row with a single empty field is also quoted
// so that it is not read back as an empty line.
if field_needs_quotes(data, self.dialect) || (single_field && data.is_empty()) {
write_quoted_field(&mut output, data, self.dialect, vm)?;
} else {
output.extend_from_slice(data);
}
}
write_lineterminator(&mut output, self.dialect.lineterminator);
let s = core::str::from_utf8(&output)
.map_err(|_| vm.new_unicode_decode_error("csv not utf8"))?;
self.write.call((s,), vm)
}
#[pymethod]
fn writerow(&self, row: PyObjectRef, vm: &VirtualMachine) -> PyResult {
match self.dialect.quoting {
@@ -1387,6 +1436,7 @@ mod _csv {
QuoteStyle::Strings | QuoteStyle::Notnull => {
return self.writerow_quoted_strings(row, vm);
}
QuoteStyle::Minimal => return self.writerow_minimal(row, vm),
_ => {}
}
+45
View File
@@ -147,3 +147,48 @@ def test_quote_none_reader_skipinitialspace_escapechar():
test_quote_none_reader_skipinitialspace_escapechar()
def test_quote_minimal_writer_lineterminator():
# https://github.com/RustPython/RustPython/issues/8302
# QUOTE_MINIMAL must quote '\r' and '\n' regardless of the line terminator.
buf = io.StringIO()
writer = csv.writer(buf, lineterminator="!")
writer.writerow(["a", "b"])
writer.writerow([1, 2])
writer.writerow(["\r", "\n"])
assert buf.getvalue() == 'a,b!1,2!"\r","\n"!'
nul = io.StringIO()
csv.writer(nul, lineterminator="\0").writerow(["\r", "\n"])
assert nul.getvalue() == '"\r","\n"\0'
crlf = io.StringIO()
csv.writer(crlf, lineterminator="!").writerow(["\r\n"])
assert crlf.getvalue() == '"\r\n"!'
# the terminator character itself still triggers quoting
term = io.StringIO()
csv.writer(term, lineterminator="!").writerow(["a!b", "c"])
assert term.getvalue() == '"a!b",c!'
# default terminator behavior is unchanged
default = io.StringIO()
csv.writer(default).writerow(["\r", "\n"])
assert default.getvalue() == '"\r","\n"\r\n'
test_quote_minimal_writer_lineterminator()
def test_quote_minimal_writer_empty_fields():
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow([""])
writer.writerow([None])
writer.writerow([])
writer.writerow(["", ""])
assert buf.getvalue() == '""\r\n""\r\n\r\n,\r\n'
test_quote_minimal_writer_empty_fields()