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

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

CSV dialects now retain arbitrary non-empty ASCII line terminator strings. Readers use CRLF row recognition, while writers replace a csv-core sentinel with the configured terminator. Tests cover quoting modes, dialect registration, reader behavior, and invalid non-ASCII values.

Changes

CSV line terminator support

Layer / File(s) Summary
Dialect terminator representation
crates/stdlib/src/csv.rs
PyDialect and FormatOptions store complete non-empty ASCII terminator strings, expose the full getter value, and clone dialects during resolution and merging.
Reader and writer terminator handling
crates/stdlib/src/csv.rs
Readers configure CRLF recognition independently of the dialect terminator; writers use a sentinel, append the configured string, and borrow dialects through quoting and escaping paths.
Multi-character terminator validation
extra_tests/snippets/stdlib_csv.py
Tests cover quoting modes, escaping, successive rows, dialect registration, getter values, reader behavior, and rejection of non-ASCII terminators.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • RustPython/RustPython#8254 — Both PRs update CSV writer behavior involving dialect-provided line terminators and quoting.
  • RustPython/RustPython#8315 — Both PRs update CSV writer serialization and quoting logic around line terminators.
  • RustPython/RustPython#8381 — The PR directly overlaps the dialect string representation, sentinel-based writer integration, reader behavior, and multi-character terminator tests.

Suggested reviewers: youknowone, shaharnaveh, doma17

Sequence Diagram(s)

sequenceDiagram
  participant CSVAPI
  participant FormatOptions
  participant csv_core
  participant Writer
  CSVAPI->>FormatOptions: parse multi-character lineterminator
  FormatOptions->>csv_core: configure sentinel writer terminator
  Writer->>csv_core: serialize row
  csv_core->>Writer: return row ending with sentinel
  Writer->>CSVAPI: replace sentinel with configured terminator
Loading
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR also changes reader semantics and adds non-ASCII/empty lineterminator validation, which the linked issue marked as follow-up scope. Move reader-behavior and validation changes into separate follow-up PRs, keeping this change focused on writer multi-character lineterminator support.
Docstring Coverage ⚠️ Warning Docstring coverage is 26.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: multi-character lineterminator support in csv.writer.
Linked Issues check ✅ Passed The writer now preserves and emits full multi-character lineterminator values, matching the linked issue's core requirement.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@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
fn field_needs_quotes(data: &[u8], dialect: &PyDialect) -> bool {
data.iter().any(|&byte| {
byte == dialect.delimiter
|| dialect.quotechar == Some(byte)
|| matches!(byte, b'\r' | b'\n')
|| matches!(dialect.lineterminator, Terminator::Any(t) if byte == t)
// CPython quotes a field that contains any character of the line
// terminator. This byte-wise `contains` matches CPython for
// ASCII terminators; non-ASCII terminators are not fully handled.
|| dialect.lineterminator.as_bytes().contains(&byte)
})
}

fn field_needs_escape(byte: u8, dialect: PyDialect) -> bool {
fn field_needs_escape(byte: u8, dialect: &PyDialect) -> bool {
byte == dialect.delimiter
|| dialect.quotechar == Some(byte)
|| dialect.escapechar == Some(byte)
|| matches!(byte, b'\r' | b'\n')
|| matches!(dialect.lineterminator, Terminator::Any(t) if byte == t)
|| dialect.lineterminator.as_bytes().contains(&byte)
}

@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