Skip to content

Commit a2d3af8

Browse files
committed
[draft] Add with_replace_method= to namedtuple to allow .replace as an alternate to ._replace
Inspired by python#136083 because one offs for this kind of thing feel like they'd create more of a mess than allowing it for anyone? Signifiantly written by Claude Sonnet 4 in Claude Code.
1 parent 5334732 commit a2d3af8

3 files changed

Lines changed: 69 additions & 2 deletions

File tree

Doc/library/collections.rst

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -844,7 +844,7 @@ Named tuples assign meaning to each position in a tuple and allow for more reada
844844
self-documenting code. They can be used wherever regular tuples are used, and
845845
they add the ability to access fields by name instead of position index.
846846

847-
.. function:: namedtuple(typename, field_names, *, rename=False, defaults=None, module=None)
847+
.. function:: namedtuple(typename, field_names, *, rename=False, defaults=None, module=None, with_replace_method=False)
848848

849849
Returns a new tuple subclass named *typename*. The new subclass is used to
850850
create tuple-like objects that have fields accessible by attribute lookup as
@@ -878,6 +878,12 @@ they add the ability to access fields by name instead of position index.
878878
If *module* is defined, the :attr:`~type.__module__` attribute of the
879879
named tuple is set to that value.
880880

881+
If *with_replace_method* is true, the named tuple will have a public
882+
:meth:`replace` method as an alias to the private :meth:`_replace` method.
883+
This provides a :pep:`8` friendly API for creating modified copies of named
884+
tuple instances. This is useful for namedtuples that do not have a field
885+
named ``replace`` and are never expected to gain one in the future.
886+
881887
Named tuple instances do not have per-instance dictionaries, so they are
882888
lightweight and require no more memory than regular tuples.
883889

@@ -901,6 +907,9 @@ they add the ability to access fields by name instead of position index.
901907
Added the *defaults* parameter and the :attr:`~somenamedtuple._field_defaults`
902908
attribute.
903909

910+
.. versionadded:: next
911+
Added the *with_replace_method* parameter.
912+
904913
.. doctest::
905914
:options: +NORMALIZE_WHITESPACE
906915

@@ -917,6 +926,12 @@ they add the ability to access fields by name instead of position index.
917926
>>> p # readable __repr__ with a name=value style
918927
Point(x=11, y=22)
919928

929+
>>> # Example with public replace method
930+
>>> Point = namedtuple('Point', ['x', 'y'], with_replace_method=True)
931+
>>> p = Point(11, 22)
932+
>>> p.replace(x=100) # public replace method when enabled
933+
Point(x=100, y=22)
934+
920935
Named tuples are especially useful for assigning field names to result tuples returned
921936
by the :mod:`csv` or :mod:`sqlite3` modules::
922937

Lib/collections/__init__.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,7 @@ def __ror__(self, other):
358358
except ImportError:
359359
_tuplegetter = lambda index, doc: property(_itemgetter(index), doc=doc)
360360

361-
def namedtuple(typename, field_names, *, rename=False, defaults=None, module=None):
361+
def namedtuple(typename, field_names, *, rename=False, defaults=None, module=None, with_replace_method=False):
362362
"""Returns a new subclass of tuple with named fields.
363363
364364
>>> Point = namedtuple('Point', ['x', 'y'])
@@ -380,6 +380,13 @@ def namedtuple(typename, field_names, *, rename=False, defaults=None, module=Non
380380
>>> p._replace(x=100) # _replace() is like str.replace() but targets named fields
381381
Point(x=100, y=22)
382382
383+
The 'with_replace_method' parameter adds a public 'replace' method as an alias to '_replace':
384+
385+
>>> Point = namedtuple('Point', ['x', 'y'], with_replace_method=True)
386+
>>> p = Point(11, 22)
387+
>>> p.replace(x=100) # public replace method when enabled
388+
Point(x=100, y=22)
389+
383390
"""
384391

385392
# Validate the field names. At the user's option, either generate an error
@@ -426,6 +433,11 @@ def namedtuple(typename, field_names, *, rename=False, defaults=None, module=Non
426433
field_defaults = dict(reversed(list(zip(reversed(field_names),
427434
reversed(defaults)))))
428435

436+
# Check for conflicting field names when with_replace_method is enabled
437+
if with_replace_method and 'replace' in field_names:
438+
raise ValueError('Cannot use with_replace_method=True when '
439+
f'a field named "replace" exists: {field_names!r}')
440+
429441
# Variables used in the methods and docstrings
430442
field_names = tuple(map(_sys.intern, field_names))
431443
num_fields = len(field_names)
@@ -508,6 +520,8 @@ def __getnewargs__(self):
508520
'__getnewargs__': __getnewargs__,
509521
'__match_args__': field_names,
510522
}
523+
if with_replace_method:
524+
class_namespace['replace'] = _replace
511525
for index, name in enumerate(field_names):
512526
doc = _sys.intern(f'Alias for field number {index}')
513527
class_namespace[name] = _tuplegetter(index, doc)

Lib/test/test_collections.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -717,6 +717,44 @@ def test_non_generic_subscript(self):
717717
self.assertIs(type(a), Group)
718718
self.assertEqual(a, (1, [2]))
719719

720+
def test_replace_method_opt_in(self):
721+
# Test that replace method is NOT available by default
722+
Point = namedtuple('Point', 'x y')
723+
p = Point(11, 22)
724+
self.assertFalse(hasattr(p, 'replace'))
725+
self.assertTrue(hasattr(p, '_replace'))
726+
727+
# Test that replace method IS available when with_replace_method=True
728+
PointWithReplace = namedtuple('PointWithReplace', 'x y', with_replace_method=True)
729+
pr = PointWithReplace(11, 22)
730+
self.assertTrue(hasattr(pr, 'replace'))
731+
self.assertTrue(hasattr(pr, '_replace'))
732+
733+
# Test that both methods work identically
734+
self.assertEqual(pr.replace(x=100), (100, 22))
735+
self.assertEqual(pr._replace(x=100), (100, 22))
736+
self.assertEqual(pr.replace(x=100), pr._replace(x=100))
737+
738+
# Test that replace method works with keyword arguments
739+
self.assertEqual(pr.replace(x=5, y=10), (5, 10))
740+
self.assertEqual(pr.replace(y=99), (11, 99))
741+
742+
# Test error handling - invalid field name
743+
with self.assertRaises(TypeError):
744+
pr.replace(invalid_field=123)
745+
746+
# Test validation - cannot have field named "replace" with with_replace_method=True
747+
with self.assertRaises(ValueError) as cm:
748+
namedtuple('BadTuple', ['x', 'replace', 'y'], with_replace_method=True)
749+
self.assertIn('Cannot use with_replace_method=True', str(cm.exception))
750+
self.assertIn('field named "replace"', str(cm.exception))
751+
752+
# Test that field named "replace" works fine without with_replace_method
753+
GoodTuple = namedtuple('GoodTuple', ['x', 'replace', 'y'])
754+
gt = GoodTuple(1, 2, 3)
755+
self.assertEqual(gt.replace, 2) # Field access works
756+
self.assertTrue(hasattr(gt, '_replace')) # _replace method still exists
757+
720758

721759
################################################################################
722760
### Abstract Base Classes

0 commit comments

Comments
 (0)