From 181df51a0c77ac520f7fc4ac7d2cde4591686ec3 Mon Sep 17 00:00:00 2001 From: Saiyam Jain Date: Sun, 25 Oct 2026 20:56:00 +0530 Subject: [PATCH 1/3] gh-160106: Fix csv.Dialect.__replace__() losing slot-backed attributes copy.replace() on a csv.Dialect subclass that stores dialect options in __slots__ would silently drop those attributes. __replace__() only copied self.__dict__, so any attribute stored in a slot was missing from the new object, causing csv.reader/writer to fall back to the default value. Fix by iterating the MRO for __slots__ entries and copying any dialect attribute that was not already set via __dict__. --- Lib/csv.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Lib/csv.py b/Lib/csv.py index b406d82efffd9f..1bf15177aa0d3a 100644 --- a/Lib/csv.py +++ b/Lib/csv.py @@ -125,6 +125,15 @@ def __replace__(self, /, **changes): f'argument {min(unexpected)!r}') new = object.__new__(self.__class__) new.__dict__.update(self.__dict__) + # Copy slot-backed dialect attributes from the instance's MRO, + # since __dict__ only captures attributes stored in __dict__. + for cls in type(self).__mro__: + for attr in getattr(cls, '__slots__', ()): + if attr in _dialect_attributes and attr not in new.__dict__: + try: + new.__dict__[attr] = getattr(self, attr) + except AttributeError: + pass new.__dict__.update(changes) new._validate() return new From 376074f8051049b05e447d241ac3b2f8f97d42be Mon Sep 17 00:00:00 2001 From: "blurb-it[bot]" <43283697+blurb-it[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:33:29 +0000 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=93=9C=F0=9F=A4=96=20Added=20by=20blu?= =?UTF-8?q?rb=5Fit.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../next/Library/2026-10-25-15-33-28.gh-issue-160106.G7eA2V.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 Misc/NEWS.d/next/Library/2026-10-25-15-33-28.gh-issue-160106.G7eA2V.rst diff --git a/Misc/NEWS.d/next/Library/2026-10-25-15-33-28.gh-issue-160106.G7eA2V.rst b/Misc/NEWS.d/next/Library/2026-10-25-15-33-28.gh-issue-160106.G7eA2V.rst new file mode 100644 index 00000000000000..ff1d43731b4a3d --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-25-15-33-28.gh-issue-160106.G7eA2V.rst @@ -0,0 +1 @@ +Fix :func:`copy.replace` on :class:`csv.Dialect` subclasses losing slot-backed attributes. From f27444dd8651fadbed48c3bc53ef651d3e0a6348 Mon Sep 17 00:00:00 2001 From: Saiyam Jain Date: Thu, 5 Nov 2026 20:22:24 +0530 Subject: [PATCH 3/3] gh-160106: Set slot-backed dialect attributes via setattr and add tests --- Lib/csv.py | 18 ++++++---- Lib/test/test_csv.py | 80 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 7 deletions(-) diff --git a/Lib/csv.py b/Lib/csv.py index 1bf15177aa0d3a..554f2f529072ed 100644 --- a/Lib/csv.py +++ b/Lib/csv.py @@ -124,17 +124,21 @@ def __replace__(self, /, **changes): raise TypeError(f'__replace__() got an unexpected keyword ' f'argument {min(unexpected)!r}') new = object.__new__(self.__class__) - new.__dict__.update(self.__dict__) - # Copy slot-backed dialect attributes from the instance's MRO, - # since __dict__ only captures attributes stored in __dict__. + if hasattr(self, '__dict__'): + new.__dict__.update(self.__dict__) + # Copy slot-backed dialect attributes from the instance's MRO. for cls in type(self).__mro__: - for attr in getattr(cls, '__slots__', ()): - if attr in _dialect_attributes and attr not in new.__dict__: + slots = getattr(cls, '__slots__', ()) + if isinstance(slots, str): + slots = (slots,) + for attr in slots: + if attr in _dialect_attributes and attr not in changes: try: - new.__dict__[attr] = getattr(self, attr) + setattr(new, attr, getattr(self, attr)) except AttributeError: pass - new.__dict__.update(changes) + for attr, value in changes.items(): + setattr(new, attr, value) new._validate() return new diff --git a/Lib/test/test_csv.py b/Lib/test/test_csv.py index 36fa7e3572a5ac..3b592c70e660cf 100644 --- a/Lib/test/test_csv.py +++ b/Lib/test/test_csv.py @@ -781,6 +781,86 @@ class mydialect(csv.Dialect): with self.assertRaisesRegex(TypeError, "'delimeter'"): copy.replace(dialect, delimeter=':') + def test_replace_dialect_slots(self): + # Dialect with single string __slots__ + class SingleSlotDialect(csv.excel): + __slots__ = 'delimiter' + def __init__(self): + self.delimiter = ';' + super().__init__() + + d1 = SingleSlotDialect() + r1 = copy.replace(d1, quoting=csv.QUOTE_ALL) + self.assertIsInstance(r1, SingleSlotDialect) + self.assertEqual(r1.delimiter, ';') + self.assertEqual(r1.quoting, csv.QUOTE_ALL) + self.assertEqual(list(csv.reader(['a;b'], r1)), [['a', 'b']]) + + # Replacing a slot-backed attribute + r1_mod = copy.replace(d1, delimiter=':') + self.assertEqual(r1_mod.delimiter, ':') + self.assertEqual(list(csv.reader(['a:b'], r1_mod)), [['a', 'b']]) + + # Dialect with tuple __slots__ + class MultiSlotDialect(csv.excel): + __slots__ = ('delimiter', 'quotechar') + def __init__(self): + self.delimiter = '|' + self.quotechar = '#' + super().__init__() + + d2 = MultiSlotDialect() + r2 = copy.replace(d2, quoting=csv.QUOTE_MINIMAL) + self.assertIsInstance(r2, MultiSlotDialect) + self.assertEqual(r2.delimiter, '|') + self.assertEqual(r2.quotechar, '#') + self.assertEqual(list(csv.reader(['#a|b#|c'], r2)), [['a|b', 'c']]) + + # Subclassing with __slots__ across multiple inheritance levels + class BaseSlotDialect(csv.excel): + __slots__ = ('delimiter',) + def __init__(self): + self.delimiter = ';' + super().__init__() + + class SubSlotDialect(BaseSlotDialect): + __slots__ = ('quotechar', 'escapechar') + def __init__(self): + super().__init__() + self.quotechar = '~' + self.escapechar = '\\' + + d3 = SubSlotDialect() + r3 = copy.replace(d3, doublequote=False) + self.assertIsInstance(r3, SubSlotDialect) + self.assertEqual(r3.delimiter, ';') + self.assertEqual(r3.quotechar, '~') + self.assertEqual(r3.escapechar, '\\') + self.assertFalse(r3.doublequote) + self.assertEqual(list(csv.reader(['~a;b~;c'], r3)), [['a;b', 'c']]) + + # Unset slot attribute on instance + class UnsetSlotDialect(csv.excel): + __slots__ = ('delimiter',) + + d4 = UnsetSlotDialect() + r4 = copy.replace(d4, quoting=csv.QUOTE_ALL) + self.assertIsInstance(r4, UnsetSlotDialect) + self.assertFalse(hasattr(r4, 'delimiter')) + + # Mapping __slots__ + class DictSlotDialect(csv.excel): + __slots__ = {'delimiter': 'doc for delimiter'} + def __init__(self): + self.delimiter = '\t' + super().__init__() + + d5 = DictSlotDialect() + r5 = copy.replace(d5, quoting=csv.QUOTE_ALL) + self.assertIsInstance(r5, DictSlotDialect) + self.assertEqual(r5.delimiter, '\t') + self.assertEqual(list(csv.reader(['a\tb'], r5)), [['a', 'b']]) + def test_pickle(self): for name in csv.list_dialects(): dialect = csv.get_dialect(name)