Add hook to modify Jinja env

This commit is contained in:
object-Object 2023-10-25 02:38:30 -04:00
parent e930333fc2
commit b6b23f7040
9 changed files with 165 additions and 51 deletions

View file

@ -88,7 +88,7 @@ def render(
logger.info(f"update_latest={update_latest}, release={release}")
# set up Jinja
env = create_jinja_env(props, pm)
env = create_jinja_env(pm, props.template.include)
templates = {
Path(path): env.get_template(template_name)

View file

@ -28,8 +28,8 @@ from hexdoc.utils.path import write_to_path
from .sitemap import MARKER_NAME, SitemapMarker
def create_jinja_env(props: Properties, pm: PluginManager):
prefix_loaders = pm.load_jinja_templates(props.template.include)
def create_jinja_env(pm: PluginManager, include: list[str]):
prefix_loaders = pm.load_jinja_templates(include)
env = SandboxedEnvironment(
loader=ChoiceLoader(
@ -54,7 +54,7 @@ def create_jinja_env(props: Properties, pm: PluginManager):
"hexdoc_texture": hexdoc_texture,
}
return env
return pm.update_jinja_env(env)
def render_book(

View file

@ -4,6 +4,8 @@ __all__ = [
"LoadResourceDirsImpl",
"LoadTaggedUnionsImpl",
"LoadJinjaTemplatesImpl",
"UpdateJinjaEnvImpl",
"UpdateTemplateArgsImpl",
"PluginManager",
"HookReturn",
]
@ -18,6 +20,8 @@ from .specs import (
LoadResourceDirsImpl,
LoadTaggedUnionsImpl,
ModVersionImpl,
UpdateJinjaEnvImpl,
UpdateTemplateArgsImpl,
)
hookimpl = pluggy.HookimplMarker(HEXDOC_PROJECT_NAME)

View file

@ -1,11 +1,24 @@
from __future__ import annotations
import importlib
from dataclasses import dataclass
from importlib.resources import Package
from types import ModuleType
from typing import Any, Callable, Generic, Iterable, Iterator, ParamSpec, TypeVar
from typing import (
Any,
Callable,
Generic,
Iterable,
Iterator,
Never,
ParamSpec,
TypeVar,
overload,
)
import pluggy
from jinja2 import PackageLoader
from jinja2.sandbox import SandboxedEnvironment
from hexdoc.model import ValidationContext
@ -54,6 +67,14 @@ class TypedHookCaller(Generic[_P, _R]):
return result
class _NoCallTypedHookCaller(TypedHookCaller[_P, None]):
"""Represents a TypedHookCaller which returns None. This will always raise, so the
return type of __call__ is set to Never."""
def __call__(self, *args: _P.args, **kwargs: _P.kwargs) -> Never:
...
class PluginManager:
"""Custom hexdoc plugin manager with helpers and stronger typing."""
@ -64,24 +85,29 @@ class PluginManager:
self.inner.check_pending()
def mod_version(self, modid: str):
return self._hook_caller(modid, PluginSpec.hexdoc_mod_version)()
return self._hook_caller(PluginSpec.hexdoc_mod_version, modid)()
def update_jinja_env(self, env: SandboxedEnvironment):
caller = self._hook_caller(PluginSpec.hexdoc_update_jinja_env)
caller.try_call(env=env)
return env
def update_template_args(self, template_args: dict[str, Any]):
self._hook_caller(None, PluginSpec.hexdoc_update_template_args).try_call(
template_args=template_args,
)
caller = self._hook_caller(PluginSpec.hexdoc_update_template_args)
caller.try_call(template_args=template_args)
return template_args
def load_resources(self, modid: str) -> Iterator[ModuleType]:
yield from self._import_from_hook(modid, PluginSpec.hexdoc_load_resource_dirs)
yield from self._import_from_hook(PluginSpec.hexdoc_load_resource_dirs, modid)
def load_tagged_unions(self, modid: str | None = None) -> Iterator[ModuleType]:
yield from self._import_from_hook(modid, PluginSpec.hexdoc_load_tagged_unions)
yield from self._import_from_hook(PluginSpec.hexdoc_load_tagged_unions, modid)
def load_jinja_templates(self, modids: Iterable[str]):
"""modid -> PackageLoader"""
loaders = dict[str, PackageLoader]()
for modid in modids:
caller = self._hook_caller(modid, PluginSpec.hexdoc_load_jinja_templates)
caller = self._hook_caller(PluginSpec.hexdoc_load_jinja_templates, modid)
for package, package_path in flatten(caller()):
module = import_package(package)
loaders[modid] = PackageLoader(module.__name__, package_path)
@ -89,12 +115,12 @@ class PluginManager:
def _import_from_hook(
self,
__modid: str | None,
__spec: Callable[_P, HookReturns[Package]],
__modid: str | None = None,
*args: _P.args,
**kwargs: _P.kwargs,
) -> Iterator[ModuleType]:
packages = self._hook_caller(__modid, __spec)(*args, **kwargs)
packages = self._hook_caller(__spec, __modid)(*args, **kwargs)
for package in flatten(packages):
yield import_package(package)
@ -106,10 +132,26 @@ class PluginManager:
caller = self.inner.subset_hook_caller(spec.__name__, [plugin])
yield modid, TypedHookCaller(modid, caller)
@overload
def _hook_caller(
self,
spec: Callable[_P, None],
modid: str | None = None,
) -> _NoCallTypedHookCaller[_P]:
...
@overload
def _hook_caller(
self,
modid: str | None,
spec: Callable[_P, _R | None],
modid: str | None = None,
) -> TypedHookCaller[_P, _R]:
...
def _hook_caller(
self,
spec: Callable[_P, _R | None],
modid: str | None = None,
) -> TypedHookCaller[_P, _R]:
"""Returns a hook caller for the named method which only manages calls to a
specific modid (aka plugin name)."""

View file

@ -2,9 +2,12 @@ from importlib.resources import Package
from typing import Any, Protocol, TypeVar
import pluggy
from jinja2.sandbox import SandboxedEnvironment
HEXDOC_PROJECT_NAME = "hexdoc"
hookspec = pluggy.HookspecMarker(HEXDOC_PROJECT_NAME)
_T = TypeVar("_T")
@ -13,9 +16,6 @@ HookReturn = _T | list[_T]
HookReturns = list[HookReturn[_T]]
hookspec = pluggy.HookspecMarker(HEXDOC_PROJECT_NAME)
class PluginSpec(Protocol):
@staticmethod
@hookspec(firstresult=True)
@ -24,7 +24,12 @@ class PluginSpec(Protocol):
@staticmethod
@hookspec
def hexdoc_update_template_args(*, template_args: dict[str, Any]) -> None:
def hexdoc_update_jinja_env(env: SandboxedEnvironment) -> None:
...
@staticmethod
@hookspec
def hexdoc_update_template_args(template_args: dict[str, Any]) -> None:
...
@staticmethod
@ -46,64 +51,48 @@ class PluginSpec(Protocol):
# mmmmmm, interfaces
class ModVersionImpl(Protocol):
"""Interface for a plugin implementing `hexdoc_mod_version`.
class PluginImpl(Protocol):
"""Interface for an implementation of a hexdoc plugin hook.
These protocols are optional - they gives better type checking, but everything will
work fine with a standard pluggy hook implementation.
"""
class ModVersionImpl(PluginImpl, Protocol):
@staticmethod
def hexdoc_mod_version() -> str:
"""Return your plugin's mod version (ie. `GRADLE_VERSION`)."""
...
class ExtraTemplateArgsImpl(Protocol):
"""Interface for a plugin implementing `hexdoc_update_template_args`.
class UpdateJinjaEnvImpl(PluginImpl, Protocol):
@staticmethod
def hexdoc_update_jinja_env(env: SandboxedEnvironment) -> None:
...
These protocols are optional - they gives better type checking, but everything will
work fine with a standard pluggy hook implementation.
"""
class UpdateTemplateArgsImpl(PluginImpl, Protocol):
@staticmethod
def hexdoc_update_template_args(template_args: dict[str, Any]) -> None:
...
class LoadResourceDirsImpl(Protocol):
"""Interface for a plugin implementing `hexdoc_load_resource_dirs`.
These protocols are optional - they gives better type checking, but everything will
work fine with a standard pluggy hook implementation.
"""
class LoadResourceDirsImpl(PluginImpl, Protocol):
@staticmethod
def hexdoc_load_resource_dirs() -> HookReturn[Package]:
"""Return the module(s) which contain your plugin's exported book resources."""
...
class LoadTaggedUnionsImpl(Protocol):
"""Interface for a plugin implementing `hexdoc_load_tagged_unions`.
These protocols are optional - they gives better type checking, but everything will
work fine with a standard pluggy hook implementation.
"""
class LoadTaggedUnionsImpl(PluginImpl, Protocol):
@staticmethod
def hexdoc_load_tagged_unions() -> HookReturn[Package]:
"""Return the module(s) which contain your plugin's tagged union subtypes."""
...
class LoadJinjaTemplatesImpl(Protocol):
"""Interface for a plugin implementing `hexdoc_load_jinja_templates`.
These protocols are optional - they gives better type checking, but everything will
work fine with a standard pluggy hook implementation.
"""
class LoadJinjaTemplatesImpl(PluginImpl, Protocol):
@staticmethod
def hexdoc_load_jinja_templates() -> HookReturn[tuple[Package, str]]:
...

View file

@ -9,6 +9,8 @@ from syrupy.assertion import SnapshotAssertion
from hexdoc.cli.main import render
from ..conftest import longrun
PROPS_FILE = Path("doc/properties.toml")
RENDERED_FILENAMES = [
@ -29,6 +31,7 @@ def subprocess_output_dir(tmp_path_factory: TempPathFactory) -> Path:
return tmp_path_factory.mktemp("subprocess", numbered=False)
@longrun
def test_render_app(app_output_dir: Path):
render(
props_file=PROPS_FILE,
@ -37,6 +40,7 @@ def test_render_app(app_output_dir: Path):
)
@longrun
def test_render_subprocess(subprocess_output_dir: Path):
cmd = [
"hexdoc",
@ -48,6 +52,7 @@ def test_render_subprocess(subprocess_output_dir: Path):
subprocess.run(cmd)
@longrun
@pytest.mark.parametrize("filename", RENDERED_FILENAMES)
def test_files(
filename: str,

View file

@ -7,15 +7,18 @@ from syrupy.assertion import SnapshotAssertion
from syrupy.extensions.single_file import SingleFileSnapshotExtension, WriteMode
from syrupy.types import SerializableData, SerializedData
from hexdoc.plugin import PluginManager
longrun = pytest.mark.skipif("not config.getoption('longrun')")
# https://stackoverflow.com/a/43938191
def pytest_addoption(parser: Parser):
parser.addoption(
"--longrun",
action="store_true",
"--no-longrun",
action="store_false",
dest="longrun",
default=False,
help="enable longrun-decorated tests",
help="disable longrun-decorated tests",
)
@ -38,3 +41,8 @@ class FilePathSnapshotExtension(SingleFileSnapshotExtension):
@pytest.fixture
def path_snapshot(snapshot: SnapshotAssertion):
return snapshot.use_extension(FilePathSnapshotExtension)
@pytest.fixture
def pm():
return PluginManager()

View file

@ -0,0 +1,63 @@
# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false
from typing import Any, Callable
import pytest
from jinja2.sandbox import SandboxedEnvironment
from markupsafe import Markup
from pytest import FixtureRequest, Mark
from hexdoc.cli.utils.render import create_jinja_env
from hexdoc.plugin import (
PluginManager,
UpdateJinjaEnvImpl,
UpdateTemplateArgsImpl,
hookimpl,
)
RenderTemplate = Callable[[], str]
@pytest.fixture
def render_template(request: FixtureRequest, pm: PluginManager) -> RenderTemplate:
match request.node.get_closest_marker("template"):
case Mark(args=[str(template_str)], kwargs=template_args):
pass
case marker:
raise TypeError(f"Expected marker `template` with 1 string, got {marker}")
def callback():
env = create_jinja_env(pm, [])
template = env.from_string(template_str)
return template.render(pm.update_template_args(dict(template_args)))
return callback
@pytest.mark.template("{{ '<br />' }}")
def test_update_jinja_env(pm: PluginManager, render_template: RenderTemplate):
class Hooks(UpdateJinjaEnvImpl):
@staticmethod
@hookimpl
def hexdoc_update_jinja_env(env: SandboxedEnvironment) -> None:
env.autoescape = False
assert render_template() == Markup.escape("<br />")
pm.inner.register(Hooks)
assert render_template() == "<br />"
@pytest.mark.template(
"{{ key }}",
key="old_value",
)
def test_update_template_args(pm: PluginManager, render_template: RenderTemplate):
class Hooks(UpdateTemplateArgsImpl):
@staticmethod
@hookimpl
def hexdoc_update_template_args(template_args: dict[str, Any]) -> None:
template_args["key"] = "new_value"
assert render_template() == "old_value"
pm.inner.register(Hooks)
assert render_template() == "new_value"

View file

@ -109,6 +109,9 @@ patchouli = "hexdoc.patchouli._hooks:PatchouliPlugin"
[tool.pytest.ini_options]
addopts = ["--import-mode=importlib"]
markers = [
"template",
]
[tool.coverage.report]
include_namespace_packages = true