-
-
Notifications
You must be signed in to change notification settings - Fork 374
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
Add support for cached_properties to slotted attrs classes. #1200
Add support for cached_properties to slotted attrs classes. #1200
Conversation
b1ea100
to
80be0a7
Compare
29e20e4
to
aca9728
Compare
Since In my other projects I've dropped 3.7 long ago, but attrs has still the most downloads on 3.7 so I would like to let it live on a little longer. Would it be a lot of effort to make your gate this feature to 3.8? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Generally LGTM but some questions.
Do cached properties make sense on frozen classes? Philosophically and implementation-wise?
Second, since __getattr__
doesn't chain, will this scenario work:
@define
class A:
@cached_property
def a(self) -> int:
return 1
@define
class B(A):
@cached_property
def b(self) -> int:
return 2
B().a
We might need to crawl the MRO for the final __getattr__
?
Ah, I'd forgotten that cached_proeperty was a "new" addition I've added a commit that restricts the checks to 3.8+, and checked it locally, so it should be working now. |
IMHO, they work well together philosophically. A cached property is persistent for the duration of an instance, so it's already "frozen", it's just a question of delaying the computation (potentially indefinitely, if it's never needed). That makes it roughly comparable to a And, I don't see any issues implementation wise -- my implementation already works with frozen classes.
Yeah, good point. I've played around a bit, and thought I had a working solution, but I think it breaks down on multiple inheritance. I'll try to work something out. |
I've pushed a fix to crawl the mro for To get the |
Thanks for working on this – I'm really excited for this to land! I'll leave the actual reviewing to Tin, but 2 quick notes/questions:
|
d447b1c
to
c1b3a9e
Compare
Hmmm, It looks like coverage data is only being uploaded for 3.7,3.10, and 3.11. The branch that coverage is complaining about only gets hit in 3.8/3.9 because I've pushed a commit to maintain comparable behaviour which I believe should resolve the coverage problem in any case.
There is some extra code at class creation (for slotted classes) to check for cached_properties:
Some basic numbers for reference (timing for 1000 attrs.frozen applications): upstream/main:
vs New changes
So it does start to become noticeable at high method counts (or presumably other non-attribute fields), but for that to be meaningful it means dynamically creating many classes with many methods, which doesn't seem like a typical use case. As far as object creation or usage, changes to the class only occur when there are |
c1b3a9e
to
b0d87e3
Compare
You can absolutely change coverage to be measured on 3.9 too btw – no need to do any crazy hacks around that (disclaimer: I haven't looked at what you've done – just stating).
Yeah this is absolutely OK, thank you for humoring me. |
src/attr/_make.py
Outdated
" _setter(item, result)", | ||
" return result", | ||
" if '__attrs_original_getattr__' in vars(__class__):", | ||
" return __class__.__attrs_original_getattr__(self, item)", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you have to do this check here? Can this be checked at class construction time and passed as a parameter to _make_cached_property_getattr
? Might make the __class__
global unnecessary too.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've moved the check out to construction, and passed in the original_getattr
to avoid the class lookup.
__class__
is still needed in the closure for the super()
call to work.
I've removed __attrs_original_getattr__
on the class, as a separate MR, because it needed some other changes for it goes through the __class__
closure reference updating, which has it's own trade-off.
src/attr/_make.py
Outdated
" return result", | ||
" if '__attrs_original_getattr__' in vars(__class__):", | ||
" return __class__.__attrs_original_getattr__(self, item)", | ||
" if hasattr(super(), '__getattr__'):", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think we can do this at construction, because a __getattr__
might exist only on a different super-class of a sub-class. We could optimise for the case that it exist with a try/except
on the attribute access instead of the if
, since I guess the AttributeError
caused by a non-existant __getattr__
isn't the most critical thing to be fast.
For reference in case it influences the decision:
echo 'with f, hasattr'
python -m timeit --setup='\
class A():
def f(self): ...
class B(A): ...
b = B()' '\
if hasattr(super(B, b), "f"):
f = super(B, b).f
'
echo 'with f, try/except'
python -m timeit --setup='\
class A():
def f(self): ...
class B(A): ...
b = B()' '\
try:
f = super(B, b).f
except AttributeError:
f = None'
echo 'without f, hasattr'
python -m timeit --setup='\
class A(): ...
class B(A): ...
b = B()' '\
if hasattr(super(B, b), "f"):
f = super(B, b).f
f = None'
echo 'without f, try/except'
python -m timeit --setup='\
class A(): ...
class B(A): ...
b = B()' '\
try:
f = super(B, b).f
except AttributeError:
f = None'
with f, hasattr
1000000 loops, best of 5: 258 nsec per loop
with f, try/except
2000000 loops, best of 5: 122 nsec per loop
without f, hasattr
500000 loops, best of 5: 592 nsec per loop
without f, try/except
500000 loops, best of 5: 914 nsec per loop
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Trying to understand the case we're guarding against here.
I don't think we can do this at construction, because a
__getattr__
might exist only on a different super-class of a sub-class.
The situation you're describing, as I understand it, is this class sandwich:
@define
class A:
@cached_property
def a(self) -> int:
return 1
class B(A):
def __getattr__(self, item: str):
if item == "b":
return 1
return super().__getattr__(item)
@define
class C(B):
@cached_property
def b(self) -> int:
return 2
Let me know if any of my reasoning is faulty.
The __getattr__
on C
needs to call super()
, but we can tell at class construction time.
The __getattr__
on A
doesn't need to call super()
, and super()
wouldn't go to B
anyway, since B
isn't a superclass of A.
So we should be able to do this check at class construction time?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry, should have been clearer, I was thinking of something along the lines of:
@attrs.frozen
class A:
a: int
@cached_property
def b(self) -> int:
return self.a + 1
class Mixin:
__slots__ = ()
def __getattr__(self, item: str) -> int:
if item == 'c':
return 1
return super().__getattr__(item)
@attrs.frozen
class C(A, Mixin):
...
C(1).c
the __getattr__
on A
needs to call super
so that it passes to the __getattr__
on Mixin
, but can't know about it's existence at construction time.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah I see. I suspected multiple inheritance might be the thing complicating our life here ;)
Fair enough. I don't think it's a hack, just a slightly different approach, but the code change is in it's own commit, so if the prior option is preferred, it should be easy to revert (in which case I'll look into the github ci settings). |
9f6fd85
to
bac209e
Compare
Summary
Proof of concept for detecting and adapting cached_property decorated functions for slotted classes. This addresses:
Caching dynamic properties when using slots=True. #164
Pull Request Check List
main
branch – use a separate branch!Our CI fails if coverage is not 100%.
.pyi
).tests/typing_example.py
.attr/__init__.pyi
, they've also been re-imported inattrs/__init__.pyi
.docs/api.rst
by hand.@attr.s()
have to be added by hand too.versionadded
,versionchanged
, ordeprecated
directives.The next version is the second number in the current release + 1.
The first number represents the current year.
So if the current version on PyPI is 22.2.0, the next version is gonna be 22.3.0.
If the next version is the first in the new year, it'll be 23.1.0.
.rst
and.md
files is written using semantic newlines.changelog.d
.