From 5ed1b38b799471660ca8cdadccc1e84895771bf6 Mon Sep 17 00:00:00 2001 From: "snarkmaster@gmail.com" Date: Wed, 31 Jan 2018 16:10:07 -0800 Subject: [PATCH] Add a `customize_class_mro` plugin hook The rationale for this MRO hook is documented on https://github.com/python/mypy/issues/4527 This patch completely addresses my need for customizing the MRO of types that use my metaclass, and I believe it is simple & general enough for other plugin authors. I did `./runtests.py`, and the only failure is in `testCoberturaParser`, which looks completely unrelated to my changes. Log here: https://gist.github.com/snarkmaster/3f78cf04cfacb7abf2a8da9b95298075 PS You will notice in the patch that I deviated from the pattern of "some_hook(fullname) returns a callback, which takes a context". I did this as a suggestion for improvement. At the very least, all of the -> None hooks could be simplified in this fashion (and probably the others, too). I'm happy to put up a patch for that, if there's a process for dealing with a breaking change in the plugin API. A specific reason I disliked the original pattern is that the provided fullname often does not have enough information for the hook to decide whether it cares, so the hook has to return a callback always. And yet, we spend cycles specifically extracting & passing just the fullname. For this reason, my base class hook is just return base_class_callback. --- mypy/plugin.py | 8 ++++++++ mypy/semanal.py | 25 +++++++++++++++---------- mypy/semanal_pass3.py | 8 ++++---- 3 files changed, 27 insertions(+), 14 deletions(-) diff --git a/mypy/plugin.py b/mypy/plugin.py index 8bfd9f0383696..168a2e37fa138 100644 --- a/mypy/plugin.py +++ b/mypy/plugin.py @@ -210,6 +210,10 @@ def get_base_class_hook(self, fullname: str ) -> Optional[Callable[[ClassDefContext], None]]: return None + def get_customize_class_mro_hook(self, fullname: str + ) -> Optional[Callable[[ClassDefContext], None]]: + return None + T = TypeVar('T') @@ -266,6 +270,10 @@ def get_base_class_hook(self, fullname: str ) -> Optional[Callable[[ClassDefContext], None]]: return self._find_hook(lambda plugin: plugin.get_base_class_hook(fullname)) + def get_customize_class_mro_hook(self, fullname: str + ) -> Optional[Callable[[ClassDefContext], None]]: + return self._find_hook(lambda plugin: plugin.get_customize_class_mro_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 13ab3063ea384..daf0fa528105d 100644 --- a/mypy/semanal.py +++ b/mypy/semanal.py @@ -1132,12 +1132,26 @@ def analyze_base_classes(self, defn: ClassDef) -> None: # Give it an MRO consisting of just the class itself and object. defn.info.mro = [defn.info, self.object_type().type] return - calculate_class_mro(defn, self.fail_blocker) + self.calculate_class_mro(defn) # If there are cyclic imports, we may be missing 'object' in # the MRO. Fix MRO if needed. if info.mro and info.mro[-1].fullname() != 'builtins.object': info.mro.append(self.object_type().type) + def calculate_class_mro(self, defn: ClassDef) -> None: + try: + calculate_mro(defn.info) + except MroError: + self.fail_blocker('Cannot determine consistent method resolution ' + 'order (MRO) for "%s"' % defn.name, defn) + defn.info.mro = [] + # Allow plugins to alter the MRO to handle the fact that `def mro()` + # on metaclasses permits MRO rewriting. + if defn.fullname: + hook = self.plugin.get_customize_class_mro_hook(defn.fullname) + if hook: + hook(ClassDefContext(defn, Expression(), self)) + def update_metaclass(self, defn: ClassDef) -> None: """Lookup for special metaclass declarations, and update defn fields accordingly. @@ -3416,15 +3430,6 @@ def refers_to_class_or_function(node: Expression) -> bool: isinstance(node.node, (TypeInfo, FuncDef, OverloadedFuncDef))) -def calculate_class_mro(defn: ClassDef, fail: Callable[[str, Context], None]) -> None: - try: - calculate_mro(defn.info) - except MroError: - fail("Cannot determine consistent method resolution order " - '(MRO) for "%s"' % defn.name, defn) - defn.info.mro = [] - - def calculate_mro(info: TypeInfo) -> None: """Calculate and set mro (method resolution order). diff --git a/mypy/semanal_pass3.py b/mypy/semanal_pass3.py index 6a554557f2b18..e9bee4c7a7ee3 100644 --- a/mypy/semanal_pass3.py +++ b/mypy/semanal_pass3.py @@ -30,11 +30,11 @@ from mypy.typeanal import TypeAnalyserPass3, collect_any_types from mypy.typevars import has_no_typevars from mypy.semanal_shared import PRIORITY_FORWARD_REF, PRIORITY_TYPEVAR_VALUES +from mypy.semanal import SemanticAnalyzerPass2 from mypy.subtypes import is_subtype from mypy.sametypes import is_same_type from mypy.scope import Scope from mypy.semanal_shared import SemanticAnalyzerCoreInterface -import mypy.semanal class SemanticAnalyzerPass3(TraverserVisitor, SemanticAnalyzerCoreInterface): @@ -45,7 +45,7 @@ class SemanticAnalyzerPass3(TraverserVisitor, SemanticAnalyzerCoreInterface): """ def __init__(self, modules: Dict[str, MypyFile], errors: Errors, - sem: 'mypy.semanal.SemanticAnalyzerPass2') -> None: + sem: SemanticAnalyzerPass2) -> None: self.modules = modules self.errors = errors self.sem = sem @@ -138,7 +138,7 @@ def visit_class_def(self, tdef: ClassDef) -> None: # import loop. (Only do so if we succeeded the first time.) if tdef.info.mro: tdef.info.mro = [] # Force recomputation - mypy.semanal.calculate_class_mro(tdef, self.fail_blocker) + self.sem.calculate_class_mro(tdef) if tdef.analyzed is not None: # Also check synthetic types associated with this ClassDef. # Currently these are TypedDict, and NamedTuple. @@ -230,7 +230,7 @@ def visit_assignment_stmt(self, s: AssignmentStmt) -> None: self.analyze_info(analyzed.info) if analyzed.info and analyzed.info.mro: analyzed.info.mro = [] # Force recomputation - mypy.semanal.calculate_class_mro(analyzed.info.defn, self.fail_blocker) + self.sem.calculate_class_mro(analyzed.info.defn) if isinstance(analyzed, TypeVarExpr): types = [] if analyzed.upper_bound: