Skip to content

Commit b4af851

Browse files
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) <noreply@anthropic.com>
1 parent c0d0b28 commit b4af851

5 files changed

Lines changed: 105 additions & 0 deletions

File tree

Doc/library/csv.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,13 @@ Dialects support the following attributes:
543543
When ``True``, raise exception :exc:`Error` on bad CSV input.
544544
The default is ``False``.
545545

546+
Dialects support :func:`copy.replace`,
547+
which returns a copy of the dialect
548+
with the specified formatting parameters replaced.
549+
550+
.. versionchanged:: next
551+
Added support for :func:`copy.replace`.
552+
546553
.. _reader-objects:
547554

548555
Reader Objects

Lib/csv.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ class excel:
8282
"unix_dialect"]
8383

8484

85+
_dialect_attributes = frozenset({
86+
'delimiter', 'quotechar', 'escapechar', 'doublequote',
87+
'skipinitialspace', 'lineterminator', 'quoting', 'strict',
88+
})
89+
8590
class Dialect:
8691
"""Describe a CSV dialect.
8792
@@ -113,6 +118,17 @@ def _validate(self):
113118
# Re-raise to get a traceback showing more user code.
114119
raise Error(str(e)) from None
115120

121+
def __replace__(self, /, **changes):
122+
unexpected = changes.keys() - _dialect_attributes
123+
if unexpected:
124+
raise TypeError(f'__replace__() got an unexpected keyword '
125+
f'argument {min(unexpected)!r}')
126+
new = object.__new__(self.__class__)
127+
new.__dict__.update(self.__dict__)
128+
new.__dict__.update(changes)
129+
new._validate()
130+
return new
131+
116132
class excel(Dialect):
117133
"""Describe the usual properties of Excel-generated CSV files."""
118134
delimiter = ','

Lib/test/test_csv.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -707,6 +707,61 @@ def test_copy(self):
707707
dialect = csv.get_dialect(name)
708708
self.assertRaises(TypeError, copy.copy, dialect)
709709

710+
def test_replace(self):
711+
dialect = csv.get_dialect('excel')
712+
new = copy.replace(dialect, delimiter=';', strict=True)
713+
self.assertIsInstance(new, type(dialect))
714+
self.assertEqual(new.delimiter, ';')
715+
self.assertTrue(new.strict)
716+
# Not replaced parameters are inherited from the original dialect.
717+
self.assertEqual(new.quotechar, dialect.quotechar)
718+
self.assertEqual(new.escapechar, dialect.escapechar)
719+
self.assertEqual(new.lineterminator, dialect.lineterminator)
720+
self.assertEqual(new.quoting, dialect.quoting)
721+
self.assertEqual(new.doublequote, dialect.doublequote)
722+
self.assertEqual(new.skipinitialspace, dialect.skipinitialspace)
723+
# The original dialect is left unchanged.
724+
self.assertEqual(dialect.delimiter, ',')
725+
self.assertFalse(dialect.strict)
726+
self.assertEqual(list(csv.reader(['a;b'], new)), [['a', 'b']])
727+
728+
self.assertIs(copy.replace(dialect), dialect)
729+
self.assertRaises(TypeError, copy.replace, dialect, delimeter=';')
730+
self.assertRaises(TypeError, copy.replace, dialect, delimiter=';;')
731+
self.assertRaises(TypeError, dialect.__replace__, dialect)
732+
733+
def test_replace_dialect_subclass(self):
734+
class mydialect(csv.Dialect):
735+
delimiter = ";"
736+
quotechar = '"'
737+
doublequote = False
738+
skipinitialspace = True
739+
lineterminator = '\r\n'
740+
quoting = csv.QUOTE_ALL
741+
742+
dialect = mydialect()
743+
new = copy.replace(dialect, delimiter=':', quoting=csv.QUOTE_MINIMAL)
744+
self.assertIsInstance(new, mydialect)
745+
self.assertEqual(new.delimiter, ':')
746+
self.assertEqual(new.quoting, csv.QUOTE_MINIMAL)
747+
# Not replaced parameters are inherited from the original dialect.
748+
self.assertEqual(new.quotechar, '"')
749+
self.assertEqual(new.escapechar, None)
750+
self.assertEqual(new.lineterminator, '\r\n')
751+
self.assertFalse(new.doublequote)
752+
self.assertTrue(new.skipinitialspace)
753+
# The original dialect is left unchanged.
754+
self.assertEqual(dialect.delimiter, ';')
755+
self.assertEqual(dialect.quoting, csv.QUOTE_ALL)
756+
self.assertEqual(list(csv.reader(['a:b'], new)), [['a', 'b']])
757+
# "strict" is supported even if it is not set on the class.
758+
self.assertTrue(copy.replace(dialect, strict=True).strict)
759+
760+
with self.assertRaises(csv.Error):
761+
copy.replace(dialect, delimiter='::')
762+
with self.assertRaisesRegex(TypeError, "'delimeter'"):
763+
copy.replace(dialect, delimeter=':')
764+
710765
def test_pickle(self):
711766
for name in csv.list_dialects():
712767
dialect = csv.get_dialect(name)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
CSV dialects (instances of :class:`csv.Dialect` subclasses and dialect
2+
objects returned by :func:`csv.get_dialect`) now support :func:`copy.replace`.

Modules/_csv.c

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,9 +586,34 @@ Dialect_reduce(PyObject *self, PyObject *args) {
586586
return NULL;
587587
}
588588

589+
PyDoc_STRVAR(dialect_replace_doc,
590+
"__replace__($self, /, **changes)\n"
591+
"--\n"
592+
"\n"
593+
"Return a copy of the dialect with the specified options replaced.");
594+
595+
static PyObject *
596+
Dialect_replace(PyObject *self, PyObject *args, PyObject *kwargs)
597+
{
598+
if (PyTuple_GET_SIZE(args) != 0) {
599+
PyErr_SetString(PyExc_TypeError,
600+
"__replace__() takes no positional arguments");
601+
return NULL;
602+
}
603+
PyObject *newargs = PyTuple_Pack(1, self);
604+
if (newargs == NULL) {
605+
return NULL;
606+
}
607+
PyObject *result = dialect_new(Py_TYPE(self), newargs, kwargs);
608+
Py_DECREF(newargs);
609+
return result;
610+
}
611+
589612
static struct PyMethodDef dialect_methods[] = {
590613
{"__reduce__", Dialect_reduce, METH_VARARGS, dialect_reduce_doc},
591614
{"__reduce_ex__", Dialect_reduce, METH_VARARGS, dialect_reduce_doc},
615+
{"__replace__", _PyCFunction_CAST(Dialect_replace),
616+
METH_VARARGS | METH_KEYWORDS, dialect_replace_doc},
592617
{NULL, NULL}
593618
};
594619

0 commit comments

Comments
 (0)