anki/pylib/tools/hookslib.py
Damien Elmes b9251290ca run pyupgrade over codebase [python upgrade required]
This adds Python 3.9 and 3.10 typing syntax to files that import
attributions from __future___. Python 3.9 should be able to cope with
the 3.10 syntax, but Python 3.8 will no longer work.

On Windows/Mac, install the latest Python 3.9 version from python.org.
There are currently no orjson wheels for Python 3.10 on Windows/Mac,
which will break the build unless you have Rust installed separately.

On Linux, modern distros should have Python 3.9 available already. If
you're on an older distro, you'll need to build Python from source first.
2021-10-04 15:05:48 +10:00

170 lines
4.8 KiB
Python

# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
"""
Code for generating hooks.
"""
import os
import subprocess
import sys
from dataclasses import dataclass
from operator import attrgetter
from typing import List, Optional
import stringcase
@dataclass
class Hook:
# the name of the hook. _filter or _hook is appending automatically.
name: str
# string of the typed arguments passed to the callback, eg
# ["kind: str", "val: int"]
args: list[str] = None
# string of the return type. if set, hook is a filter.
return_type: Optional[str] = None
# if add-ons may be relying on the legacy hook name, add it here
legacy_hook: Optional[str] = None
# if legacy hook takes no arguments but the new hook does, set this
legacy_no_args: bool = False
# docstring to add to hook class
doc: Optional[str] = None
def callable(self) -> str:
"Convert args into a Callable."
types = []
for arg in self.args or []:
(name, type) = arg.split(":")
type = f'"{type.strip()}"'
types.append(type)
types_str = ", ".join(types)
return f"Callable[[{types_str}], {self.return_type or 'None'}]"
def arg_names(self) -> list[str]:
names = []
for arg in self.args or []:
if not arg:
continue
(name, type) = arg.split(":")
names.append(name.strip())
return names
def full_name(self) -> str:
return f"{self.name}_{self.kind()}"
def kind(self) -> str:
if self.return_type is not None:
return "filter"
else:
return "hook"
def classname(self) -> str:
return f"_{stringcase.pascalcase(self.full_name())}"
def list_code(self) -> str:
return f"""\
_hooks: list[{self.callable()}] = []
"""
def code(self) -> str:
appenddoc = f"({', '.join(self.args or [])})"
if self.doc:
classdoc = f" '''{self.doc}'''\n"
else:
classdoc = ""
code = f"""\
class {self.classname()}:
{classdoc}{self.list_code()}
def append(self, callback: {self.callable()}) -> None:
'''{appenddoc}'''
self._hooks.append(callback)
def remove(self, callback: {self.callable()}) -> None:
if callback in self._hooks:
self._hooks.remove(callback)
def count(self) -> int:
return len(self._hooks)
{self.fire_code()}
{self.name} = {self.classname()}()
"""
return code
def fire_code(self) -> str:
if self.return_type is not None:
# filter
return self.filter_fire_code()
else:
# hook
return self.hook_fire_code()
def legacy_args(self) -> str:
if self.legacy_no_args:
# hook name only
return f'"{self.legacy_hook}"'
else:
return ", ".join([f'"{self.legacy_hook}"'] + self.arg_names())
def hook_fire_code(self) -> str:
arg_names = self.arg_names()
args_including_self = ["self"] + (self.args or [])
out = f"""\
def __call__({", ".join(args_including_self)}) -> None:
for hook in self._hooks:
try:
hook({", ".join(arg_names)})
except:
# if the hook fails, remove it
self._hooks.remove(hook)
raise
"""
if self.legacy_hook:
out += f"""\
# legacy support
anki.hooks.runHook({self.legacy_args()})
"""
return f"{out}\n\n"
def filter_fire_code(self) -> str:
arg_names = self.arg_names()
args_including_self = ["self"] + (self.args or [])
out = f"""\
def __call__({", ".join(args_including_self)}) -> {self.return_type}:
for filter in self._hooks:
try:
{arg_names[0]} = filter({", ".join(arg_names)})
except:
# if the hook fails, remove it
self._hooks.remove(filter)
raise
"""
if self.legacy_hook:
out += f"""\
# legacy support
{arg_names[0]} = anki.hooks.runFilter({self.legacy_args()})
"""
out += f"""\
return {arg_names[0]}
"""
return f"{out}\n\n"
def write_file(path: str, hooks: list[Hook], prefix: str, suffix: str):
hooks.sort(key=attrgetter("name"))
code = f"{prefix}\n"
for hook in hooks:
code += hook.code()
code += f"\n{suffix}"
# work around issue with latest black
if sys.platform == "win32" and "HOME" in os.environ:
os.environ["USERPROFILE"] = os.environ["HOME"]
with open(path, "wb") as file:
file.write(code.encode("utf8"))
subprocess.run([sys.executable, "-m", "black", "-q", path], check=True)