https://github.com/python/cpython/commit/b4af851f6b5e1cf4dcc2e0397321d7c761f227c6
commit: b4af851f6b5e1cf4dcc2e0397321d7c761f227c6
branch: main
author: Serhiy Storchaka <[email protected]>
committer: serhiy-storchaka <[email protected]>
date: 2026-08-13T13:11:35+03:00
summary:

gh-155033: Support copy.replace() for csv dialects (GH-155035)

Add __replace__() to the _csv.Dialect type and to the csv.Dialect base
class.  Formatting parameters that are not replaced are inherited from
the original dialect, and the result is validated like a newly created
dialect.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

files:
A Misc/NEWS.d/next/Library/2026-08-01-12-00-00.gh-issue-155033.dR3pLc.rst
M Doc/library/csv.rst
M Lib/csv.py
M Lib/test/test_csv.py
M Modules/_csv.c

diff --git a/Doc/library/csv.rst b/Doc/library/csv.rst
index 710a5b11ab6405..631b82ab9a335a 100644
--- a/Doc/library/csv.rst
+++ b/Doc/library/csv.rst
@@ -543,6 +543,13 @@ Dialects support the following attributes:
    When ``True``, raise exception :exc:`Error` on bad CSV input.
    The default is ``False``.
 
+Dialects support :func:`copy.replace`,
+which returns a copy of the dialect
+with the specified formatting parameters replaced.
+
+.. versionchanged:: next
+   Added support for :func:`copy.replace`.
+
 .. _reader-objects:
 
 Reader Objects
diff --git a/Lib/csv.py b/Lib/csv.py
index c66717dc1ee59e..0c5c40c759240c 100644
--- a/Lib/csv.py
+++ b/Lib/csv.py
@@ -82,6 +82,11 @@ class excel:
            "unix_dialect"]
 
 
