attrs icon indicating copy to clipboard operation
attrs copied to clipboard

API semantics for deep nested evolve calls

Open jacobg opened this issue 2 years ago • 8 comments

When trying to evolve a frozen object at a deep level, there is a lot of boilerplate, e.g.,:

foo = attr.evolve(
   foo,
   bar=attr.evolve(
      foo.bar,
      baz=attr.evolve(
         foo.bar.baz,
         qux='whew!'
       )
   )
)

Scala has a similar problem, being that immutable data classes is a normal thing in that language. There is a Scala library developed helped to solve that problem: https://www.optics.dev/Monocle/

Maybe it would be useful to have those sort of semantics for updating frozen attr objects,e.g.:

foo = attr.focus(foo, Foo.bar.baz.qux).modify('yay!')

Any thoughts?

jacobg avatar Mar 15 '22 19:03 jacobg

Definitely an interesting idea, but Foo.bar.baz.qux cannot work (since Foo.bar is unset for dict classes, and occupied for slot classes). And nested fields() get ugly.

Tinche avatar Mar 16 '22 02:03 Tinche

Thanks @Tinche . So maybe a string path?

foo = attr.focus(foo,'bar.baz.qux').modify('yay!')

jacobg avatar Mar 16 '22 08:03 jacobg

This might be an alternative approach to #634 and #861.

It shouldn't need any special support within attrs?

Modify seems to be a callable thing (replace is for setting), but that should be easy enough with Python.

cc @sscherfke

hynek avatar Mar 18 '22 06:03 hynek

I just made a separate function based on https://github.com/python-attrs/attrs/issues/634#issuecomment-744060646, and it seems to work fine:

def evolve_recursive(inst, **changes):
    """ Recursive attr.evolve() method, where any attr-based attributes
        will be evolved too.
    """
    cls = inst.__class__
    attrs = attr.fields(cls)
    for a in attrs:
        if not a.init:
            continue
        attr_name = a.name  # To deal with private attributes.
        init_name = attr_name if attr_name[0] != "_" else attr_name[1:]
        value = getattr(inst, attr_name)
        if init_name not in changes:
            # Add original value to changes
            changes[init_name] = value
        elif attr.has(value):
            # Evolve nested attrs classes
            changes[init_name] = attr.evolve(value, **changes[init_name])
    return cls(**changes)

def test_set_deep_sparse():

    @attr.s(auto_attribs=True, frozen=True, slots=True)
    class Bar:
        a: str
        b: str

    @attr.s(auto_attribs=True, frozen=True, slots=True)
    class Baz:
        c: str
        d: str

    @attr.s(auto_attribs=True, frozen=True, slots=True)
    class Foo:
        bar: Bar
        baz: Baz

    foo = Foo(bar=Bar(a='a1', b='b1'), baz=Baz(c='c1', d='d1'))

    assert (evolve_recursive(foo, bar={'a': 'a2'}, baz={'c': 'c2'}) ==
            Foo(bar=Bar(a='a2', b='b1'), baz=Baz(c='c2', d='d1')))

jacobg avatar Mar 18 '22 12:03 jacobg

Typed Settings contains a recursive version of evolve(): https://gitlab.com/sscherfke/typed-settings/-/blob/main/src/typed_settings/attrs/init.py#L328-368 For some cases (with old, untyped coded) it has slightly different behavior than attrs.evolve() (which is why we removed it from attrs).

Typed Settings also defines operations for working with dicts and dotted paths which is a bit similar to @jacobg 's original idea.

Updating nested instances with a dotted path may be easier if you just want to update single attributes. The recursive evolve is a bit more convenient when you want to update multiple attributes (on different nesting levels). So maybe there's room for both variants.

sscherfke avatar Mar 19 '22 10:03 sscherfke

If it's syntactically nicer, one can use the dict constructor in evolve_recursive:

evolve_recursive(foo, bar=dict(a="a2"), baz=dict(c="c2"))

In fact one could define a class Evolve that just wrapped its arguments, and then it could be used backward-compatibly in evolve:

evolve(foo, bar=Evolve(a="a2"), baz=Evolve(c="c2"))

That is, evolve could check specifically whether the value being passed was an instance of Evolve, and if so, know that recursive evolution was being requested. In all other cases evolve would act as now. Since attrs.Evolve would be a new class, this should not break any existing code.

