The hook helpers

The helpers under extended_mypy_django_plugin.plugin.hooks exist as a helper for defining hooks on a mypy plugin.

The way a mypy plugin works is there is a class that inherits from mypy.plugin.Plugin with specific hooks that take in a string and returns a function.

So for example, the get_customize_class_mro_hook hook will take in the fullname representing classes that can be altered, and a function must be returned if the plugin wants to do something with that object (such that the function takes in a single ClassDefContext object and returns None).

The first plugin that mypy encounters which returns a function will win and no other plugins will get to look at that fullname.

This is fine but it can get awkward when the function returned takes in more options than only the context. To improve this situation, this module implements a Hook class and associated decorator to turn those hooks into python descriptors that do the correct thing.

from typing import Generic
from mypy.plugin import Plugin, AttributeContext
from mypy.types import Type as MypyType
from extended_mypy_django_plugin.plugin import hook


class MyPlugin(Plugin):
    @hook.hook
    class get_attribute_hook(hook.Hook["MyPlugin", AttributeContext, MypyType]):
        def choose(
            self, *, fullname: str, super_hook: hook.MypyHook[AttributeContext, MypyType]
        ) -> bool:
            # return True if we want to use the run method for ``self.fullname``.
            # With super_hook being the result of `super().get_attribute_hook(fullname)` on the plugin
            return self.fullname.endswith(".blah")

        def run(self, ctx: AttributeContext) -> MypyType:
            # Do stuff
            return ...

The extended_mypy_django_plugin.plugin.hook.hook decorator turns that attribute into a descriptor that will cache an instance of the Hook with the instance of the plugin and that hook on the parent class (useful when the mypy plugin is subclassing an existing mypy plugin)

The descriptor will then return the hook method on the Hook, which matches the required signature for the hook.

There are two default implementations of this Hook class:

class extended_mypy_django_plugin.plugin.hook.PlainHook(*, plugin: T_Plugin, super_hook_maker: Callable[[str], Callable[[T_Ctx], T_Ret] | None])

This is an implementation of Hook that does the bare minimum.

It’s hook method will determine what would have been returned for this hook from the parent class and pass the fullname and that super_hook to choose.

If choose returns False, then hook returns the super_hook, otherwise it returns self.run.

class extended_mypy_django_plugin.plugin.hook.HookWithExtra(*, plugin: T_Plugin, super_hook_maker: Callable[[str], Callable[[T_Ctx], T_Ret] | None])

This is an implementation of Hook that allows passing information from the choose method into the run method.

Mypy plugins work by implementing methods that take in a string and either return a callable that takes action, or returns None. Whichever plugin returns a function first wins.

This hook class splits the two parts into two methods: deciding if we should choose this hook and the running of the logic.

This implementation in particular also allows passing information from the choosing part into the running part:

from typing import Generic
from mypy.plugin import Plugin, AttributeContext
from mypy.types import Type as MypyType
from extended_mypy_django_plugin.plugin import hook


class MyPlugin(Plugin):
    @hook.hook
    class get_attribute_hook(hook.HookWithExtra["MyPlugin", AttributeContext, str, MypyType]):
        def choose(
            self, *, fullname: str, super_hook: hook.MypyHook[AttributeContext, MypyType]
        ) -> hook.Choice[str]:
            if fullname.startswith("things"):
                return True, "one"
            elif fullname.startswith("other"):
                return True, "two"
            else:
                return False

        def run(
            self,
            ctx: AttributeContext,
            *,
            fullname: str,
            super_hook: hook.MypyHook[AttributeContext, MypyType],
            extra: str,
        ) -> MypyType:
            # at this point, extra will either be "one" or "two"

            # Do stuff
            return ...

The type of the data passed between choose and run is specified when filling out the Generic.

When the plugin runs the hook method on this implementation, it will determine what the parent class would return from this hook, get a result from choose from the fullname and that super_hook and either return super_hook if the choose returned False or return functools.partial(self.run, fullname=fullname, super_hook=super_hook, extra=extra) where extra is the second item in a (True, extra) tuple returned from choose.

And they must be used with the hook decorator:

class extended_mypy_django_plugin.plugin.hook.hook(hook_kls: type[Hook[T_Plugin, T_Ctx, T_Ret]])

This is a class decorator used to return a callable object that takes in a string and either returns a function that mypy can use to perform an action, or return None if this hook does not need to do anything in that instance.

It is used as a decorator for a subclass of Hook and will cache an instance of that hook between multiple access of the descriptor