attrs
attrs copied to clipboard
Support evolving to a subclass (possibly any class)
Recently I wrote a modification to the attrs.evolve
function that works like this:
@attrs.define
class Foo:
a: int
@attrs.define
class Bar(Foo):
b: str
foo = Foo(1)
bar = evolve_as(foo, Bar, b="2")
print(bar)
# Bar(a=1, b='2')
This was very useful for my particular use-case, which involved enriching a data object in a pipeline, so I thought that this might be a good fit for attrs.
My implementation did not require Bar
to be a subclass of Foo
, but I'm not sure about the design implications of that. Might be better to limit this to subclasses only.
For reference, this is my implementation:
def evolve_as(__inst: Any, __cls: Type[_S], **changes: Any) -> _S:
inst_cls = type(__inst)
inst_field_names = {field.name for field in attrs.fields(inst_cls)}
for cls_field in attrs.fields(__cls):
field_name = cls_field.name
if field_name in inst_field_names: # get field from inst
init = cls_field.init
alias = cls_field.alias
if not init:
continue
if alias not in changes:
changes[alias] = getattr(__inst, field_name)
return __cls(**changes)
Had to prefix the parameters with __
to try not to invade the target class attributes namespace
Heh wait, so what you're doing is taking a base class and fill out the rest such that it becomes a super class?
Heh wait, so what you're doing is taking a base class and fill out the rest such that it becomes a super class?
Not a super class, a subclass.
As a more concrete example, I used this in a Document Understanding system to tag text blocks:
@attrs.define
class TextBlock:
text: str
x: float
y: float
width: float
height: float
@attrs.define
class TaggedTextBlock(TextBlock):
tag: str
blocks: List[TextBlock] = ocr(document)
tagged_blocks = [
evolve_as(block, TaggedTextBlock, tag=get_block_tag(block))
for block in blocks
]
For well-behaved subclasses (which for attrs-generated ones should be all of em unless you did init=False
and wrote a "bad" subclass __init__
yourself), isn't your example equivalent to:
tagged_blocks = [
TaggedTextBlock(**attrs.asdict(block), tag=get_block_tag(block))
for block in blocks
]
(i.e. in general, calling attrs.asdict
and adding whatever additional attributes in the subclass you have)?
For well-behaved subclasses (which for attrs-generated ones should be all of em unless you did init=False and wrote a "bad" subclass init yourself), isn't your example equivalent to:
Well sorta... Would you call overwriting superclass fields definitions "well-behaved"? Take overwriting with an alias
as an example:
@attrs.define
class Foo:
a: int
@attrs.define
class Bar(Foo):
a: int = attrs.field(alias="b")
foo = Foo(1)
bar = evolve_as(foo, Bar) # Ok
bar = Bar(**attrs.asdict(foo))
# TypeError: __init__() got an unexpected keyword argument 'a'
This also fails if you overwrite a single field's init
option as False
.
I get that overwriting fields definitions might be an edge case that you don't really want to cover though. Generally attrs.asdict
should be enough.
I think it's also relevant to say that evolve_as
is more performant than creating an intermediate dict with asdict
. It's about 30% faster on my laptop for small objects (2 int fields), and about 70% faster for bigger objects (10 int fields).
Well sorta... Would you call overwriting superclass fields definitions "well-behaved"?
I wouldn't personally, because it means the subclass has a different signature than the superclass and that breaks with multiple inheritance and/or violates Liskov (though IIRC there's some back and forth about whether people agree to the latter indeed violating LSP, and considering I don't personally generally use inheritance, I guess I shouldn't feel too strongly :P) -- but yeah fair enough if that's the case you have in mind that at least clarifies the ask, just wanted to see if I followed, obviously Hynek or some of the library maintainers will have opinions :D
Not sure what you mean by "that breaks with multiple inheritance"...
@attrs.define(slots=False)
class Foo:
a: int
@attrs.define(slots=False)
class Bar:
b: int
@attrs.define(slots=False)
class Baz(Foo, Bar):
a: int = attrs.field(alias="c")
foo = Foo(1)
baz = evolve_as(foo, Baz, b=2) # OK
Maybe getting out of topic, but I think LSP is almost too easy to break if you consider "class signature" as the invariant property. Just add a new field without a default value and you can't instantiate the class in the same way, breaking LSP.