Skip to content
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

Merged
merged 8 commits into from
May 7, 2022

Conversation

FuegoFro
Copy link
Contributor

@FuegoFro FuegoFro commented Jan 5, 2021

Description

This adds a get_class_attribute_hook to plugins to modify attributes on classes (as opposed to the existing get_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.

Copy link
Contributor Author

@FuegoFro FuegoFro left a 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!

Comment on lines 782 to 787
# 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))
Copy link
Contributor Author

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!

Copy link
Collaborator

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)

Copy link
Contributor Author

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:

mypy/mypy/checkmember.py

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?

Copy link
Collaborator

@JukkaL JukkaL left a 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.

Comment on lines 782 to 787
# 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))
Copy link
Collaborator

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)

test-data/unit/check-custom-plugin.test Outdated Show resolved Hide resolved
Copy link
Contributor Author

@FuegoFro FuegoFro left a 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 🙂

Comment on lines 782 to 787
# 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))
Copy link
Contributor Author

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:

mypy/mypy/checkmember.py

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?

test-data/unit/check-custom-plugin.test Outdated Show resolved Hide resolved
mypy/checkmember.py Outdated Show resolved Hide resolved
@FuegoFro FuegoFro requested a review from JukkaL January 11, 2021 19:43
@FuegoFro
Copy link
Contributor Author

@JukkaL I've got a few questions that I'd love some help/feedback on when you have a bit to look things over 🙂

Copy link
Collaborator

@JukkaL JukkaL left a 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.

mypy/checkmember.py Outdated Show resolved Hide resolved
mypy/checkmember.py Outdated Show resolved Hide resolved
mypy/checkmember.py Outdated Show resolved Hide resolved
mypy/checkmember.py Outdated Show resolved Hide resolved
mypy/checkmember.py Outdated Show resolved Hide resolved
mypy/checkmember.py Outdated Show resolved Hide resolved
mypy/checkmember.py Outdated Show resolved Hide resolved
test-data/unit/check-custom-plugin.test Outdated Show resolved Hide resolved
mypy/checkmember.py Show resolved Hide resolved
@FuegoFro FuegoFro requested a review from JukkaL January 15, 2021 18:33
@FuegoFro
Copy link
Contributor Author

@JukkaL Thanks so much for the response! I think this should be ready to go now 🙂

@FuegoFro
Copy link
Contributor Author

@JukkaL is there anything else you think needs to be changed here before merging? 🙂

@FuegoFro
Copy link
Contributor Author

FuegoFro commented Feb 3, 2021

Heya @JukkaL just checking in to see if we can get this merged if there's nothing else needed here 🙂

@FuegoFro
Copy link
Contributor Author

@JukkaL friendly ping, would love to get this merged in 🙂

@FuegoFro FuegoFro force-pushed the plugin_class_attribute_hook branch from 4b0f40f to 26f6cfe Compare March 9, 2021 18:53
@FuegoFro FuegoFro force-pushed the plugin_class_attribute_hook branch from 26f6cfe to 98edc41 Compare April 5, 2021 23:33
@charmoniumQ
Copy link

Any update on this?

@FuegoFro FuegoFro force-pushed the plugin_class_attribute_hook branch from b8608f7 to 2b70840 Compare May 19, 2021 16:53
@mezz
Copy link

mezz commented Jul 30, 2021

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
Copy link
Member

@sobolevn sobolevn left a 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'.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
get_class_attribute_hook is called with '__main__.Cls.x'.
get_class_attribute_hook is called with '__main__.Cls.x' as fullname.

Copy link
Contributor Author

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):
Copy link
Member

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.

Copy link
Contributor Author

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
Copy link
Member

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.

Copy link
Contributor Author

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:/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:

x: Type[Cls]
reveal_type(x.attr) # N: Revealed type is "builtins.int"

Copy link
Contributor Author

@FuegoFro FuegoFro left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the review! I added docs, here's a screenshot for convenience:
image

Let me know if there's anything else I can do to help get this PR merged! After ~a year, it'd be great to stop maintaining our own fork of mypy 🙏

attr = 'test'

B: Any
class Cls(B, metaclass=M):
Copy link
Contributor Author

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
Copy link
Contributor Author

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:/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:

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'.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed 👍

@github-actions
Copy link
Contributor

According to mypy_primer, this change has no effect on the checked open source code. 🤖🎉

Copy link
Collaborator

@hauntsaninja hauntsaninja left a 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:

if n is not None and isinstance(n.node, (MypyFile, TypeInfo, TypeAlias)):

@hauntsaninja hauntsaninja merged commit 23e2a51 into python:master May 7, 2022
@FuegoFro
Copy link
Contributor Author

Excellent, thank you so much for the merge! 😀

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

Successfully merging this pull request may close these issues.

get_attribute_hook does not trigger for class attributes, get_base_class_hook cannot resolve class attr types
7 participants