-
-
Notifications
You must be signed in to change notification settings - Fork 2.9k
Add a class attribute hook to the plugin system #9881
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 a class attribute hook to the plugin system #9881
Conversation
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.
Hey there! Let me know if you think this is something that is even worth having. It does solve a need for me that I don't think is otherwise possible currently (see the issue linked in the description), but I don't know how much extra surface area/ongoing maintenance it'll cause for you. Also let me know if there's something else I should be updating (documentation, maybe?). As mentioned in the description, I looked to the PR that added get_function_signature_hook
to determine which files needed changing, and I didn't see anything around documentation changed there. Thanks in advance, and happy new year!
mypy/checkmember.py
Outdated
# Call the class attribute hook before returning. | ||
fullname = '{}.{}'.format(info.fullname, name) | ||
hook = mx.chk.plugin.get_class_attribute_hook(fullname) | ||
if hook: | ||
result = hook(AttributeContext(get_proper_type(mx.original_type), | ||
result, mx.context, mx.chk)) |
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 wasn't sure if the hook should be invoked for every exit point of this function. If so, I can pull the hook invocation into a wrapper function that calls into this one (or similar). I'm not familiar enough with this code to be confident where it would and would not make sense to use the hook, so advice is appreciated!
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.
We should call the hook in all/most exit points, for consistency. It would be good to have tests for other exit points as well. I can help with them if you are unsure when they will be triggered, or you can try adding assert False
to them and running tests to find examples.
I'd prefer to avoid defining a nested function, since currently they are kind of unoptimized in mypyc.
Brainstorming: Maybe something along these lines would work?
# Towards the top of the function
fullname = ...
hook = ...
...
# At each exit point where we don't return None; apply_plugin_hook would be a new function
return apply_plugin_hook(mx, hook, result)
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.
Just so I understand, calling through to another function like this is fine, but doing the "opposite" is unoptimized? Though, I realize I wasn't super clear in my previous comment haha. To be a bit more explicit, what I was suggesting was to rename analyze_class_attribute_access
to something like analyze_class_attribute_access_inner
and then create a new
def analyze_class_attribute_access(...):
result = analyze_class_attribute_access_inner(...)
fullname = '{}.{}'.format(info.fullname, name)
hook = mx.chk.plugin.get_class_attribute_hook(fullname)
if hook:
result = hook(AttributeContext(get_proper_type(mx.original_type),
result, mx.context, mx.chk))
Would that pattern be unoptimized, or is it functions/closures declared within a function that's unoptimized?
In any case, when looking through this a bit more it does seem there's maybe a few exit points we don't want to call the hook for (which I wanted to double check with you) in addition to the None
return, namely these two cases, which might make the whole _inner
idea above moot anyway:
Lines 782 to 789 in 6e8c0cd
elif isinstance(node.node, Var): | |
mx.not_ready_callback(name, mx.context) | |
return AnyType(TypeOfAny.special_form) | |
if isinstance(node.node, TypeVarExpr): | |
mx.msg.fail(message_registry.CANNOT_USE_TYPEVAR_AS_EXPRESSION.format( | |
info.name, name), mx.context) | |
return AnyType(TypeOfAny.from_error) |
Do we want to call the hook for these two exit points?
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.
Thanks for the PR! A plugin hook for class attributes seems like a reasonable thing to have.
mypy/checkmember.py
Outdated
# Call the class attribute hook before returning. | ||
fullname = '{}.{}'.format(info.fullname, name) | ||
hook = mx.chk.plugin.get_class_attribute_hook(fullname) | ||
if hook: | ||
result = hook(AttributeContext(get_proper_type(mx.original_type), | ||
result, mx.context, mx.chk)) |
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.
We should call the hook in all/most exit points, for consistency. It would be good to have tests for other exit points as well. I can help with them if you are unsure when they will be triggered, or you can try adding assert False
to them and running tests to find examples.
I'd prefer to avoid defining a nested function, since currently they are kind of unoptimized in mypyc.
Brainstorming: Maybe something along these lines would work?
# Towards the top of the function
fullname = ...
hook = ...
...
# At each exit point where we don't return None; apply_plugin_hook would be a new function
return apply_plugin_hook(mx, hook, result)
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.
Thank you for the review! 😀 I'll get the extra exit points and extra tests up soon 🙂
mypy/checkmember.py
Outdated
# Call the class attribute hook before returning. | ||
fullname = '{}.{}'.format(info.fullname, name) | ||
hook = mx.chk.plugin.get_class_attribute_hook(fullname) | ||
if hook: | ||
result = hook(AttributeContext(get_proper_type(mx.original_type), | ||
result, mx.context, mx.chk)) |
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.
Just so I understand, calling through to another function like this is fine, but doing the "opposite" is unoptimized? Though, I realize I wasn't super clear in my previous comment haha. To be a bit more explicit, what I was suggesting was to rename analyze_class_attribute_access
to something like analyze_class_attribute_access_inner
and then create a new
def analyze_class_attribute_access(...):
result = analyze_class_attribute_access_inner(...)
fullname = '{}.{}'.format(info.fullname, name)
hook = mx.chk.plugin.get_class_attribute_hook(fullname)
if hook:
result = hook(AttributeContext(get_proper_type(mx.original_type),
result, mx.context, mx.chk))
Would that pattern be unoptimized, or is it functions/closures declared within a function that's unoptimized?
In any case, when looking through this a bit more it does seem there's maybe a few exit points we don't want to call the hook for (which I wanted to double check with you) in addition to the None
return, namely these two cases, which might make the whole _inner
idea above moot anyway:
Lines 782 to 789 in 6e8c0cd
elif isinstance(node.node, Var): | |
mx.not_ready_callback(name, mx.context) | |
return AnyType(TypeOfAny.special_form) | |
if isinstance(node.node, TypeVarExpr): | |
mx.msg.fail(message_registry.CANNOT_USE_TYPEVAR_AS_EXPRESSION.format( | |
info.name, name), mx.context) | |
return AnyType(TypeOfAny.from_error) |
Do we want to call the hook for these two exit points?
@JukkaL I've got a few questions that I'd love some help/feedback on when you have a bit to look things over 🙂 |
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.
Thanks for the updates! Overall looks good. Answered various questions.
@JukkaL Thanks so much for the response! I think this should be ready to go now 🙂 |
@JukkaL is there anything else you think needs to be changed here before merging? 🙂 |
Heya @JukkaL just checking in to see if we can get this merged if there's nothing else needed here 🙂 |
@JukkaL friendly ping, would love to get this merged in 🙂 |
4b0f40f
to
26f6cfe
Compare
26f6cfe
to
98edc41
Compare
Any update on this? |
b8608f7
to
2b70840
Compare
Hi @JukkaL, I think this PR is ready. Can you give it a final review? |
This adds a hook to modify attributes on *classes* (as opposed to the existing attribute hook, which is for attributes on *instances*). This also adds a test to demonstrate the new behavior. The modifications and added tests were modeled off of python@3acbf3f. This is intended to solve python#9645
…in, marked with 'TODO RIGHT NOW NOMERGE'
2b70840
to
a976739
Compare
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.
Thank you! This mostly looks good to me, but I think that we should add a bit more tests. And don't forget about docs: https://mypy.readthedocs.io/en/stable/extending_mypy.html
mypy/plugin.py
Outdated
|
||
Cls.x | ||
|
||
get_class_attribute_hook is called with '__main__.Cls.x'. |
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.
get_class_attribute_hook is called with '__main__.Cls.x'. | |
get_class_attribute_hook is called with '__main__.Cls.x' as fullname. |
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.
Changed 👍
attr = 'test' | ||
|
||
B: Any | ||
class Cls(B, metaclass=M): |
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 suggest to use one more test example with real B
, because subtyping Any
is not the same as subtyping a regular class.
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.
Added!
class Cls: | ||
attr = None | ||
def f(self) -> int: | ||
return Cls.attr + 1 |
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.
What about self.__class__.attr
? Should we support that?
I think we can also check that c: Type[Cls]; c.attr
works correctly.
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.
Hm, so it looks like adding a test for __class__
is actually somewhat difficult, but also unclear that it actually adds much new value here. In particular it looks like the __class__
being specified as generic is done in typeshed here: https://github.com/python/typeshed/blob/master/stdlib/builtins.pyi#L87-L88
The way this is done in other tests is via [builtins fixtures/__new__.pyi]
but that just defines __class__ = object
and isn't generic. But even if we were able to use the real stubs, then really all that __class__
would be testing is that the generics system+stubs work correctly, and less about the class attribute plugin itself. But if you think testing the generic __class__
is important I can add the stub manually within this test!
The other one is already tested above:
mypy/test-data/unit/check-custom-plugin.test
Lines 918 to 919 in af49c12
x: Type[Cls] | |
reveal_type(x.attr) # N: Revealed type is "builtins.int" |
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.
attr = 'test' | ||
|
||
B: Any | ||
class Cls(B, metaclass=M): |
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.
Added!
class Cls: | ||
attr = None | ||
def f(self) -> int: | ||
return Cls.attr + 1 |
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.
Hm, so it looks like adding a test for __class__
is actually somewhat difficult, but also unclear that it actually adds much new value here. In particular it looks like the __class__
being specified as generic is done in typeshed here: https://github.com/python/typeshed/blob/master/stdlib/builtins.pyi#L87-L88
The way this is done in other tests is via [builtins fixtures/__new__.pyi]
but that just defines __class__ = object
and isn't generic. But even if we were able to use the real stubs, then really all that __class__
would be testing is that the generics system+stubs work correctly, and less about the class attribute plugin itself. But if you think testing the generic __class__
is important I can add the stub manually within this test!
The other one is already tested above:
mypy/test-data/unit/check-custom-plugin.test
Lines 918 to 919 in af49c12
x: Type[Cls] | |
reveal_type(x.attr) # N: Revealed type is "builtins.int" |
mypy/plugin.py
Outdated
|
||
Cls.x | ||
|
||
get_class_attribute_hook is called with '__main__.Cls.x'. |
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.
Changed 👍
According to mypy_primer, this change has no effect on the checked open source code. 🤖🎉 |
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.
Thanks for your patience, let's make this happen.
It wasn't clear to me why e.g. we don't apply the hook to things like type aliases, but then I saw Jukka's comments. I believe he's referring to stuff like:
Line 4106 in fc335cb
if n is not None and isinstance(n.node, (MypyFile, TypeInfo, TypeAlias)): |
Excellent, thank you so much for the merge! 😀 |
Description
This adds a
get_class_attribute_hook
to plugins to modify attributes on classes (as opposed to the existingget_attribute_hook
, which is for attributes on instances). The modifications and added tests were modeled off of 3acbf3f.Fixes #9645.
Test Plan
A new test is added which exercises the new functionality.