From d611bc660e54be00df57acdb53011c36dd46a7d5 Mon Sep 17 00:00:00 2001 From: Adrian Freund Date: Mon, 18 Jan 2021 15:59:14 +0100 Subject: [PATCH 1/3] Add get_function_decorator_plugin_hook to plugin interface --- mypy/plugin.py | 28 +++++++++++++- mypy/semanal.py | 29 ++++++++++++++- test-data/unit/check-custom-plugin.test | 37 +++++++++++++++++++ .../unit/plugins/function_decorator_hook.py | 19 ++++++++++ test_pyqt/mypy.ini | 2 + test_pyqt/pyqt5.py | 24 ++++++++++++ test_pyqt/pyqttest.py | 22 +++++++++++ 7 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 test-data/unit/plugins/function_decorator_hook.py create mode 100644 test_pyqt/mypy.ini create mode 100644 test_pyqt/pyqt5.py create mode 100644 test_pyqt/pyqttest.py diff --git a/mypy/plugin.py b/mypy/plugin.py index 52c44d457c1b..37a8882fb8fa 100644 --- a/mypy/plugin.py +++ b/mypy/plugin.py @@ -124,7 +124,7 @@ class C: pass from mypy_extensions import trait, mypyc_attr from mypy.nodes import ( - Expression, Context, ClassDef, SymbolTableNode, MypyFile, CallExpr + Expression, Context, ClassDef, SymbolTableNode, MypyFile, CallExpr, Decorator ) from mypy.tvar_scope import TypeVarLikeScope from mypy.types import Type, Instance, CallableType, TypeList, UnboundType, ProperType @@ -454,6 +454,16 @@ def final_iteration(self) -> bool: ]) +# A context for a decorator hook, that modifies the function definition +FunctionDecoratorContext = NamedTuple( + 'FunctionDecoratorContext', [ + ('decorator', Expression), + ('decoratedFunction', Decorator), + ('api', SemanticAnalyzerPluginInterface) + ] +) + + @mypyc_attr(allow_interpreted_subclasses=True) class Plugin(CommonPluginApi): """Base class of all type checker plugins. @@ -705,6 +715,18 @@ def get_dynamic_class_hook(self, fullname: str """ return None + def get_function_decorator_hook(self, fullname: str + ) -> Optional[Callable[[FunctionDecoratorContext], bool]]: + """Update function definition for given function decorators + + The plugin can modify a function _in place_. + + The hook is called with full names of all function decorators. + + Return true if the decorator has been handled and should be removed + """ + return None + T = TypeVar('T') @@ -787,6 +809,10 @@ def get_dynamic_class_hook(self, fullname: str ) -> Optional[Callable[[DynamicClassDefContext], None]]: return self._find_hook(lambda plugin: plugin.get_dynamic_class_hook(fullname)) + def get_function_decorator_hook(self, fullname: str + ) -> Optional[Callable[[FunctionDecoratorContext], bool]]: + return self._find_hook(lambda plugin: plugin.get_function_decorator_hook(fullname)) + def _find_hook(self, lookup: Callable[[Plugin], T]) -> Optional[T]: for plugin in self._plugins: hook = lookup(plugin) diff --git a/mypy/semanal.py b/mypy/semanal.py index 42353d10a5e6..9b03819788ab 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -104,7 +104,7 @@ from mypy.options import Options from mypy.plugin import ( Plugin, ClassDefContext, SemanticAnalyzerPluginInterface, - DynamicClassDefContext + DynamicClassDefContext, FunctionDecoratorContext ) from mypy.util import correct_relative_import, unmangle, module_prefix, is_typeshed_file from mypy.scope import Scope @@ -1023,6 +1023,8 @@ def visit_decorator(self, dec: Decorator) -> None: removed.append(i) else: self.fail("@final cannot be used with non-method functions", d) + if self.apply_decorator_plugin_hooks(d, dec): + removed.append(i) for i in reversed(removed): del dec.decorators[i] if (not dec.is_overload or dec.var.is_property) and self.type: @@ -1038,6 +1040,31 @@ def check_decorated_function_is_method(self, decorator: str, if not self.type or self.is_func_scope(): self.fail("'%s' used with a non-method" % decorator, context) + def apply_decorator_plugin_hooks(self, node: Expression, dec: Decorator) -> bool: + # TODO: Remove duplicate code + def get_fullname(expr: Expression) -> Optional[str]: + if isinstance(expr, CallExpr): + return get_fullname(expr.callee) + elif isinstance(expr, IndexExpr): + return get_fullname(expr.base) + elif isinstance(expr, RefExpr): + if expr.fullname: + return expr.fullname + # If we don't have a fullname look it up. This happens because base classes are + # analyzed in a different manner (see exprtotype.py) and therefore those AST + # nodes will not have full names. + sym = self.lookup_type_node(expr) + if sym: + return sym.fullname + return None + + decorator_name = get_fullname(node) + if decorator_name: + hook = self.plugin.get_function_decorator_hook(decorator_name) + if hook: + return hook(FunctionDecoratorContext(node, dec, self)) + return False + # # Classes # diff --git a/test-data/unit/check-custom-plugin.test b/test-data/unit/check-custom-plugin.test index 9ab79bafd244..4826c29e5187 100644 --- a/test-data/unit/check-custom-plugin.test +++ b/test-data/unit/check-custom-plugin.test @@ -730,3 +730,40 @@ reveal_type(dynamic_signature(1)) # N: Revealed type is 'builtins.int' [file mypy.ini] \[mypy] plugins=/test-data/unit/plugins/function_sig_hook.py + +[case testFunctionDecoratorPluginHookForFunction] +# flags: --config-file tmp/mypy.ini + +from m import decorator + +@decorator +def function(self) -> str: ... + +@function.setter +def function(self, value: str) -> None: ... + +[file m.py] +from typing import Callable +def decorator(param) -> Callable[..., str]: pass +[file mypy.ini] +\[mypy] +plugins=/test-data/unit/plugins/function_decorator_hook.py + +[case testFunctionDecoratorPluginHookForMethod] +# flags: --config-file tmp/mypy.ini + +from m import decorator + +class A: + @decorator + def property(self) -> str: ... + + @property.setter + def property(self, value: str) -> None: ... + +[file m.py] +from typing import Callable +def decorator(param) -> Callable[..., str]: pass +[file mypy.ini] +\[mypy] +plugins=/test-data/unit/plugins/function_decorator_hook.py \ No newline at end of file diff --git a/test-data/unit/plugins/function_decorator_hook.py b/test-data/unit/plugins/function_decorator_hook.py new file mode 100644 index 000000000000..62c486c21a7a --- /dev/null +++ b/test-data/unit/plugins/function_decorator_hook.py @@ -0,0 +1,19 @@ +from mypy.plugin import Plugin, FunctionDecoratorContext + + +class FunctionDecoratorPlugin(Plugin): + def get_function_decorator_hook(self, fullname): + if fullname == 'm.decorator': + return my_hook + return None + + +def my_hook(ctx: FunctionDecoratorContext) -> bool: + ctx.decoratedFunction.func.is_property = True + ctx.decoratedFunction.var.is_property = True + + return True + + +def plugin(version): + return FunctionDecoratorPlugin diff --git a/test_pyqt/mypy.ini b/test_pyqt/mypy.ini new file mode 100644 index 000000000000..c4117dbb0cea --- /dev/null +++ b/test_pyqt/mypy.ini @@ -0,0 +1,2 @@ +[mypy] +plugins = pyqt5.py diff --git a/test_pyqt/pyqt5.py b/test_pyqt/pyqt5.py new file mode 100644 index 000000000000..9bd07aa91bca --- /dev/null +++ b/test_pyqt/pyqt5.py @@ -0,0 +1,24 @@ +from typing import Callable, Optional +from typing import Type + +from mypy.plugin import ( + Plugin, FunctionDecoratorContext, +) + + +def plugin(version: str) -> Type[Plugin]: + return PyQt5Plugin + + +class PyQt5Plugin(Plugin): + """PyQt5 Plugin""" + + def get_function_decorator_hook(self, fullname: str + ) -> Optional[Callable[[FunctionDecoratorContext], None]]: + if fullname == 'PyQt5.QtCore.pyqtProperty': + return pyqt_property_callback + return None + + +def pyqt_property_callback(ctx: FunctionDecoratorContext): + ctx.decoratedFunction.func.is_property = True diff --git a/test_pyqt/pyqttest.py b/test_pyqt/pyqttest.py new file mode 100644 index 000000000000..03b9e15255f0 --- /dev/null +++ b/test_pyqt/pyqttest.py @@ -0,0 +1,22 @@ +import typing + +from PyQt5.QtCore import QObject, pyqtProperty, pyqtSignal + + +@typing.final +class FeatureModel(QObject): + + stateChanged = pyqtSignal() + + def __init__(self, enabled: bool, /) -> None: + super().__init__() + self._enabled = enabled + + @pyqtProperty(bool, notify=stateChanged) + def enabled(self): + return self._enabled + + @enabled.setter + def enabled(self, enabled): + self._enabled = enabled + self.stateChanged.emit() From 7b510a451a69f886e0af3e853ac971eb694f05ad Mon Sep 17 00:00:00 2001 From: Adrian Freund Date: Mon, 18 Jan 2021 16:34:49 +0100 Subject: [PATCH 2/3] Removed accidentally added files --- test_pyqt/mypy.ini | 2 -- test_pyqt/pyqt5.py | 24 ------------------------ test_pyqt/pyqttest.py | 22 ---------------------- 3 files changed, 48 deletions(-) delete mode 100644 test_pyqt/mypy.ini delete mode 100644 test_pyqt/pyqt5.py delete mode 100644 test_pyqt/pyqttest.py diff --git a/test_pyqt/mypy.ini b/test_pyqt/mypy.ini deleted file mode 100644 index c4117dbb0cea..000000000000 --- a/test_pyqt/mypy.ini +++ /dev/null @@ -1,2 +0,0 @@ -[mypy] -plugins = pyqt5.py diff --git a/test_pyqt/pyqt5.py b/test_pyqt/pyqt5.py deleted file mode 100644 index 9bd07aa91bca..000000000000 --- a/test_pyqt/pyqt5.py +++ /dev/null @@ -1,24 +0,0 @@ -from typing import Callable, Optional -from typing import Type - -from mypy.plugin import ( - Plugin, FunctionDecoratorContext, -) - - -def plugin(version: str) -> Type[Plugin]: - return PyQt5Plugin - - -class PyQt5Plugin(Plugin): - """PyQt5 Plugin""" - - def get_function_decorator_hook(self, fullname: str - ) -> Optional[Callable[[FunctionDecoratorContext], None]]: - if fullname == 'PyQt5.QtCore.pyqtProperty': - return pyqt_property_callback - return None - - -def pyqt_property_callback(ctx: FunctionDecoratorContext): - ctx.decoratedFunction.func.is_property = True diff --git a/test_pyqt/pyqttest.py b/test_pyqt/pyqttest.py deleted file mode 100644 index 03b9e15255f0..000000000000 --- a/test_pyqt/pyqttest.py +++ /dev/null @@ -1,22 +0,0 @@ -import typing - -from PyQt5.QtCore import QObject, pyqtProperty, pyqtSignal - - -@typing.final -class FeatureModel(QObject): - - stateChanged = pyqtSignal() - - def __init__(self, enabled: bool, /) -> None: - super().__init__() - self._enabled = enabled - - @pyqtProperty(bool, notify=stateChanged) - def enabled(self): - return self._enabled - - @enabled.setter - def enabled(self, enabled): - self._enabled = enabled - self.stateChanged.emit() From 38f7d419d747e5e8fbd5306703b205b2477afc31 Mon Sep 17 00:00:00 2001 From: Adrian Freund Date: Wed, 11 May 2022 14:57:16 +0200 Subject: [PATCH 3/3] Fix attribute name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Sigurd Ljødal <544451+ljodal@users.noreply.github.com> --- mypy/plugin.py | 2 +- test-data/unit/plugins/function_decorator_hook.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mypy/plugin.py b/mypy/plugin.py index 4e4ba1360da6..bf8909778eed 100644 --- a/mypy/plugin.py +++ b/mypy/plugin.py @@ -480,7 +480,7 @@ class DynamicClassDefContext(NamedTuple): FunctionDecoratorContext = NamedTuple( 'FunctionDecoratorContext', [ ('decorator', Expression), - ('decoratedFunction', Decorator), + ('decorated_function', Decorator), ('api', SemanticAnalyzerPluginInterface) ] ) diff --git a/test-data/unit/plugins/function_decorator_hook.py b/test-data/unit/plugins/function_decorator_hook.py index 62c486c21a7a..0efb30a7e89f 100644 --- a/test-data/unit/plugins/function_decorator_hook.py +++ b/test-data/unit/plugins/function_decorator_hook.py @@ -9,8 +9,8 @@ def get_function_decorator_hook(self, fullname): def my_hook(ctx: FunctionDecoratorContext) -> bool: - ctx.decoratedFunction.func.is_property = True - ctx.decoratedFunction.var.is_property = True + ctx.decorated_function.func.is_property = True + ctx.decorated_function.var.is_property = True return True