Skip to content

Commit 0d784ab

Browse files
widehyo1youknowone
authored andcommitted
csv: make skipinitialspace quote-aware
The csv-core preprocessing path split each raw iterator item on every delimiter before trimming field prefixes. Delimiters inside quoted fields were therefore treated as separators, so spaces after them could be removed before csv-core parsed the record. Extract the quote, escape, delimiter, and field-start transitions from read_quote_record() into a small per-item scanner. Reuse its events in the skipinitialspace preprocessor and discard only InitialSpace events while copying all other source bytes unchanged. This keeps the custom quote-mode reader and preprocessing logic aligned without introducing persistent multiline or strict parser state. Remove the delimiter cache that was only needed by the old split-and-join path and add a regression for spaces following a delimiter inside a quoted field. Assisted-by: Codex:gpt-5-sol
1 parent 0c75438 commit 0d784ab

2 files changed

Lines changed: 130 additions & 101 deletions

File tree

crates/stdlib/src/csv.rs

Lines changed: 122 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ mod _csv {
1414
};
1515
use alloc::fmt;
1616
use csv_core::Terminator;
17-
use itertools::{self, Itertools};
17+
use itertools::Itertools;
1818
use parking_lot::Mutex;
1919
use rustpython_common::{lock::LazyLock, wtf8::Wtf8Buf};
2020
use rustpython_vm::{match_class, sliceable::SliceableSequenceOp};
@@ -409,7 +409,6 @@ mod _csv {
409409
output_ends: vec![0; 16],
410410
reader: options.to_reader(),
411411
skipinitialspace: options.get_skipinitialspace(),
412-
delimiter: options.get_delimiter(),
413412
line_num: 0,
414413
}),
415414
dialect: options.result(vm)?,
@@ -773,29 +772,6 @@ mod _csv {
773772
skipinitialspace
774773
}
775774

