Skip to content

csv: support multi-character lineterminator in writer#8328

Open
jinmay wants to merge 3 commits into
RustPython:mainfrom
jinmay:csv-multichar-lineterminator
Open

csv: support multi-character lineterminator in writer#8328
jinmay wants to merge 3 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 emit the configured terminator consistently across quoting/escaping modes, including QUOTE_NONE (with escapechar).
    • CSV readers reliably split records on \r\n, leaving other terminator text intact as field content (CPython-aligned).
    • Dialect registration and retrieval now preserve the full lineterminator value.
  • Tests
    • Added a new snippet test verifying multi-character lineterminator behavior across multiple quoting modes, dialect round-tripping, and reader behavior.

@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 09b915a6-742b-4715-ac2b-e11cc894ced0

📥 Commits

Reviewing files that changed from the base of the PR and between 9e3fad7 and f0f1e8c.

⛔ 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 (2)
  • extra_tests/snippets/stdlib_csv.py
  • crates/stdlib/src/csv.rs

📝 Walkthrough

Walkthrough

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

Changes

CSV line terminator support

Layer / File(s) Summary
Dialect terminator representation
crates/stdlib/src/csv.rs
PyDialect and FormatOptions store complete non-empty terminator strings, reject empty values, 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 internally, append the configured string, and borrow dialects through quoting and escaping paths.
Multi-character terminator coverage
extra_tests/snippets/stdlib_csv.py
Tests cover quoting modes, escaping, successive rows, dialect registration, getter values, and reader behavior.

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

Possibly related PRs

  • RustPython/RustPython#8109 — Both PRs modify crates/stdlib/src/csv.rs writer-side quoting/escaping and row-building logic around dialect-provided line-terminator bytes.
  • 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.

Suggested reviewers: youknowone

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 lineterminator handling and validation behavior, which were explicitly marked as separate follow-ups. Limit this PR to writer emission of multi-character lineterminators and move reader/validation changes to separate follow-up work.
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 clearly states the primary change: multi-character lineterminator support in csv.writer.
Linked Issues check ✅ Passed The PR implements the requested writer support for arbitrary string lineterminators and adds coverage for the new behavior.
✨ 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
@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/bz2.py
[x] test: cpython/Lib/test/test_bz2.py (TODO: 2)

dependencies:

  • bz2

dependent tests: (101 tests)

  • bz2: test_bz2 test_codecs test_fileinput test_tarfile
    • fileinput: test_genericalias
    • shutil: test_argparse test_compileall test_ctypes test_embed test_filecmp test_glob test_httpservers test_importlib test_inspect test_largefile test_launcher test_logging test_modulefinder test_os test_peg_generator test_pkgutil test_py_compile test_reprlib test_sax test_shutil test_site test_string_literals test_subprocess test_support test_sysconfig test_tempfile test_traceback test_unicode_file test_venv test_zoneinfo
      • ctypes.util: test_ctypes
      • ensurepip: test_ensurepip
      • http.server: test_robotparser test_urllib2_localnet test_xmlrpc
      • multiprocessing.util: test_asyncio test_concurrent_futures
      • pathlib: test_ast test_dbm_sqlite3 test_importlib test_json test_pathlib test_pyrepl test_runpy test_tomllib test_tools test_unparse test_winapi test_zipapp test_zipfile test_zstd
      • tempfile: test_asyncio test_bytes test_cmd_line test_compile test_concurrent_futures test_contextlib test_cprofile test_csv test_dis test_doctest test_faulthandler test_generated_cases test_hashlib test_importlib test_linecache test_mailbox test_ntpath test_pickle test_pkg test_posix test_pstats test_pydoc test_pyrepl test_regrtest test_selectors test_socket test_sys test_sys_settrace test_tabnanny test_termios test_threadedtempfile test_tokenize test_turtle test_urllib test_urllib2 test_urllib_response test_winconsoleio test_zipfile test_zipfile64
      • webbrowser: test_webbrowser
      • zipapp: test_pdb
      • zipfile: test_zipfile test_zipimport test_zipimport_support
    • zipfile:
      • importlib.metadata: test_importlib

[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
jinmay added 3 commits July 21, 2026 23:15
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.
@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.

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