Unfortunately, this won't allow static type checking of the arguments to Evolve. But then I don't really think it's feasible to statically type evolve arguments anyway?

This would look something like:

class Evolve:
    def __init__(self, **changes):
        self.changes = changes
def evolve(inst, **changes):
    cls = inst.__class__
    attrs = fields(cls)
    for a in attrs:
        if not a.init:
            continue
        attr_name = a.name  # To deal with private attributes.
        init_name = a.alias
        if init_name not in changes:
            changes[init_name] = getattr(inst, attr_name)
        elif isinstance(changes[init_name], Evolve):
            changes[init_name] = evolve(getattr(inst, attr_name), changes[init_name].changes)

    return cls(**changes)

If one wanted to be more ambitious, one could make Evolve private (_Evolve) and if attrs.evolve was called without a positional argument, it would return an _Evolve instance; then this sort of implementation would allow one to write:

evolve(foo, bar=evolve(a="a2"), baz=evolve(c="c2"))

Without additional work this would be a problem for error handling, because if one forgot the positional argument the effect would be very peculiar. It might make more sense to give Evolve an modify method, as Scala does:

new_thing = Evolve(bar=Evolve(a="a2"), baz=Evolve(c="c2")).modify(old_thing)

This would coexist fairly well with static type checking, in that an Evolve object would be an obviously different type from the instance we were modifying. In principle it might be possible to make Evolve generic in a way that type inference could work for it.

td-anne avatar Feb 23 '24 10:02 td-anne

I don't think given the increasingly narrow guardrails that are forced on us by Pyright/dataclass transform, thinking about type-safety is a waste of time. :(

The builder approach sure looks interesting, so if someone wants to tackle it, why not add a third way to copy-and-modify instances. ;)

hynek avatar Feb 26 '24 05:02 hynek

If one wanted to be more ambitious, one could make Evolve private (_Evolve) and if attrs.evolve was called without a positional argument, it would return an _Evolve instance; then this sort of implementation would allow one to write:

Incidentally, I implemented exactly this approach some months ago. Here is the code:

https://gist.github.com/loehnertj/4cf864d98054d7a54749e99bd4ae28b8

Documentation and some testing included. Use it as you like. I'll gladly help with integration if desired (however I don't know about attrs' internal structure + standards).

One particular challenge was how to handle list-of-object fields, which occur rather often. This is solved by means of a second "evols" (Evolve-list) function giving you a "builder"-ish interface to modify lists. I.e. you chain method calls to evols in order to change one element, all elements, etc.

Quoting the docstrings:

def evo(_inst: T = None, **kwargs) -> Union[T, Evolution]:
def evols(rtype: type = list) -> ListEvolution:

evo: Create a new instance, based on the first positional argument with changes applied.

This is very similar to attr.evolve, with two major extensions.

The first argument may be omitted. In this case, returns a callable that applies the changes to its single argument, a so called .Evolution.

Secondly, if any keyword argument-value is an Evolution instance, it will be applied to the object's property as identified by the keyword.

This means, that you can save a lot of boilerplate repetition when evolving nested objects. The resulting syntax is very similar to nested construction.

Example: Suppose that object a has subobject b with field x that you want to copy-and-modify.

Classical attr.evolve would read like::

a_mod = evolve(a, b=evolve(a.b, x=1))

With evo, this simplifies to::

a_mod = evo(a, b=evo(x=1))

evols: Frequently, properties are lists of objects. Such properties can be evolved using .evols. Allows both content and structural modification.

Example: suppose that the b property is now list-valued::

a_mod = evo(a, b=evols().at(-1, x=1))
a_mod2 = evo(a_mod, b=evols().pop(0))

To actually specify the operations to do, chain-call the appropriate methods of .ListEvolution. For example, you can mimic sorted(l) by doing::

evolution = evols().sort()
sorted_l = evolution(l)

Other possible operations are adding, removing, reordering items as well as evolving or replacing individual items. See .ListEvolution.

Optionally, return type of the modification can be specified by means of rtype parameter.

List evolution methods include at (modify item), set (replace item), all (modify all items), pop, remove, insert, append, select (permutation), sort.

loehnertj avatar Mar 21 '24 08:03 loehnertj