Add namespaced template imports

This commit is contained in:
object-Object 2023-10-19 00:08:34 -04:00
parent 5c152a4c30
commit a5b905cb9f
12 changed files with 145 additions and 55 deletions

View file

@ -4,5 +4,7 @@
"ms-python.vscode-pylance",
"ms-python.black-formatter",
"ms-python.isort",
"samuelcolvin.jinjahtml",
"noxiz.jinja-snippets",
],
}

View file

@ -107,7 +107,10 @@ missing = []
[template]
static_dir = "static"
packages = ["hexdoc"]
include = [
"hexcasting",
"patchouli",
]
[template.args]
mod_name = "Hex Casting"

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)
env = create_jinja_env(props, pm)
templates = {
"index.html": env.get_template(props.template.main),

View file

@ -6,13 +6,7 @@ import shutil
from pathlib import Path
from typing import Any
from jinja2 import (
ChoiceLoader,
FileSystemLoader,
PackageLoader,
StrictUndefined,
Template,
)
from jinja2 import ChoiceLoader, PrefixLoader, StrictUndefined, Template
from jinja2.sandbox import SandboxedEnvironment
from hexdoc.core.metadata import HexdocMetadata
@ -21,6 +15,7 @@ from hexdoc.core.resource import ResourceLocation
from hexdoc.minecraft import I18n
from hexdoc.minecraft.assets.textures import AnimatedTexture, Texture
from hexdoc.patchouli import Book
from hexdoc.plugin import PluginManager
from hexdoc.utils.jinja.extensions import IncludeRawExtension
from hexdoc.utils.jinja.filters import (
hexdoc_block,
@ -33,12 +28,15 @@ from hexdoc.utils.path import write_to_path
from .sitemap import MARKER_NAME, SitemapMarker
def create_jinja_env(props: Properties):
def create_jinja_env(props: Properties, pm: PluginManager):
prefix_loaders = pm.load_jinja_templates(props.template.include)
env = SandboxedEnvironment(
# search order: template_dirs, template_packages
loader=ChoiceLoader(
[FileSystemLoader(props.template.dirs)]
+ [PackageLoader(name, str(path)) for name, path in props.template.packages]
[
PrefixLoader(prefix_loaders, ":"),
*prefix_loaders.values(),
]
),
undefined=StrictUndefined,
lstrip_blocks=True,

View file

@ -5,7 +5,7 @@ import re
from pathlib import Path
from typing import Annotated, Any, Self
from pydantic import AfterValidator, Field, HttpUrl, field_validator
from pydantic import AfterValidator, Field, HttpUrl
from pydantic_settings import BaseSettings, SettingsConfigDict
from hexdoc.core.resource import ResourceDir, ResourceLocation
@ -72,20 +72,9 @@ class TemplateProps(StripHiddenModel):
style: str = "main.css.jinja"
static_dir: RelativePath | None = None
dirs: list[RelativePath] = Field(default_factory=list)
packages: list[tuple[str, Path]]
include: list[str]
args: dict[str, Any]
@field_validator("packages", mode="before")
def _check_packages(cls, values: Any | list[Any]):
if not isinstance(values, list):
return values
for i, value in enumerate(values):
if isinstance(value, str):
values[i] = (value, Path("_templates"))
return values
class MinecraftAssetsProps(StripHiddenModel):
ref: str

View file

@ -1,7 +1,10 @@
from importlib.resources import Package
import hexdoc
from hexdoc.__gradle_version__ import GRADLE_VERSION
from hexdoc.plugin import (
HookReturn,
LoadJinjaTemplatesImpl,
LoadResourceDirsImpl,
LoadTaggedUnionsImpl,
ModVersionImpl,
@ -12,7 +15,12 @@ from . import hex_recipes
from .page import hex_pages
class HexcastingPlugin(LoadResourceDirsImpl, LoadTaggedUnionsImpl, ModVersionImpl):
class HexcastingPlugin(
LoadResourceDirsImpl,
LoadTaggedUnionsImpl,
ModVersionImpl,
LoadJinjaTemplatesImpl,
):
@staticmethod
@hookimpl
def hexdoc_mod_version() -> str:
@ -20,14 +28,20 @@ class HexcastingPlugin(LoadResourceDirsImpl, LoadTaggedUnionsImpl, ModVersionImp
@staticmethod
@hookimpl
def hexdoc_load_resource_dirs() -> Package | list[Package]:
# lazy import because this won't exist when we're initially generating it
# so we only want to import it when addons need the data
def hexdoc_load_resource_dirs() -> HookReturn[Package]:
# lazy import because generated may not exist when this file is loaded
# eg. when generating the contents of generated
# so we only want to import it if we actually need it
from hexdoc._export import generated
return generated
@staticmethod
@hookimpl
def hexdoc_load_tagged_unions() -> Package | list[Package]:
def hexdoc_load_tagged_unions() -> HookReturn[Package]:
return [hex_recipes, hex_pages]
@staticmethod
@hookimpl
def hexdoc_load_jinja_templates() -> HookReturn[tuple[Package, str]]:
return hexdoc, "_templates"

View file

@ -1,12 +1,23 @@
from importlib.resources import Package
from hexdoc.plugin import LoadTaggedUnionsImpl, hookimpl
import hexdoc
from hexdoc.plugin import (
HookReturn,
LoadJinjaTemplatesImpl,
LoadTaggedUnionsImpl,
hookimpl,
)
from .page import pages
class PatchouliPlugin(LoadTaggedUnionsImpl):
class PatchouliPlugin(LoadTaggedUnionsImpl, LoadJinjaTemplatesImpl):
@staticmethod
@hookimpl
def hexdoc_load_tagged_unions() -> Package | list[Package]:
return pages
@staticmethod
@hookimpl
def hexdoc_load_jinja_templates() -> HookReturn[tuple[Package, str]]:
return hexdoc, "_templates"

View file

@ -3,7 +3,9 @@ __all__ = [
"ModVersionImpl",
"LoadResourceDirsImpl",
"LoadTaggedUnionsImpl",
"LoadJinjaTemplatesImpl",
"PluginManager",
"HookReturn",
]
import pluggy
@ -11,6 +13,8 @@ import pluggy
from .manager import PluginManager
from .specs import (
HEXDOC_PROJECT_NAME,
HookReturn,
LoadJinjaTemplatesImpl,
LoadResourceDirsImpl,
LoadTaggedUnionsImpl,
ModVersionImpl,

View file

@ -2,13 +2,14 @@ import importlib
from dataclasses import dataclass
from importlib.resources import Package
from types import ModuleType
from typing import Callable, Generic, Iterator, ParamSpec, Sequence, TypeVar
from typing import Callable, Generic, Iterable, Iterator, ParamSpec, TypeVar
import pluggy
from jinja2 import PackageLoader
from hexdoc.model import ValidationContext
from .specs import HEXDOC_PROJECT_NAME, HookPackages, PluginSpec
from .specs import HEXDOC_PROJECT_NAME, HookReturns, PluginSpec
_T = TypeVar("_T")
@ -16,6 +17,10 @@ _P = ParamSpec("_P")
_R = TypeVar("_R", covariant=True)
class PluginNotFoundError(RuntimeError):
pass
@dataclass
class TypedHookCaller(Generic[_P, _R]):
plugin_name: str | None
@ -32,16 +37,22 @@ class TypedHookCaller(Generic[_P, _R]):
return f"Plugin {HEXDOC_PROJECT_NAME}-{self.plugin_name}"
def __call__(self, *args: _P.args, **kwargs: _P.kwargs) -> _R:
def try_call(self, *args: _P.args, **kwargs: _P.kwargs) -> _R | None:
result = self.caller(*args, **kwargs)
match result:
case None | []:
raise RuntimeError(
f"{self.plugin_display_name} does not implement hook {self.name}"
)
return None
case _:
return result
def __call__(self, *args: _P.args, **kwargs: _P.kwargs) -> _R:
result = self.try_call(*args, **kwargs)
if result is None:
raise PluginNotFoundError(
f"{self.plugin_display_name} does not implement hook {self.name}"
)
return result
class PluginManager:
"""Custom hexdoc plugin manager with helpers and stronger typing."""
@ -61,10 +72,20 @@ class PluginManager:
def load_tagged_unions(self, modid: str | None = None) -> Iterator[ModuleType]:
yield from self._import_from_hook(modid, PluginSpec.hexdoc_load_tagged_unions)
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)
for package, package_path in flatten(caller()):
module = import_package(package)
loaders[modid] = PackageLoader(module.__name__, package_path)
return loaders
def _import_from_hook(
self,
__modid: str | None,
__spec: Callable[_P, HookPackages],
__spec: Callable[_P, HookReturns[Package]],
*args: _P.args,
**kwargs: _P.kwargs,
) -> Iterator[ModuleType]:
@ -72,6 +93,14 @@ class PluginManager:
for package in flatten(packages):
yield import_package(package)
def _all_hook_callers(
self,
spec: Callable[_P, _R | None],
) -> Iterator[tuple[str, TypedHookCaller[_P, _R]]]:
for modid, plugin in self.inner.list_name_plugin():
caller = self.inner.subset_hook_caller(spec.__name__, [plugin])
yield modid, TypedHookCaller(modid, caller)
def _hook_caller(
self,
modid: str | None,
@ -100,7 +129,7 @@ class PluginManagerContext(ValidationContext, arbitrary_types_allowed=True):
def flatten(values: list[list[_T] | _T]) -> Iterator[_T]:
for value in values:
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
if isinstance(value, list):
yield from value
else:
yield value

View file

@ -1,16 +1,21 @@
from importlib.resources import Package
from typing import Protocol
from typing import Protocol, TypeVar
import pluggy
HEXDOC_PROJECT_NAME = "hexdoc"
_T = TypeVar("_T")
HookReturn = _T | list[_T]
HookReturns = list[HookReturn[_T]]
hookspec = pluggy.HookspecMarker(HEXDOC_PROJECT_NAME)
HookPackages = list[Package | list[Package]]
class PluginSpec(Protocol):
@staticmethod
@hookspec(firstresult=True)
@ -19,12 +24,17 @@ class PluginSpec(Protocol):
@staticmethod
@hookspec
def hexdoc_load_resource_dirs() -> HookPackages:
def hexdoc_load_resource_dirs() -> HookReturns[Package]:
...
@staticmethod
@hookspec
def hexdoc_load_tagged_unions() -> HookPackages:
def hexdoc_load_tagged_unions() -> HookReturns[Package]:
...
@staticmethod
@hookspec
def hexdoc_load_jinja_templates() -> HookReturns[tuple[Package, str]]:
...
@ -52,7 +62,7 @@ class LoadResourceDirsImpl(Protocol):
"""
@staticmethod
def hexdoc_load_resource_dirs() -> Package | list[Package]:
def hexdoc_load_resource_dirs() -> HookReturn[Package]:
"""Return the module(s) which contain your plugin's exported book resources."""
...
@ -65,6 +75,18 @@ class LoadTaggedUnionsImpl(Protocol):
"""
@staticmethod
def hexdoc_load_tagged_unions() -> Package | list[Package]:
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.
"""
@staticmethod
def hexdoc_load_jinja_templates() -> HookReturn[tuple[Package, str]]:
...

View file

@ -37,9 +37,10 @@ regex = "{^_pattern_regex}"
[template]
static_dir = "static"
packages = [
"{{ cookiecutter.__project_slug }}",
"hexdoc",
include = [
"{{ cookiecutter.modid }}",
"hexcasting",
"patchouli",
]
[template.args]

View file

@ -1,11 +1,23 @@
from importlib.resources import Package
from hexdoc.plugin import LoadResourceDirsImpl, ModVersionImpl, hookimpl
from hexdoc.plugin import (
HookReturn,
LoadJinjaTemplatesImpl,
LoadResourceDirsImpl,
ModVersionImpl,
hookimpl,
)
import {{ cookiecutter.__project_slug }}
from .__gradle_version__ import GRADLE_VERSION
class {{ cookiecutter.plugin_classname }}(LoadResourceDirsImpl, ModVersionImpl):
class {{ cookiecutter.plugin_classname }}(
LoadJinjaTemplatesImpl,
LoadResourceDirsImpl,
ModVersionImpl,
):
@staticmethod
@hookimpl
def hexdoc_mod_version() -> str:
@ -13,9 +25,14 @@ class {{ cookiecutter.plugin_classname }}(LoadResourceDirsImpl, ModVersionImpl):
@staticmethod
@hookimpl
def hexdoc_load_resource_dirs() -> Package | list[Package]:
def hexdoc_load_resource_dirs() -> HookReturn[Package]:
# This needs to be a lazy import because they may not exist when this file is
# first loaded, eg. when generating the contents of generated.
from ._export import generated, resources
return [generated, resources]
@staticmethod
@hookimpl
def hexdoc_load_jinja_templates() -> HookReturn[tuple[Package, str]]:
return {{ cookiecutter.__project_slug }}, "_templates"