diff --git a/Doc/library/csv.rst b/Doc/library/csv.rst index 81949261c563d0..c3e02f21d31891 100644 --- a/Doc/library/csv.rst +++ b/Doc/library/csv.rst @@ -528,6 +528,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} };