csv: support multi-character lineterminator in writer by jinmay · Pull Request #8328 · RustPython/RustPython · GitHub
Skip to content

csv: support multi-character lineterminator in writer - #8328

Merged
youknowone merged 4 commits into
RustPython:mainfrom
jinmay:csv-multichar-lineterminator
Jul 29, 2026
Merged

csv: support multi-character lineterminator in writer#8328
youknowone merged 4 commits into
RustPython:mainfrom
jinmay:csv-multichar-lineterminator

Conversation

@jinmay

@jinmay jinmay commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Summary

csv.writer rejected any lineterminator that was not a single character — including the default '\r\n' when passed explicitly — because the dialect stored it as a single-byte csv_core::Terminator. CPython accepts an arbitrary-length terminator.

The dialect now stores the line terminator as an owned String (dropping PyDialect's Copy derive). The hand-written writer paths (QUOTE_MINIMAL / QUOTE_NONE / QUOTE_STRINGS / QUOTE_NOTNULL) emit the full terminator directly. The csv_core-backed QUOTE_ALL / QUOTE_NONNUMERIC paths still rely on csv_core for its quoting bookkeeping, so they emit a one-byte sentinel terminator (keeping csv_core's closing-quote / empty-record handling intact) and then replace that sentinel with the real terminator. field_needs_quotes / field_needs_escape now quote/escape a field containing any byte of the terminator, matching CPython.

The reader now ignores lineterminator and always splits on \r / \n / \r\n, matching CPython (csv.reader never honored the dialect terminator) and avoiding a mid-UTF-8 record split that a multi-byte terminator would otherwise cause.

import csv, io
sio = io.StringIO()
csv.writer(sio, lineterminator="!@#").writerow(["a", "b"])
# before: TypeError: "lineterminator" must be a 1-character string
# after:  'a,b!@#'   (matches CPython)

Scoped to ASCII terminators; non-ASCII/surrogate terminators and empty-terminator acceptance are left for a follow-up.

Test plan

  • Unmarks test_write_lineterminator (@unittest.expectedFailure before). cargo run --release -- -m test test_csv: 128 run, 7 skipped, SUCCESS.
  • extra_tests/snippets/stdlib_csv.py: passes (added test_multichar_lineterminator covering multi-char terminators, terminator-byte quoting, QUOTE_ALL / QUOTE_NONNUMERIC, QUOTE_NONE escaping, register_dialect round-trip, and reader behavior; verified on both RustPython and CPython).
  • cargo fmt --check / cargo clippy: clean (no new warnings).
  • Compared against CPython 3.14 on the same machine; the writer outputs match for ASCII terminators.

Assisted-by: Claude Code:claude-opus-4-8

Summary by CodeRabbit

  • Bug Fixes
    • Improved CSV lineterminator handling to support arbitrary non-empty multi-character terminators.
    • CSV writers now preserve and emit the configured terminator consistently across quoting/escaping modes, including QUOTE_NONE with escapechar.
    • CSV readers reliably split records on \r\n while keeping other terminator text inside fields (CPython-aligned).
    • Dialects/options now preserve full lineterminator values and reject non-ASCII inputs.
  • Tests
    • Added coverage for multi-character lineterminator behavior across quoting modes and reader/writer round-tripping.
    • Added tests validating rejection of non-ASCII lineterminator values.

@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/stdlib/src/csv.rs (1)

885-935: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Redundant sentinel-terminator setup duplicated across both dialect branches.

.terminator(Terminator::Any(CSV_CORE_TERMINATOR_SENTINEL)) is set inside both the DialectItem::Str (Line 894) and DialectItem::Obj (Line 911) branches, and then unconditionally overwritten again with the identical value at Line 934. The two branch-local calls are dead code since Line 934 always re-applies the same sentinel regardless of dialect source — this is exactly the value-differs/logic-shared duplication the repo guideline calls out.

As per coding guidelines, "When branches differ only in a value but share common logic, extract the differing value first, then call the common logic once to avoid duplicate code."