776-
fn get_delimiter(&self) -> u8 {
777-
let mut delimiter = match &self.dialect {
778-
DialectItem::Str(name) => {
779-
let g = GLOBAL_HASHMAP.lock();
780-
if let Some(dialect) = g.get(name) {
781-
dialect.delimiter
782-
// RustPython todo
783-
// todo! Perfecting the remaining attributes.
784-
} else {
785-
b','
786-
}
787-
}
788-
DialectItem::Obj(obj) => obj.delimiter,
789-
_ => b',',
790-
};
791-
792-
if let Some(attr) = self.delimiter {
793-
delimiter = attr
794-
}
795-
796-
delimiter
797-
}
798-
799775
fn get_lineterminator(&self) -> csv_core::Terminator {
800776
let mut lineterminator = match &self.dialect {
801777
DialectItem::Str(name) => {
@@ -959,7 +935,6 @@ mod _csv {
959935
output_ends: Vec<usize>,
960936
reader: csv_core::Reader,
961937
skipinitialspace: bool,
962-
delimiter: u8,
963938
line_num: u64,
964939
}
965940

@@ -994,92 +969,131 @@ mod _csv {
994969

995970
impl SelfIter for Reader {}
996971

997-
fn read_quote_record(
998-
input: &[u8],
999-
dialect: PyDialect,
1000-
field_limit: isize,
1001-
vm: &VirtualMachine,
1002-
) -> PyResult<Vec<PyObjectRef>> {
1003-
// QUOTE_NOTNULL and QUOTE_STRINGS map empty unquoted fields to None,
1004-
// but preserve quoted empty fields as strings, so retain quote provenance.
1005-
let mut fields = vec![(Vec::new(), false)];
1006-
let mut at_field_start = true;
1007-
let mut in_quoted_field = false;
1008-
let mut escaped = false;
1009-
let mut index = 0;
972+
enum QuoteScanEvent {
973+
InitialSpace,
974+
StartQuotedField,
975+
EndQuotedField,
976+
Escaped(Option<u8>),
977+
DoubleQuote(u8),
978+
Delimiter,
979+
RecordTerminator,
980+
Data(u8),
981+
}
1010982

1011-
while index < input.len() {
1012-
let byte = input[index];
983+
struct QuoteScanState {
984+
at_field_start: bool,
985+
in_quoted_field: bool,
986+
}
1013987

1014-
if escaped {
1015-
fields.last_mut().unwrap().0.push(byte);
1016-
escaped = false;
1017-
at_field_start = false;
1018-
index += 1;
1019-
continue;
988+
impl QuoteScanState {
989+
const fn new() -> Self {
990+
Self {
991+
at_field_start: true,
992+
in_quoted_field: false,
1020993
}
994+
}
995+
996+
fn scan(
997+
&mut self,
998+
input: &[u8],
999+
index: usize,
1000+
dialect: PyDialect,
1001+
unquoted_escape: bool,
1002+
) -> (QuoteScanEvent, usize) {
1003+
let byte = input[index];
10211004

1022-
if dialect.escapechar == Some(byte) {
1023-
escaped = true;
1024-
at_field_start = false;
1025-
index += 1;
1026-
continue;
1005+
if (self.in_quoted_field || unquoted_escape) && dialect.escapechar == Some(byte) {
1006+
self.at_field_start = false;
1007+
return match input.get(index + 1).copied() {
1008+
Some(escaped) => (QuoteScanEvent::Escaped(Some(escaped)), 2),
1009+
None => (QuoteScanEvent::Escaped(None), 1),
1010+
};
10271011
}
10281012

1029-
if in_quoted_field {
1013+
if self.in_quoted_field {
10301014
if dialect.quotechar == Some(byte) {
10311015
if dialect.doublequote && input.get(index + 1) == Some(&byte) {
1032-
fields.last_mut().unwrap().0.push(byte);
1033-
index += 2;
1034-
} else {
1035-
in_quoted_field = false;
1036-
index += 1;
1016+
return (QuoteScanEvent::DoubleQuote(byte), 2);
10371017
}
1038-
} else {
1039-
fields.last_mut().unwrap().0.push(byte);
1040-
index += 1;
1018+
self.in_quoted_field = false;
1019+
return (QuoteScanEvent::EndQuotedField, 1);
10411020
}
1042-
continue;
1021+
return (QuoteScanEvent::Data(byte), 1);
1022+
}
1023+
1024+
if self.at_field_start && dialect.skipinitialspace && byte == b' ' {
1025+
return (QuoteScanEvent::InitialSpace, 1);
10431026
}
10441027

1045-
if at_field_start && dialect.skipinitialspace && byte == b' ' {
1046-
index += 1;
1047-
} else if at_field_start
1028+
if self.at_field_start
10481029
&& dialect.quoting != QuoteStyle::None
10491030
&& dialect.quotechar == Some(byte)
10501031
{
1051-
fields.last_mut().unwrap().1 = true;
1052-
at_field_start = false;
1053-
in_quoted_field = true;
1054-
index += 1;
1055-
} else if byte == dialect.delimiter {
1056-
fields.push((Vec::new(), false));
1057-
at_field_start = true;
1058-
index += 1;
1059-
} else if matches!(byte, b'\r' | b'\n') {
1060-
if !input[index..]
1061-
.iter()
1062-
.all(|&byte| matches!(byte, b'\r' | b'\n'))
1063-
{
1064-
return Err(new_csv_error(
1065-
vm,
1066-
concat!(
1067-
"new-line character seen in unquoted field",
1068-
" - do you need to open the file in universal-newline mode?"
1069-
),
1070-
));
1071-
}
1072-
break;
1032+
self.at_field_start = false;
1033+
self.in_quoted_field = true;
1034+
return (QuoteScanEvent::StartQuotedField, 1);
1035+
}
1036+
1037+
if byte == dialect.delimiter {
1038+
self.at_field_start = true;
1039+
return (QuoteScanEvent::Delimiter, 1);
1040+
}
1041+
1042+
self.at_field_start = false;
1043+
if matches!(byte, b'\r' | b'\n') {
1044+
(QuoteScanEvent::RecordTerminator, 1)
10731045
} else {
1074-
fields.last_mut().unwrap().0.push(byte);
1075-
at_field_start = false;
1076-
index += 1;
1046+
(QuoteScanEvent::Data(byte), 1)
1047+
}
1048+
}
1049+
}
1050+
1051+
fn read_quote_record(
1052+
input: &[u8],
1053+
dialect: PyDialect,
1054+
field_limit: isize,
1055+
vm: &VirtualMachine,
1056+
) -> PyResult<Vec<PyObjectRef>> {
1057+
// QUOTE_NOTNULL and QUOTE_STRINGS map empty unquoted fields to None,
1058+
// but preserve quoted empty fields as strings, so retain quote provenance.
1059+
let mut fields = vec![(Vec::new(), false)];
1060+
let mut scan_state = QuoteScanState::new();
1061+
let mut dangling_escape = false;
1062+
let mut index = 0;
1063+
1064+
while index < input.len() {
1065+
let (event, consumed) = scan_state.scan(input, index, dialect, true);
1066+
match event {
1067+
QuoteScanEvent::InitialSpace | QuoteScanEvent::EndQuotedField => {}
1068+
QuoteScanEvent::StartQuotedField => fields.last_mut().unwrap().1 = true,
1069+
QuoteScanEvent::Escaped(Some(byte)) | QuoteScanEvent::DoubleQuote(byte) => {
1070+
fields.last_mut().unwrap().0.push(byte);
1071+
}
1072+
QuoteScanEvent::Escaped(None) => dangling_escape = true,
1073+
QuoteScanEvent::Delimiter => fields.push((Vec::new(), false)),
1074+
QuoteScanEvent::RecordTerminator => {
1075+
if !input[index..]
1076+
.iter()
1077+
.all(|&byte| matches!(byte, b'\r' | b'\n'))
1078+
{
1079+
return Err(new_csv_error(
1080+
vm,
1081+
concat!(
1082+
"new-line character seen in unquoted field",
1083+
" - do you need to open the file in universal-newline mode?"
1084+
),
1085+
));
1086+
}
1087+
break;
1088+
}
1089+
QuoteScanEvent::Data(byte) => fields.last_mut().unwrap().0.push(byte),
10771090
}
1091+
index += consumed;
10781092
}
10791093

10801094
// CPython treats an escape character at the end of an iterator item
10811095
// as escaping the implicit newline at the end of that item.
1082-
if escaped {
1096+
if dangling_escape {
10831097
fields.last_mut().unwrap().0.push(b'\n');
10841098
}
10851099

@@ -1124,7 +1138,6 @@ mod _csv {
11241138
output_ends,
11251139
reader,
11261140
skipinitialspace,
1127-
delimiter,
11281141
line_num,
11291142
} = &mut *state;
11301143

@@ -1145,9 +1158,22 @@ mod _csv {
11451158
}
11461159

11471160
#[inline]
1148-
fn trim_initial_spaces(input: &[u8]) -> &[u8] {
1149-
let trimmed_start = input.iter().position(|&x| x != b' ').unwrap_or(input.len());
1150-
&input[trimmed_start..]
1161+
fn trim_initial_spaces(input: &[u8], dialect: PyDialect) -> Vec<u8> {
1162+
let mut trimmed = Vec::with_capacity(input.len());
1163+
let mut scan_state = QuoteScanState::new();
1164+
let mut index = 0;
1165+
1166+
// Delimiters inside quoted fields are data, so only skip spaces
1167+
// after delimiters encountered outside quotes.
1168+
while index < input.len() {
1169+
let (event, consumed) = scan_state.scan(input, index, dialect, false);
1170+
if !matches!(event, QuoteScanEvent::InitialSpace) {
1171+
trimmed.extend_from_slice(&input[index..index + consumed]);
1172+
}
1173+
index += consumed;
1174+
}
1175+
1176+
trimmed
11511177
}
11521178

11531179
#[inline]
@@ -1162,12 +1188,7 @@ mod _csv {
11621188
}
11631189

11641190
let input = if *skipinitialspace {
1165-
let t = input.split(|x| x == delimiter);
1166-
t.map(|x| {
1167-
let trimmed = trim_initial_spaces(x);
1168-
String::from_utf8(trimmed.to_vec()).unwrap()
1169-
})
1170-
.join(format!("{}", *delimiter as char).as_str())
1191+
String::from_utf8(trim_initial_spaces(input, zelf.dialect)).unwrap()
11711192
} else {
11721193
String::from_utf8(input.to_vec()).unwrap()
11731194
};

extra_tests/snippets/stdlib_csv.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,3 +192,11 @@ def test_quote_minimal_writer_empty_fields():
192192

193193

194194
test_quote_minimal_writer_empty_fields()
195+
196+
197+
def test_reader_skipinitialspace_preserves_quoted_spaces():
198+
reader = csv.reader(['a, "b, c", d'], skipinitialspace=True)
199+
assert list(reader) == [["a", "b, c", "d"]]
200+
201+
202+
test_reader_skipinitialspace_preserves_quoted_spaces()

0 commit comments

Comments
 (0)