+_dialect_attributes = frozenset({
+    'delimiter', 'quotechar', 'escapechar', 'doublequote',
+    'skipinitialspace', 'lineterminator', 'quoting', 'strict',
+})
+
 class Dialect:
     """Describe a CSV dialect.
 
@@ -113,6 +118,17 @@ def _validate(self):
             # Re-raise to get a traceback showing more user code.
             raise Error(str(e)) from None
 
+    def __replace__(self, /, **changes):
+        unexpected = changes.keys() - _dialect_attributes
+        if unexpected:
+            raise TypeError(f'__replace__() got an unexpected keyword '
+                            f'argument {min(unexpected)!r}')
+        new = object.__new__(self.__class__)
+        new.__dict__.update(self.__dict__)
+        new.__dict__.update(changes)
+        new._validate()
+        return new
+
 class excel(Dialect):
     """Describe the usual properties of Excel-generated CSV files."""
     delimiter = ','
diff --git a/Lib/test/test_csv.py b/Lib/test/test_csv.py
index 91170cc16b3ac9..7a679d627ff8c0 100644
--- a/Lib/test/test_csv.py
+++ b/Lib/test/test_csv.py
@@ -707,6 +707,61 @@ def test_copy(self):
             dialect = csv.get_dialect(name)
             self.assertRaises(TypeError, copy.copy, dialect)
 
+    def test_replace(self):
+        dialect = csv.get_dialect('excel')
+        new = copy.replace(dialect, delimiter=';', strict=True)
+        self.assertIsInstance(new, type(dialect))
+        self.assertEqual(new.delimiter, ';')
+        self.assertTrue(new.strict)
+        # Not replaced parameters are inherited from the original dialect.
+        self.assertEqual(new.quotechar, dialect.quotechar)
+        self.assertEqual(new.escapechar, dialect.escapechar)
+        self.assertEqual(new.lineterminator, dialect.lineterminator)
+        self.assertEqual(new.quoting, dialect.quoting)
+        self.assertEqual(new.doublequote, dialect.doublequote)
+        self.assertEqual(new.skipinitialspace, dialect.skipinitialspace)
+        # The original dialect is left unchanged.
+        self.assertEqual(dialect.delimiter, ',')
+        self.assertFalse(dialect.strict)
+        self.assertEqual(list(csv.reader(['a;b'], new)), [['a', 'b']])
+
+        self.assertIs(copy.replace(dialect), dialect)
+        self.assertRaises(TypeError, copy.replace, dialect, delimeter=';')
+        self.assertRaises(TypeError, copy.replace, dialect, delimiter=';;')
+        self.assertRaises(TypeError, dialect.__replace__, dialect)
+
+    def test_replace_dialect_subclass(self):
+        class mydialect(csv.Dialect):
+            delimiter = ";"
+            quotechar = '"'
+            doublequote = False
+            skipinitialspace = True
+            lineterminator = '\r\n'
+            quoting = csv.QUOTE_ALL
+
+        dialect = mydialect()
+        new = copy.replace(dialect, delimiter=':', quoting=csv.QUOTE_MINIMAL)
+        self.assertIsInstance(new, mydialect)
+        self.assertEqual(new.delimiter, ':')
+        self.assertEqual(new.quoting, csv.QUOTE_MINIMAL)
+        # Not replaced parameters are inherited from the original dialect.
+        self.assertEqual(new.quotechar, '"')
+        self.assertEqual(new.escapechar, None)
+        self.assertEqual(new.lineterminator, '\r\n')
+        self.assertFalse(new.doublequote)
+        self.assertTrue(new.skipinitialspace)
+        # The original dialect is left unchanged.
+        self.assertEqual(dialect.delimiter, ';')
+        self.assertEqual(dialect.quoting, csv.QUOTE_ALL)
+        self.assertEqual(list(csv.reader(['a:b'], new)), [['a', 'b']])
+        # "strict" is supported even if it is not set on the class.
+        self.assertTrue(copy.replace(dialect, strict=True).strict)
+
+        with self.assertRaises(csv.Error):
+            copy.replace(dialect, delimiter='::')
+        with self.assertRaisesRegex(TypeError, "'delimeter'"):
+            copy.replace(dialect, delimeter=':')
+
     def test_pickle(self):
         for name in csv.list_dialects():
             dialect = csv.get_dialect(name)
diff --git 
a/Misc/NEWS.d/next/Library/2026-08-01-12-00-00.gh-issue-155033.dR3pLc.rst 
b/Misc/NEWS.d/next/Library/2026-08-01-12-00-00.gh-issue-155033.dR3pLc.rst
new file mode 100644
index 00000000000000..9100d7be447e41
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2026-08-01-12-00-00.gh-issue-155033.dR3pLc.rst
@@ -0,0 +1,2 @@
+CSV dialects (instances of :class:`csv.Dialect` subclasses and dialect
+objects returned by :func:`csv.get_dialect`) now support :func:`copy.replace`.
diff --git a/Modules/_csv.c b/Modules/_csv.c
index a7fcc78e058f05..c640f2d36a8464 100644
--- a/Modules/_csv.c
+++ b/Modules/_csv.c
@@ -586,9 +586,34 @@ Dialect_reduce(PyObject *self, PyObject *args) {
     return NULL;
 }
 
+PyDoc_STRVAR(dialect_replace_doc,
+"__replace__($self, /, **changes)\n"
+"--\n"
+"\n"
+"Return a copy of the dialect with the specified options replaced.");
+
+static PyObject *
+Dialect_replace(PyObject *self, PyObject *args, PyObject *kwargs)
+{
+    if (PyTuple_GET_SIZE(args) != 0) {
+        PyErr_SetString(PyExc_TypeError,
+                        "__replace__() takes no positional arguments");
+        return NULL;
+    }
+    PyObject *newargs = PyTuple_Pack(1, self);
+    if (newargs == NULL) {
+        return NULL;
+    }
+    PyObject *result = dialect_new(Py_TYPE(self), newargs, kwargs);
+    Py_DECREF(newargs);
+    return result;
+}
+
 static struct PyMethodDef dialect_methods[] = {
     {"__reduce__", Dialect_reduce, METH_VARARGS, dialect_reduce_doc},
     {"__reduce_ex__", Dialect_reduce, METH_VARARGS, dialect_reduce_doc},
+    {"__replace__", _PyCFunction_CAST(Dialect_replace),
+     METH_VARARGS | METH_KEYWORDS, dialect_replace_doc},
     {NULL, NULL}
 };
 

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to