Skip to content

Option to "partially override" an attribute from a parent class? #637

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
pymarv opened this issue Apr 6, 2020 · 21 comments · May be fixed by #1429
Open

Option to "partially override" an attribute from a parent class? #637

pymarv opened this issue Apr 6, 2020 · 21 comments · May be fixed by #1429
Labels

Comments

@pymarv
Copy link

pymarv commented Apr 6, 2020

Hi,

I'm part of a team that's in the early stages of migrating a large legacy app to Python. I'm pushing pretty hard for the team to adopt attrs as our standard way of doing OOP. I know the "attr way" is to avoid inheritance as much as possible, but that's not always possible, especially when you are part of a team with a project that likes to rely on inheritance (generally nothing too crazy, just simple stuff, but still - it's used a lot in the app and it's a good fit a lot of how the app works). One thing we do a lot is have base classes that device attributes that subclasses "further refine". With these, we want to be able to "extended" the attribute definition from the parent classes vs. totally overwrite it. It doesn't seem like this is possible today in attrs. For example:

import attr
import inspect

@attr.s
class One:
    myattrib = attr.ib(
        default=1,
        validator=attr.validators.instance_of(int),
        converter=int,
    )

@attr.s
class Two(One):
    myattrib = attr.ib(
        default=2
    )

print(inspect.getsource(One().__init__))
print(inspect.getsource(Two().__init__))

Output when run:

def __init__(self, myattrib=attr_dict['myattrib'].default):
    self.myattrib = __attr_converter_myattrib(myattrib)
    if _config._run_validators is True:
        __attr_validator_myattrib(self, __attr_myattrib, self.myattrib)

def __init__(self, myattrib=attr_dict['myattrib'].default):
    self.myattrib = myattrib

So if we want to extend the definition of myattrib, we would have to copy/paste the full definition vs. just modify what is different. What would people think about an option that says "take the full definition from the superclasses and just "merge in" this one extra aspect." Having to repeat the full definition across an inheritance hierarchy gets old (and ugly) pretty quick. :-)