♻️ Proposed fix removing the redundant branch-local terminator calls
             if let Some(dialect) = g.get(name) {
                 let mut builder = builder
                     .delimiter(dialect.delimiter)
-                    .double_quote(dialect.doublequote)
-                    .terminator(Terminator::Any(CSV_CORE_TERMINATOR_SENTINEL));
+                    .double_quote(dialect.doublequote);
             DialectItem::Obj(obj) => {
                 let mut builder = builder
                     .delimiter(obj.delimiter)
-                    .double_quote(obj.doublequote)
-                    .terminator(Terminator::Any(CSV_CORE_TERMINATOR_SENTINEL));
+                    .double_quote(obj.doublequote);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/stdlib/src/csv.rs` around lines 885 - 935, Remove the redundant
.terminator(Terminator::Any(CSV_CORE_TERMINATOR_SENTINEL)) calls from both
DialectItem::Str and DialectItem::Obj builder branches in to_writer. Keep the
existing unconditional terminator assignment after the dialect-specific and
override handling so the sentinel is applied once for every dialect source.

Source: Coding guidelines

🧹 Nitpick comments (1)
crates/stdlib/src/csv.rs (1)

1488-1496: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Correctness-critical sentinel invariant relies only on debug_assert_eq!, which is compiled out in release builds.

If the assumption that buffer[buffer_offset - 1] == CSV_CORE_TERMINATOR_SENTINEL ever breaks (e.g. future change to to_writer/csv-core usage), release builds would silently drop the wrong byte and splice the real terminator into corrupted output instead of failing loudly. Given this is currently guaranteed by how csv-core's terminator() is used, the risk is low today, but the guard should hold in release builds too.

🛡️ Proposed fix to keep the check in release builds
-            debug_assert_eq!(buffer[buffer_offset - 1], CSV_CORE_TERMINATOR_SENTINEL);
+            assert_eq!(buffer[buffer_offset - 1], CSV_CORE_TERMINATOR_SENTINEL);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/stdlib/src/csv.rs` around lines 1488 - 1496, Replace the debug-only
assertion in the output construction path with a release-enforced check that
validates buffer[buffer_offset - 1] is CSV_CORE_TERMINATOR_SENTINEL before
removing it; fail loudly if the invariant is violated, while preserving the
existing terminator replacement behavior for valid output.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/stdlib/src/csv.rs`:
- Around line 623-632: The empty-lineterminator validation is duplicated with
inconsistent exception types and messages. Update FormatOptions::from_args at
crates/stdlib/src/csv.rs:623-632 and prase_lineterminator_from_obj at
crates/stdlib/src/csv.rs:223-236 to share one validation path, using the same
exception type and wording that describe the actual non-empty constraint;
preserve acceptance of non-empty strings, including multi-character values.

---

Outside diff comments:
In `@crates/stdlib/src/csv.rs`:
- Around line 885-935: Remove the redundant
.terminator(Terminator::Any(CSV_CORE_TERMINATOR_SENTINEL)) calls from both
DialectItem::Str and DialectItem::Obj builder branches in to_writer. Keep the
existing unconditional terminator assignment after the dialect-specific and
override handling so the sentinel is applied once for every dialect source.

---

Nitpick comments:
In `@crates/stdlib/src/csv.rs`:
- Around line 1488-1496: Replace the debug-only assertion in the output
construction path with a release-enforced check that validates
buffer[buffer_offset - 1] is CSV_CORE_TERMINATOR_SENTINEL before removing it;
fail loudly if the invariant is violated, while preserving the existing
terminator replacement behavior for valid output.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 79e824cb-2025-42fe-a920-917fb6699cf3

📥 Commits

Reviewing files that changed from the base of the PR and between 3290f28 and c6565d0.

⛔ Files ignored due to path filters (1)
  • Lib/test/test_csv.py is excluded by !Lib/**
📒 Files selected for processing (2)
  • crates/stdlib/src/csv.rs
  • extra_tests/snippets/stdlib_csv.py

Comment thread crates/stdlib/src/csv.rs Outdated
@github-actions

github-actions Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

📦 Library Dependencies

The following Lib/ modules were modified. Here are their dependencies:

[x] lib: cpython/Lib/csv.py
[x] test: cpython/Lib/test/test_csv.py (TODO: 17)

dependencies:

  • csv

dependent tests: (4 tests)

  • csv: test_csv test_genericalias
    • importlib.metadata: test_importlib test_zoneinfo

Legend:

  • [+] path exists in CPython
  • [x] up-to-date, [ ] outdated

@jinmay
jinmay force-pushed the csv-multichar-lineterminator branch from c6565d0 to 5659610 Compare July 20, 2026 02:57
@jinmay
jinmay marked this pull request as draft July 20, 2026 03:38
@youknowone youknowone added the z-ca-2026 Tag to track Contribution Academy 2026 label Jul 20, 2026
@jinmay
jinmay marked this pull request as ready for review July 20, 2026 07:27
@widehyo1

Copy link
Copy Markdown
Contributor

Since #8304 was merged, the read_quote_none_record function has been removed, so this branch now has a merge conflict. That wasn't intentional—could you take a look?

@jinmay
jinmay force-pushed the csv-multichar-lineterminator branch from 9e3fad7 to f0f1e8c Compare July 22, 2026 00:07
@jinmay

jinmay commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

@widehyo1 sure. I will! thank you for catching this

Comment thread crates/stdlib/src/csv.rs
Comment on lines +1336 to 1354

@doma17 doma17 Jul 23, 2026

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.

Hi @jinmay

Lineterminator accepts non-ASCII strings, but this byte-wise check can split UTF-8 fields.
Please reject them or handle them character-wise.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks @doma17, good catch.

I deferred non-ASCII terminators on purpose since doing it properly is a much bigger change — delimiter/quotechar/escapechar are still single bytes, so it only really makes sense together with full Unicode dialect support (#8310). But I only wrote that down in the issue and PR description and never enforced it in code, so the parser accepted non-ASCII and then handled it byte-wise. Didn't think to guard it there.

I'll reject non-ASCII terminators for now (csv.Error) and leave full support to the #8310 follow-up.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@jinmay A golden rule when you know caveat is leaving a comment here is a caveat. please add a comment about future TODO about this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Sorry, I should have put that in the code in the first place rather than only in the PR description. Added a TODO at this line and on the validation helper, both pointing at #8310 for the code-point-wise handling.

@youknowone youknowone left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Looks good in general, please also fix build failure

widehyo1 added a commit to widehyo1/RustPython that referenced this pull request Jul 26, 2026
Replace the csv-core reader and per-item quote scanner with one Rust
implementation of CPython's nine-state reader parser. Keep parser state
across iterator items and distinguish virtual item boundaries from true
iterator exhaustion.

Centralize field completion so quote provenance, empty-field None conversion,
float conversion, strict parsing, field limits, blank rows, and escaped or
quoted newlines share one path. Preserve the reentrant-iterator generation
guard and the post-RustPython#8328 writer behavior.

Remove the expected-failure markers from the eleven reader tests that now
pass.

Assisted-by: Codex:gpt-5.6-sol
@jinmay
jinmay force-pushed the csv-multichar-lineterminator branch from f0f1e8c to 7c5a962 Compare July 27, 2026 14:25
@jinmay
jinmay requested review from doma17 and youknowone July 27, 2026 14:26

@coderabbitai coderabbitai Bot left a comment

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.

🧹 Nitpick comments (1)
extra_tests/snippets/stdlib_csv.py (1)

232-237: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Widen QUOTE_NONE escaping coverage to all terminator bytes.

This only verifies that ! (one byte of the three-byte terminator !@#) gets escaped. A field containing all three bytes together (e.g. "a!@#b") would better validate that the per-byte escaping loop correctly handles a multi-character terminator end-to-end.

♻️ Suggested broader case
     none = io.StringIO()
     csv.writer(
         none, lineterminator="!@#", quoting=csv.QUOTE_NONE, escapechar="\\"
     ).writerow(["a!b", "x"])
     assert none.getvalue() == "a\\!b,x!@#", none.getvalue()
+
+    none_all = io.StringIO()
+    csv.writer(
+        none_all, lineterminator="!@#", quoting=csv.QUOTE_NONE, escapechar="\\"
+    ).writerow(["a!@#b", "x"])
+    assert none_all.getvalue() == "a\\!\\@\\`#b`,x!@#", none_all.getvalue()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@extra_tests/snippets/stdlib_csv.py` around lines 232 - 237, Expand the
QUOTE_NONE test around the csv.writer call to use a field containing all
terminator bytes together, such as “a!@#b”, and update the expected escaped
output accordingly so the assertion verifies each byte in the multi-character
lineterminator is escaped.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@extra_tests/snippets/stdlib_csv.py`:
- Around line 232-237: Expand the QUOTE_NONE test around the csv.writer call to
use a field containing all terminator bytes together, such as “a!@#b”, and
update the expected escaped output accordingly so the assertion verifies each
byte in the multi-character lineterminator is escaped.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro Plus

Run ID: b770ed4d-605d-4c30-915e-7466e37f6a5c

📥 Commits

Reviewing files that changed from the base of the PR and between f0f1e8c and 7c5a962.

⛔ Files ignored due to path filters (1)
  • Lib/test/test_csv.py is excluded by !Lib/**
📒 Files selected for processing (2)
  • crates/stdlib/src/csv.rs
  • extra_tests/snippets/stdlib_csv.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/stdlib/src/csv.rs

@doma17 doma17 left a comment

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.

LGTM.

@coderabbitai coderabbitai Bot mentioned this pull request Jul 27, 2026
2 tasks
jinmay added 4 commits July 28, 2026 08:00
Store the dialect line terminator as an owned String instead of the
single-byte csv_core::Terminator, dropping PyDialect's Copy derive. The
manual writer paths emit the full terminator; the csv-core-backed
QUOTE_ALL/QUOTE_NONNUMERIC paths emit a sentinel byte (preserving
csv-core's quote/empty-record bookkeeping) and append the real
terminator. field_needs_quotes/escape now quote a field containing any
terminator byte. The reader ignores lineterminator and always uses CRLF,
matching CPython and avoiding mid-UTF-8 record splits.
- Remove the redundant per-branch sentinel terminator setup in to_writer;
  the unconditional terminator call after the match is the single source.
- Reword the empty-lineterminator errors to "must not be empty" (the
  constraint is non-empty, not single-character) on both entry points.
- Promote the sentinel invariant check in writerow from debug_assert_eq!
  to assert_eq! so it also guards release builds.
- Add a snippet case for a field containing a line-break byte to ensure
  the csv-core path drops only the trailing terminator.
The writer decides what to quote and escape by comparing raw bytes, so a
non-ASCII terminator quoted a field that merely shared a UTF-8 lead byte,
and QUOTE_NONE escaped individual bytes of the terminator and then failed to
decode the record back to a string.

Reject non-ASCII terminators, including lone surrogates, as csv.Error when
the dialect is parsed, and leave code-point-wise handling to a follow-up
(RustPython#8310). The non-string error message now matches CPython as well.
@jinmay
jinmay force-pushed the csv-multichar-lineterminator branch from 7c5a962 to 61e9782 Compare July 27, 2026 23:03

@youknowone youknowone left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@jinmay Thank you!

@doma17 @widehyo1 And thank you so much for the reviews!

@youknowone
youknowone merged commit 6906aa1 into RustPython:main Jul 29, 2026
27 checks passed
@jinmay
jinmay deleted the csv-multichar-lineterminator branch July 30, 2026 04:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

z-ca-2026 Tag to track Contribution Academy 2026

Projects

None yet

Development

Successfully merging this pull request may close these issues.

csv.writer: multi-character lineterminator is truncated to a single byte

4 participants