Add hook to modify Jinja env
This commit is contained in:
parent
e930333fc2
commit
b6b23f7040
|
@ -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)
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)."""
|
||||
|
|
|
@ -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]]:
|
||||
...
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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()
|
||||
|
|
63
doc/test/hooks/test_manager.py
Normal file
63
doc/test/hooks/test_manager.py
Normal 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"
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue