A __replace_item__ protocol to complement __replace__

copy.replace() was introduced to centralize operations that create a copy of an object while replacing selected fields. Could Python benefit from a similar copy.replace_item() function for sequences and mappings? This would provide a shared mechanism for updating lists, tuples, (frozen) dicts, and third-party immutable mappings without modifying the original.

For example:

l = [1, 2, 3]
f = frozendict({"a": 1, "b": 2})  

l2 = copy.replace_item(l, 0, 100)    # [100, 2, 3]
f2 = copy.replace_item(f, "a", 100)  # frozendict({"a": 100, "b": 2})

Built-in types such as list, tuple, dict and frozendict could support this operation, while custom types could implement a corresponding __replace_item__(self, key, value) method that returns an updated object of the same type.

1 Like

Try the pipe operator instead:

>>> f = frozendict({"a": 1, "b": 2})
>>> f | {"a": 100}
frozendict({'a': 100, 'b': 2})
2 Likes

Sure, that works for mappings but not for sequences. My proposal is to give all “replace at a given index” operations a shared entrypoint.

Fair, but I’m not really seeing that “replace at given index” is as universal a concept as you’re implying. Is it a problem that you have the pipe operator for dictionaries and the _replace method for namedtuples? Are there situations where you need to operate generically on immutable maps and sequences, not knowing whether it’s a key or an index?

Namedtuples are a great example. You can copy/replace from a field name but not from an index position even though namedtuples are built to be used both ways. A shared item-replacement protocol would let callers express “return a copy with this item changed” using the same index that they already use to read it.

Where I would find this most useful is as a generic entrypoint to modify immutable containers and to modify mutable containers without worrying about modifying the original. Right now to perform this action on a tuple at say, index 1, you have to do

updated = t[:1] + (new_value,) + t[2:]

And the general form t[:i] + (v,) + t[i+1:] would be wrong for i = -1, requiring extra checks.

Whereas with list you can do

new_list = l.copy()
new_list[1] = new_value 

These are very different mechanisms for essentially the same operation. Unless I am missing something, a generic replace_item function would provide a shared way to perform both of these as copy.replace_item(seq, 1, new_value). Code written using this funciton would keeps working if a list is later changed to a tuple. The objects themselves implementing a replace_item protocol could help centralize the logic and catch edge cases.

As for a different use case, a recursive function applying an override along a path through mixed sequences and mappings could use this operation at every step.

For example:

def override(node, path, value):
    if not path:
        return value
    key, *rest = path
    return copy.replace_item(node, key, override(node[key], rest, value))

overrides = {
    ("servers", 0): "localhost",        # nested sequence index
    ("timeouts", "connect"): 10,        # mapping key
}
for path, value in overrides.items():
    config = override(config, path, value)

The config can be parsed JSON or frozen containers; the function doesn’t need to know. This could also be cheaper too than say a deepcopy followed by mutation because only the containers within the path would be copied.

Every time I’ve heard of someone wanting to do deep mutations of immutable structure trees, it’s been solving the wrong problem. If this really IS the right thing for your use-case, go ahead, but it’s not something that I would want to see in the stdlib.

1 Like