Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions Lib/test/test_csv.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,10 @@ def _test_arg_valid(self, ctor, arg):
self.assertRaises(ValueError, ctor, arg,
quotechar='\x85', lineterminator='\x85')

@unittest.expectedFailure # TODO: RUSTPYTHON
def test_reader_arg_valid(self):
self._test_arg_valid(csv.reader, [])
self.assertRaises(OSError, csv.reader, BadIterable())

@unittest.expectedFailure # TODO: RUSTPYTHON
def test_writer_arg_valid(self):
self._test_arg_valid(csv.writer, StringIO())
class BadWriter:
Expand Down
120 changes: 99 additions & 21 deletions crates/stdlib/src/csv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ mod _csv {
use itertools::Itertools;
use parking_lot::Mutex;
use rustpython_common::{lock::LazyLock, wtf8::Wtf8Buf};
use rustpython_vm::{match_class, sliceable::SliceableSequenceOp};
use rustpython_vm::match_class;
use std::collections::HashMap;

#[pyattr]
Expand Down Expand Up @@ -173,12 +173,11 @@ mod _csv {
} else {
match_class!(match obj.to_owned() {
s @ PyStr => {
Ok(s.as_bytes().iter().copied().exactly_one().map_err(|_| {
parse_single_char(&s, |len| {
vm.new_type_error(format!(
r#""delimiter" must be a unicode character, not a string of length {}"#,
s.len()
r#""delimiter" must be a unicode character, not a string of length {len}"#
))
})?)
})
}
attr => {
Err(vm.new_type_error(format!(
Expand All @@ -193,8 +192,13 @@ mod _csv {
fn parse_quotechar_from_obj(vm: &VirtualMachine, obj: &PyObject) -> PyResult<Option<u8>> {
match_class!(match obj.get_attr("quotechar", vm)? {
s @ PyStr => {
Ok(Some(s.as_bytes().iter().copied().exactly_one().map_err(|_| {
new_csv_error(vm, format!(r#""quotechar" must be a unicode character or None, not a string of length {}"#, s.len()))
Ok(Some(parse_single_char(&s, |len| {
new_csv_error(
vm,
format!(
r#""quotechar" must be a unicode character or None, not a string of length {len}"#
),
)
})?))
}
_n @ PyNone => {
Expand All @@ -215,10 +219,12 @@ mod _csv {
fn parse_escapechar_from_obj(vm: &VirtualMachine, obj: &PyObject) -> PyResult<Option<u8>> {
match_class!(match obj.get_attr("escapechar", vm)? {
s @ PyStr => {
Ok(Some(s.as_bytes().iter().copied().exactly_one().map_err(|_| {
Ok(Some(parse_single_char(&s, |len| {
new_csv_error(
vm,
format!(r#""escapechar" must be a unicode character or None, not a string of length {}"#, s.len()),
format!(
r#""escapechar" must be a unicode character or None, not a string of length {len}"#
),
)
})?))
}
Expand Down Expand Up @@ -277,6 +283,18 @@ mod _csv {
})
}

fn parse_single_char(
s: &Py<PyStr>,
error: impl Fn(usize) -> PyBaseExceptionRef,
) -> PyResult<u8> {
let ch = s
.as_wtf8()
.code_points()
.exactly_one()
.map_err(|_| error(s.char_len()))?;
u8::try_from(ch.to_u32()).map_err(|_| error(s.char_len()))
}

fn prase_quoting_from_obj(vm: &VirtualMachine, obj: &PyObject) -> PyResult<QuoteStyle> {
match_class!(match obj.get_attr("quoting", vm)? {
i @ PyInt => {
Expand Down Expand Up @@ -344,6 +362,7 @@ mod _csv {
};

let dialect = opts.update_py_dialect(dialect);
validate_dialect(vm, &dialect)?;
GLOBAL_HASHMAP
.lock()
.insert(name.as_str().to_owned(), dialect);
Expand Down Expand Up @@ -441,13 +460,14 @@ mod _csv {
_rest: FuncArgs,
vm: &VirtualMachine,
) -> PyResult<Reader> {
let dialect = options.result(vm)?;
Ok(Reader {
iter,
state: PyMutex::new(ReadState {
line_num: 0,
generation: 0,
}),
dialect: options.result(vm)?,
dialect,
})
}

Expand All @@ -466,14 +486,15 @@ mod _csv {
return Err(vm.new_type_error(r#"argument 1 must have a "write" method"#));
}
};
let dialect = options.result(vm)?;

Ok(Writer {
write,
state: PyMutex::new(WriteState {
buffer: vec![0; 1024],
writer: options.to_writer(),
}),
dialect: options.result(vm)?,
dialect,
Comment on lines +489 to +497

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@coderabbitai Is the "Configure csv-core from the resolved dialect" comment still valid?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@fanninpm Yes. The comment is still valid.

writer resolves dialect with options.result(vm), but it configures csv_core::Writer with options.to_writer(). These two paths can produce different settings.

For example, FormatOptions::to_writer() applies escapechar only from self.escapechar. It does not apply an escapechar inherited from the resolved PyDialect. The stored Writer::dialect uses that inherited value, but the csv_core::Writer does not.

Configure csv_core::Writer directly from the resolved dialect to keep both writer paths consistent.

You are interacting with an AI system.

})
}

Expand Down Expand Up @@ -640,11 +661,14 @@ mod _csv {

if let Some(escapechar) = args.kwargs.swap_remove("escapechar") {
res.escapechar = match_class!(match escapechar {
s @ PyStr =>
Some(s.as_bytes().iter().copied().exactly_one().map_err(|_| {
vm.new_type_error(r#""escapechar" must be a 1-character string"#)
})?),
_ => None,
s @ PyStr => Some(parse_single_char(&s, |_| {
vm.new_type_error(r#""escapechar" must be a 1-character string"#)
})?),
_ => {
return Err(ArgumentError::Exception(
vm.new_type_error(r#""escapechar" must be a 1-character string"#),
));
}
})
};
Comment on lines 662 to 673

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@coderabbitai Is the "Use the dialect attribute validation helpers for escapechar and quotechar kwargs" comment still valid?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@fanninpm, the comment is still partially valid.

The new parse_single_char call fixes validation for string values. It rejects invalid lengths and non-byte Unicode characters.

However, escapechar=None is still rejected because the keyword branch accepts only PyStr. The dialect attribute helper accepts None. Also, FormatOptions::escapechar: Option<u8> cannot represent an explicit None override that clears an escapechar inherited from a dialect.

The comment should be narrowed to preserve None handling and explicit override behavior.

You are interacting with an AI system.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment in question is #8402 (review) authored by @coderabbitai.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

repo="RustPython/RustPython"
pr_number="8402"
review_id="4788540642"

echo "== Review =="
gh api "repos/$repo/pulls/$pr_number/reviews/$review_id" \
  --jq '{id, user: .user.login, state, body, submitted_at, commit_id}'

echo
echo "== Inline comments in review =="
gh api --paginate "repos/$repo/pulls/$pr_number/comments?per_page=100" \
  --jq ".[] | select(.pull_request_review_id == $review_id) | {id, path, line, side, body, html_url}"

Length of output: 32674


@fanninpm, thank you for the correction. I reviewed that original CodeRabbit review.

The original comment is partially valid.

  • The string validation part is resolved. escapechar now uses parse_single_char, and quotechar already validates a single character.
  • The remaining issue is escapechar=None. The escapechar keyword branch rejects None, although dialect attributes accept it.
  • FormatOptions::escapechar: Option<u8> also cannot represent an explicit None override for an escapechar inherited from a base dialect.
  • quotechar already has Option<Option<u8>>, so it can represent that override.

The original wording should be narrowed. It should request consistent escapechar=None handling and an Option<Option<u8>> representation for FormatOptions::escapechar. It should not require direct use of parse_escapechar_from_obj or parse_quotechar_from_obj, because those helpers read attributes from a dialect object rather than parse keyword values.

You are interacting with an AI system.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can @coderabbitai edit @coderabbitai's comment from earlier to that effect?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

repo="RustPython/RustPython"
pr_number="8402"
review_id="4788540642"

gh api --paginate "repos/$repo/pulls/$pr_number/comments?per_page=100" \
  --jq ".[] | select(.pull_request_review_id == $review_id and .user.login == \"coderabbitai\") | {id, path, line, side, body, html_url}"

Length of output: 450


🏁 Script executed:

#!/bin/bash
set -euo pipefail

repo="RustPython/RustPython"
review_id="4788540642"

gh api --paginate "repos/$repo/pulls/reviews/$review_id/comments?per_page=100" \
  --jq '.[] | {id, user: .user.login, path, line, side, body, html_url}'

Length of output: 676


🏁 Script executed:

#!/bin/bash
set -euo pipefail

repo="RustPython/RustPython"
pr_number="8402"
review_id="4788540642"

gh api "repos/$repo/pulls/$pr_number/reviews/$review_id" \
  --jq '{id, user: .user.login, state, body, submitted_at, commit_id, html_url}'

Length of output: 7318


🏁 Script executed:

#!/bin/bash
set -euo pipefail

repo="RustPython/RustPython"
pr_number="8402"
review_id="4788540642"
body_file="$(mktemp)"
updated_body_file="$(mktemp)"
trap 'rm -f "$body_file" "$updated_body_file"' EXIT

gh api "repos/$repo/pulls/$pr_number/reviews/$review_id" --jq '.body' > "$body_file"

python3 - "$body_file" "$updated_body_file" <<'PY'
from pathlib import Path
import sys

source = Path(sys.argv[1]).read_text()
old_finding = """**Use the dialect attribute validation helpers for `escapechar` and `quotechar` kwargs.**

The `delimiter` kwarg already delegates to `parse_delimiter_from_obj`. `escapechar`/`quotechar` should use the corresponding helpers instead of inlining checks, so invalid types/lengths use the correct `TypeError` text and accept `PyNone` where that helper supports it. This closes the remaining keyword-argument path for issue `#8284`."""
new_finding = """**Support `escapechar=None` as a keyword override.**

The string validation is now correct. However, the `escapechar` keyword branch rejects `None`, while a dialect attribute accepts it. Also, `FormatOptions::escapechar: Option<u8>` cannot represent an explicit `None` override that clears an `escapechar` inherited from a base dialect. Change it to represent three states: omitted, a character, and explicit `None`. Keep `quotechar` behavior unchanged because `Option<Option<u8>>` already represents these states."""

old_prompt = """In `@crates/stdlib/src/csv.rs` around lines 632 - 757, Update
FormatOptions::from_args to parse the escapechar and quotechar kwargs through
the existing dialect attribute validation helpers, matching the delimiter path.
Remove the inline match-based validation and preserve each helper’s handling of
invalid types, character length, and PyNone support."""
new_prompt = """In `@crates/stdlib/src/csv.rs` around lines 632 - 757, Update
FormatOptions so the `escapechar` keyword accepts `None` and can explicitly
clear an `escapechar` inherited from a base dialect. Use a representation that
distinguishes an omitted keyword, a one-character string, and explicit `None`.
Keep the existing single-character validation. Do not change `quotechar`
handling because it already distinguishes these states."""

if source.count(old_finding) != 1 or source.count(old_prompt) != 2:
    raise SystemExit("The expected review text was not found in the expected count.")

updated = source.replace(old_finding, new_finding).replace(old_prompt, new_prompt)
Path(sys.argv[2]).write_text(updated)
PY

gh api \
  --method PATCH \
  "repos/$repo/pulls/$pr_number/reviews/$review_id" \
  --raw-field "body=$(cat "$updated_body_file")" \
  --jq '{id, state, html_url}'

Length of output: 3032


🏁 Script executed:

#!/bin/bash
set -euo pipefail

repo="RustPython/RustPython"
pr_number="8402"
review_id="4788540642"
body_file="$(mktemp)"
trap 'rm -f "$body_file"' EXIT

gh api "repos/$repo/pulls/$pr_number/reviews/$review_id" --jq '.body' > "$body_file"

rg -n -C 4 'Use the dialect attribute|FormatOptions::from_args|escapechar and quotechar|escapechar kwargs' "$body_file"

Length of output: 2244


🏁 Script executed:

#!/bin/bash
set -euo pipefail

repo="RustPython/RustPython"
pr_number="8402"
review_id="4788540642"
body_file="$(mktemp)"
updated_body_file="$(mktemp)"
trap 'rm -f "$body_file" "$updated_body_file"' EXIT

gh api "repos/$repo/pulls/$pr_number/reviews/$review_id" --jq '.body' > "$body_file"

python3 - "$body_file" "$updated_body_file" <<'PY'
from pathlib import Path
import sys

source = Path(sys.argv[1]).read_text()

replacements = {
"""**Use the dialect attribute validation helpers for `escapechar` and `quotechar` kwargs.**

The `delimiter` kwarg already delegates to `parse_delimiter_from_obj`. `escapechar`/`quotechar` should use the corresponding helpers instead of inlining checks, so invalid types/lengths use the correct `TypeError` text and accept `PyNone` where that helper supports it. This closes the remaining keyword-argument path for issue `#8284`."""
:
"""**Support `escapechar=None` as a keyword override.**

The string validation is now correct. However, the `escapechar` keyword branch rejects `None`, while a dialect attribute accepts it. Also, `FormatOptions::escapechar: Option<u8>` cannot represent an explicit `None` override that clears an `escapechar` inherited from a base dialect. Change it to represent omitted, character, and explicit-`None` states. Keep `quotechar` behavior unchanged because `Option<Option<u8>>` already represents these states."""
,
"""In `@crates/stdlib/src/csv.rs` around lines 632 - 757, Update
FormatOptions::from_args to parse the escapechar and quotechar kwargs through
the existing dialect attribute validation helpers, matching the delimiter path.
Remove the inline match-based validation and preserve each helper’s handling of
invalid types, character length, and PyNone support."""
:
"""In `@crates/stdlib/src/csv.rs` around lines 632 - 757, Update
FormatOptions so the `escapechar` keyword accepts `None` and can explicitly
clear an `escapechar` inherited from a base dialect. Use a representation that
distinguishes an omitted keyword, a one-character string, and explicit `None`.
Keep the existing single-character validation. Do not change `quotechar`
handling because it already distinguishes these states."""
,
"""- Around line 632-757: Update FormatOptions::from_args to parse the escapechar
and quotechar kwargs through the existing dialect attribute validation helpers,
matching the delimiter path. Remove the inline match-based validation and
preserve each helper’s handling of invalid types, character length, and PyNone
support."""
:
"""- Around line 632-757: Update FormatOptions so the `escapechar` keyword
accepts `None` and can explicitly clear an inherited `escapechar`. Use a
representation that distinguishes an omitted keyword, a one-character string,
and explicit `None`. Keep the existing single-character validation. Do not
change `quotechar` handling because it already distinguishes these states."""
}

updated = source
for old, new in replacements.items():
    count = updated.count(old)
    if count != 1:
        raise SystemExit(f"Expected exactly one occurrence, found {count}: {old[:72]!r}")
    updated = updated.replace(old, new)

Path(sys.argv[2]).write_text(updated)
PY

gh api \
  --method PATCH \
  "repos/$repo/pulls/$pr_number/reviews/$review_id" \
  --raw-field "body=$(cat "$updated_body_file")" \
  --jq '{id, state, html_url}'

Length of output: 3757


🏁 Script executed:

#!/bin/bash
set -euo pipefail

repo="RustPython/RustPython"
pr_number="8402"
review_id="4788540642"
body_file="$(mktemp)"
trap 'rm -f "$body_file"' EXIT

gh api "repos/$repo/pulls/$pr_number/reviews/$review_id" --jq '.body' > "$body_file"

sed -n '41,59p;99,104p' "$body_file" | cat -vet

Length of output: 1937


🏁 Script executed:

#!/bin/bash
set -euo pipefail

repo="RustPython/RustPython"
pr_number="8402"
review_id="4788540642"
body_file="$(mktemp)"
updated_body_file="$(mktemp)"
trap 'rm -f "$body_file" "$updated_body_file"' EXIT

gh api "repos/$repo/pulls/$pr_number/reviews/$review_id" --jq '.body' > "$body_file"

python3 - "$body_file" "$updated_body_file" <<'PY'
from pathlib import Path
import sys

source = Path(sys.argv[1]).read_text()

replacements = {
"""> **Use the dialect attribute validation helpers for `escapechar` and `quotechar` kwargs.**
> 
> The `delimiter` kwarg already delegates to `parse_delimiter_from_obj`. `escapechar`/`quotechar` should use the corresponding helpers instead of inlining checks, so invalid types/lengths use the correct `TypeError` text and accept `PyNone` where that helper supports it. This closes the remaining keyword-argument path for issue `#8284`."""
:
"""> **Support `escapechar=None` as a keyword override.**
> 
> The string validation is now correct. However, the `escapechar` keyword branch rejects `None`, while a dialect attribute accepts it. Also, `FormatOptions::escapechar: Option<u8>` cannot represent an explicit `None` override that clears an `escapechar` inherited from a base dialect. Change it to represent omitted, character, and explicit-`None` states. Keep `quotechar` behavior unchanged because `Option<Option<u8>>` already represents these states."""
,
"""> In `@crates/stdlib/src/csv.rs` around lines 632 - 757, Update
> FormatOptions::from_args to parse the escapechar and quotechar kwargs through
> the existing dialect attribute validation helpers, matching the delimiter path.
> Remove the inline match-based validation and preserve each helper’s handling of
> invalid types, character length, and PyNone support."""
:
"""> In `@crates/stdlib/src/csv.rs` around lines 632 - 757, Update
> FormatOptions so the `escapechar` keyword accepts `None` and can explicitly
> clear an `escapechar` inherited from a base dialect. Use a representation that
> distinguishes an omitted keyword, a one-character string, and explicit `None`.
> Keep the existing single-character validation. Do not change `quotechar`
> handling because it already distinguishes these states."""
,
"""- Around line 632-757: Update FormatOptions::from_args to parse the escapechar
and quotechar kwargs through the existing dialect attribute validation helpers,
matching the delimiter path. Remove the inline match-based validation and
preserve each helper’s handling of invalid types, character length, and PyNone
support."""
:
"""- Around line 632-757: Update FormatOptions so the `escapechar` keyword
accepts `None` and can explicitly clear an inherited `escapechar`. Use a
representation that distinguishes an omitted keyword, a one-character string,
and explicit `None`. Keep the existing single-character validation. Do not
change `quotechar` handling because it already distinguishes these states."""
}

updated = source
for old, new in replacements.items():
    count = updated.count(old)
    if count != 1:
        raise SystemExit(f"Expected exactly one occurrence, found {count}: {old[:72]!r}")
    updated = updated.replace(old, new)

Path(sys.argv[2]).write_text(updated)
PY

gh api \
  --method PATCH \
  "repos/$repo/pulls/$pr_number/reviews/$review_id" \
  --raw-field "body=$(cat "$updated_body_file")" \
  --jq '{id, state, html_url}'

Length of output: 3805


@fanninpm, I could not edit the earlier review. GitHub rejected the update request with HTTP 404 for the submitted review. The review text remains unchanged.

The narrowed finding is:

Support escapechar=None as a keyword override. The string validation is correct. However, the escapechar keyword rejects None, and FormatOptions::escapechar: Option<u8> cannot represent an explicit None override that clears an inherited escapechar.

You are interacting with an AI system.


Expand Down Expand Up @@ -690,9 +714,9 @@ mod _csv {

if let Some(quotechar) = args.kwargs.swap_remove("quotechar") {
res.quotechar = match_class!(match quotechar {
s @ PyStr => Some(Some(s.as_bytes().iter().copied().exactly_one().map_err(
|_| { vm.new_type_error(r#""quotechar" must be a 1-character string"#) }
)?)),
s @ PyStr => Some(Some(parse_single_char(&s, |_| {
vm.new_type_error(r#""quotechar" must be a 1-character string"#)
})?)),
PyNone => {
if res
.quoting
Expand Down Expand Up @@ -735,6 +759,58 @@ mod _csv {
}
}

fn validate_dialect(vm: &VirtualMachine, dialect: &PyDialect) -> PyResult<()> {
let special = |name: &str, value: u8| {
if matches!(value, b'\r' | b'\n') {
Err(vm.new_value_error(format!(
"{name} must be a single character, not a line break"
)))
} else {
Ok(())
}
};

special("delimiter", dialect.delimiter)?;
if let Some(quotechar) = dialect.quotechar {
special("quotechar", quotechar)?;
}
if let Some(escapechar) = dialect.escapechar {
special("escapechar", escapechar)?;
}

if dialect.skipinitialspace
&& (matches!(dialect.escapechar, Some(b' ')) || matches!(dialect.quotechar, Some(b' ')))
{
return Err(vm.new_value_error(
"escapechar or quotechar cannot be a space when skipinitialspace is enabled",
));
}

let values = [
("delimiter", Some(core::slice::from_ref(&dialect.delimiter))),
(
"quotechar",
dialect.quotechar.as_ref().map(core::slice::from_ref),
),
(
"escapechar",
dialect.escapechar.as_ref().map(core::slice::from_ref),
),
("lineterminator", Some(dialect.lineterminator.as_bytes())),
];
for (index, (left_name, left)) in values.iter().enumerate() {
let Some(left) = left else { continue };
for (right_name, right) in values.iter().skip(index + 1) {
if right.as_ref() == Some(left) {
return Err(vm.new_value_error(format!(
"{left_name} and {right_name} cannot be the same"
)));
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Ok(())
}

impl FormatOptions {
fn update_py_dialect(&self, mut res: PyDialect) -> PyDialect {
macro_rules! check_and_fill {
Expand Down Expand Up @@ -768,7 +844,7 @@ mod _csv {
}

fn result(&self, vm: &VirtualMachine) -> PyResult<PyDialect> {
match &self.dialect {
let dialect = match &self.dialect {
DialectItem::Str(name) => {
let g = GLOBAL_HASHMAP.lock();
if let Some(dialect) = g.get(name) {
Expand All @@ -784,7 +860,9 @@ mod _csv {
let res = g.get("excel").unwrap().clone();
Ok(self.update_py_dialect(res))
}
}
}?;
validate_dialect(vm, &dialect)?;
Ok(dialect)
}

fn get_quoting(&self) -> QuoteStyle {
Expand Down
Loading