Just by way of context on both this ticket and the other ticket I have opened (#573 - lazy=True option), the legacy app we are using is coming from Perl. Yes, I know everyone loves to hate Perl, :-) but hear me out. :-) The app uses Moose (https://metacpan.org/pod/Moose) and it's "lightweight cousin" Moo (https://metacpan.org/pod/Moo) and for all the bad things everyone wants to cast at Perl, the Moose/Moo "OOP framework" is really nice and has some great features. It has completely changed how OOP is done in Perl and took it from horrible to really nice (nobody has done OOP in Perl for 10+ years without using Moose.) (The creator of Moose, Stevan Little, spent lots of time studying and using lots of other OOP methodologies and was able to incorporate the "best of the best" ideas.) It's very similar to attrs in many ways, but there are a few things missing that are SOOO handy, useful, powerful, etc. :-)

In terms of how Moose would write the above example (and use the "+" to signify an "override"):

package One;
use Moo;
use Types::Standard 'Int';
has 'myattrib' => (
    is      => 'ro',
    default => 1,
    isa     => Int,
    coerce  => sub { int(shift) },  # Not required for str->int but to show how others work
);

package Two;
use Moo;
extends 'One';
has '+myattrib' => (                # <== Note the '+'
    default => 2,
);

Would something like an extend=True option to attrs be a nice addition to trigger similar behavior to the "+" shown above?

Thank you

@hynek
Copy link
Member

hynek commented Apr 18, 2020

I do know about Moose and it has been on my agenda to look for inspirations once I run out of own tasks…which weirdly hasn't happened yet. 🤪


Before changing APIs, you can access the field definitions while declaring fields. Which means that the following works:

@attr.s
class Two(One):
    myattrib = attr.ib(
        default=2,
        validator=attr.fields(One).myattrib.validator,
        converter=attr.fields(One).myattrib.converter,
    )

Typing aside, writing a helper that gives you something like evolve_attrib(attr.fields(One).myattrib, default=2) would be trivial and maybe there's more way to make it more ergonomic? The data is all there for you to use.

@wsanchez
Copy link

wsanchez commented Oct 2, 2020

Another detailed description in #698, which is a duplicate.

@hynek
Copy link
Member

hynek commented Oct 26, 2020

So I guess I could be convinced to add something like

@attr.define
class Two(One):
    myattrib = attr.field_evolve(default=2)

That would also keep myattrib in the same order which would solve #707.

However:

  • I find it hard to find the motivation to implement it.
  • @euresti might correct me, but it sounds like a pretty big chunk of work in mypy? And so far they haven't even merged his NG API PR. :(((

@hynek
Copy link
Member

hynek commented Aug 29, 2021

Over at #829 we've come up with a somewhat clunky but magic-free approach of allowing to evolve Attributes (already in) and then convert them to fields/attr.ibs.

In your case that would look like this:

@attr.define
class Two(One):
    myattrib = attr.fields(One).myattrib.evolve(default=2).to_field()

I do realize it's a tad verbose, but it's a lot clearer with less indirection than what was proposed so far and would only require us to impolement to_field for Attribute classes.

@hkclark
Copy link
Contributor

hkclark commented Aug 31, 2021

Great. Many thanks for the follow up! I'll check it out!

@hynek hynek added this to the 22.1.0 milestone Dec 28, 2021
@YAmikep
Copy link

YAmikep commented Jan 2, 2022

To confirm, the above does not exist in the current version yet, correct?

@hynek
Copy link
Member

hynek commented Jan 3, 2022

correct, it does not.

@petered
Copy link

petered commented Jan 5, 2022

The above would be great to have - not only for modifying fields of sub-classes but also for "de-duplicating defaults" - when passing arguments down through multiple levels, as in #876. Not sure how possible this is with regard to attrs internals, but from an external perspective it would be seem more intuitive to have the syntax:

@attrs
class Two(One):
    myattrib = One.myattrib.evolve(default=2)

@reubano
Copy link

reubano commented Jan 22, 2022

Re #829 Here's what I'm doing...

from attr import define, field, fields, validators, make_class

@define
class One:
    myattrib: int = field(
        default=1,
        validator=validators.instance_of(int),
        converter=int,
    )

def create_subclass(name, base, **kwargs):
    def gen_fields(*args):
        for field in args:
            if field.name in kwargs:
                yield field.evolve(default=kwargs[field.name])
            else:
                yield field

    def reset_defaults(cls, fields):
        return list(gen_fields(*fields))

    return make_class(name, {}, bases=(base,), field_transformer=reset_defaults)


Two = create_subclass('Two', One, myattrib=2)
In [31]: fields(One)
Out[31]: (Attribute(name='myattrib', default=1, validator=<instance_of validator for type <class 'int'>>, repr=True, eq=True, eq_key=None, order=True, order_key=None, hash=None, init=True, metadata=mappingproxy({}), type=<class 'int'>, converter=<class 'int'>, kw_only=False, inherited=False, on_setattr=None))

In [32]: fields(Two)
Out[32]: (Attribute(name='myattrib', default=2, validator=<instance_of validator for type <class 'int'>>, repr=True, eq=True, eq_key=None, order=True, order_key=None, hash=None, init=True, metadata=mappingproxy({}), type=<class 'int'>, converter=<class 'int'>, kw_only=False, inherited=True, on_setattr=None))

@hynek
Copy link
Member

hynek commented Jan 2, 2023

a quick note to myself, that the implementation needs to take into account make_class (cf #1074).

@jamesmurphy-mc
Copy link
Contributor

I've implemented evolve_field in one of my codebases by subclassing _CountingAttr and using a custom field transformer (probably not how you want to do it here in upstream), and one thing I'd like to point out is that it seems natural to have evolved fields stay at the same index as the original field rather than go to the end. E.g.

@define
class A:
    x: int = 0
    y: int = 1

# attrs order: x, y

@define
class B(A):
    x: int = evolve_field(default=42)

# attrs order: x, y

@define
class C(A):
    x: int = 42

# attrs order: y, x

Notice how if you simply redefine the x attribute as in C, the order of init parameters is swapped, which may be confusing when used with inheritance. However, in B, evolve_field (which can only be used with inheritance) the order is maintained, keeping the same interface as in the parent class.

Another issue that came up during my implementation was that some sanity checks like "no attrs without a default following an attr with a default" happen before the field transformer is run, so the field transformer has no chance to fix the issues. You may not run into this since it may not be implemented using a field transformer like I did.

@lucas-nelson-uiuc
Copy link

I've implemented evolve_field in one of my codebases by subclassing _CountingAttr and using a custom field transformer (probably not how you want to do it here in upstream), and one thing I'd like to point out is that it seems natural to have evolved fields stay at the same index as the original field rather than go to the end. E.g.

@jamesmurphy-mc how were you able to implement this?

My use-case is similar to yours. Preferably, I would like to implement something like what you have or pass a new field definition altogether that can overwrite the existing field while maintaining order.

@define(kw_only=True)
class A:
    x: int = field(default=0)
    y: str = field(default="1")
    z: str = field(default="2")

# attrs order: x (int), y (str), z (str)


# using your approach
@define(kw_only=True)
class B(A):
    x: str = evolve_field(type=str, default="0")

# attrs order: x (str), y (str), z (str)


@define(kw_only=True)
class C(A):
    x: str = field(default="0")

# attrs order: x (str), y (str), z (str)

@jamesmurphy-mc
Copy link
Contributor

@jamesmurphy-mc how were you able to implement this?

It was a mark and sweep approach, using evolve_field to mark and a custom transformer calling in a @define wrapper to sweep.

Just like field, evolve_field would return a _CountingAttr, but instead of the base class, it would return this custom subclass that had some metadata about what fields are being evolved. We used our own @define that called the attrs one with some special stuff including a custom transformer, and this custom transformer would look for the evolve metadata and handle it accordingly by traversing the MRO and finding the old field from the super class and then applying the changes from the evolve metadata.

@Tinche
Copy link
Member

Tinche commented Dec 23, 2024

Changing the type of an attribute in a subclass is very tricky.

Changing the type to something completely incompatible (like str to int) should never be allowed since it breaks the Liskov substitution principle. Changing the type of a field to a subtype (like changing int to Literal[1]) should only be allowed for frozen classes.

Defaults, aliases and kw-only status should be safe since constructors don't obey the LSP.

You could argue for a consenting adults approach where we allow anything at runtime, but apply restrictions in Mypy. Still seems like a footgun.

@AdrianSosic
Copy link

Hey @Tinche, fully get the LSP argument for str -> int, but can you elaborate why frozenness would be required for a change like int -> Literal[1]? 🤔

I agree that there is probably no obvious answer to this entire evolving fields discussion. However, I wonder what a reasonable/pragmatic alternative would look like for the not-so-uncommon pattern where you want your types in the subclass to be more specific.

Specifically, how would you avoid a situation like

from attrs import define

@define
class BaseSettings: ...

@define
class SpecialSettings(BaseSettings): ...

@define
class Base:
    settings: BaseSettings

@define
class Special(Base): 
    settings: SpecialSettings

I guess one option is use Generics for the base class, but this has a different flavor to it. Would be happy if you could share your thoughts 🙃

@Tinche
Copy link
Member

Tinche commented Feb 3, 2025

can you elaborate why frozenness would be required for a change like int -> Literal[1]?

Sure. Imagine this:

@define
class ClassA:
    a: int

Now, imagine a very simple and innocent function, like this:

def innocent(a: ClassA) -> None:
    a.a += 1

All good so far, right? Now for the final act of the show, imagine:

@define
class ClassB(ClassA):
    a: Literal[1]

innocent(ClassB(1))

It's a variance issue, the same as with list vs Sequence. Variance is a term associated with generics, so that be a clue of what we're dealing with in reality ;)

@AdrianSosic
Copy link

Thanks @Tinche, much appreciated. Makes sense, indeed.

Still, any thoughts on my example above? What's your take on it, i.e. how would you avoid having to copy-paste the entire field definition in the subclass just to change the type annotation?

@Tinche
Copy link
Member

Tinche commented Feb 4, 2025

Try something like this?

from attrs import define


@define
class BaseSettings: ...


@define
class SpecialSettings(BaseSettings): ...


@define
class Base[T = BaseSettings]:
    settings: T


Special = Base[SpecialSettings]

@AdrianSosic
Copy link

Yeah, already thought about using Generics for this. And while it works (and probably is a clean solution), I still wonder: is it possible to avoid using the default type in a certain local context? For example, using your code above, I can't do something like:

lst: list[Base] = [Base(BaseSettings()), Special(SpecialSettings())]

since the default type would be used, which gives:

error: List item 1 has incompatible type "Base[SpecialSettings]"; expected "Base[BaseSettings]"  [list-item]

Of course, one could omit then default from the class definition but then ... well ... there would be no default 🙃 So what is the solution here to enable both: a default + a correct list type?

@Tinche
Copy link
Member

Tinche commented Feb 5, 2025

Since Special is just a static type alias here, what about:

lst: list[Base] = [Base(BaseSettings()), Base(SpecialSettings())]

?

@AdrianSosic
Copy link

Doesn't work unfortunately because – and I forget to mention this – Special is not just an alias but really has some additional stuff defined, e.g.

@define
class Special(Base[SpecialSettings]):
    special_attribute: int = 0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging a pull request may close this issue.