Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Doc/library/csv.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions Lib/csv.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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 = ','
Expand Down
55 changes: 55 additions & 0 deletions Lib/test/test_csv.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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`.
25 changes: 25 additions & 0 deletions Modules/_csv.c
Original file line number Diff line number Diff line change
Expand Up @@ -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}
};

Expand Down
Loading