Skip to content

Commit

Permalink
feat: enable self.jinja2.get_template without prefix
Browse files Browse the repository at this point in the history
  • Loading branch information
janbritz committed Nov 7, 2024
1 parent 520a196 commit 48164d7
Show file tree
Hide file tree
Showing 5 changed files with 48 additions and 6 deletions.
2 changes: 1 addition & 1 deletion examples/full/python/local/full_example/question_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ def _compute_score(self) -> float:

@property
def formulation(self) -> str:
return self.jinja2.get_template("local.full_example/formulation.xhtml.j2").render()
return self.jinja2.get_template("formulation.xhtml.j2").render()


class ExampleQuestion(Question):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def _compute_score(self) -> float:
@property
def formulation(self) -> str:
self.placeholders["description"] = "Welcher ist der zweite Buchstabe im deutschen Alphabet?"
return self.jinja2.get_template("local.minimal_example/formulation.xhtml.j2").render()
return self.jinja2.get_template("formulation.xhtml.j2").render()


class ExampleQuestion(Question):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ def _compute_score(self) -> float:

@property
def formulation(self) -> str:
return self.jinja2.get_template("local.static_files_example/formulation.xhtml.j2").render()
return self.jinja2.get_template("formulation.xhtml.j2").render()


class ExampleQuestion(Question):
Expand Down
6 changes: 5 additions & 1 deletion questionpy/_attempt.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import inspect
from abc import ABC, abstractmethod
from collections.abc import Mapping, Sequence
from functools import cached_property
Expand Down Expand Up @@ -237,7 +238,10 @@ def _compute_final_score(self) -> float:

@cached_property
def jinja2(self) -> jinja2.Environment:
return create_jinja2_environment(self, self.question)
# Get the caller module.
frame_info = inspect.stack()[2] # Second entry, because of the decorator.
module = inspect.getmodule(frame_info.frame)
return create_jinja2_environment(self, self.question, called_from=module)

@property
def variant(self) -> int:
Expand Down
42 changes: 40 additions & 2 deletions questionpy/_ui.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import importlib.resources
import re
from collections.abc import Callable
from types import ModuleType
from typing import TYPE_CHECKING

import jinja2
Expand All @@ -9,6 +12,27 @@
from questionpy import Attempt, Question


class _CurrentPackageTemplateLoader(jinja2.PackageLoader):
"""Same as :class:`jinja2.PackageLoader` but ensures that no prefix was given."""

def __init__(self, package_name: str):
super().__init__(package_name)
self._pattern = re.compile(r"^.+\..+/.+")

def get_source(self, environment: jinja2.Environment, template: str) -> tuple[str, str, Callable[[], bool] | None]:
if self._pattern.match(template):
# Namespace and short name were provided.
raise jinja2.TemplateNotFound(name=template)
return super().get_source(environment, template)


def _loader_for_current_package(namespace: str, short_name: str) -> jinja2.BaseLoader | None:
pkg_name = f"{namespace}.{short_name}"
if not (importlib.resources.files(pkg_name) / "templates").is_dir():
return None
return _CurrentPackageTemplateLoader(pkg_name)


def _loader_for_package(package: Package) -> jinja2.BaseLoader | None:
pkg_name = f"{package.manifest.namespace}.{package.manifest.short_name}"
if not (importlib.resources.files(pkg_name) / "templates").is_dir():
Expand All @@ -20,11 +44,14 @@ def _loader_for_package(package: Package) -> jinja2.BaseLoader | None:
return jinja2.PackageLoader(pkg_name)


def create_jinja2_environment(attempt: "Attempt", question: "Question") -> jinja2.Environment:
def create_jinja2_environment(
attempt: "Attempt", question: "Question", called_from: ModuleType | None
) -> jinja2.Environment:
"""Creates a Jinja2 environment with sensible default configuration.
- Library templates are accessible under the prefix ``qpy/``.
- Package templates are accessible under the prefix ``<namespace>.<short_name>/``.
- The prefix is optional when accessing templates of the current package.
- The QPy environment, attempt, question and question type are available as globals.
"""
qpy_env = get_qpy_environment()
Expand All @@ -38,7 +65,18 @@ def create_jinja2_environment(attempt: "Attempt", question: "Question") -> jinja
# Add a place for SDK-Templates, such as the one used by ComposedAttempt etc.
loader_mapping["qpy"] = jinja2.PackageLoader(__package__)

env = jinja2.Environment(autoescape=True, loader=jinja2.PrefixLoader(mapping=loader_mapping))
loaders: list[jinja2.BaseLoader] = [jinja2.PrefixLoader(mapping=loader_mapping)]

# Get caller package template loader.
if called_from:
namespace, short_name = called_from.__name__.split(".", maxsplit=3)[:2]
if current_package_loader := _loader_for_current_package(namespace, short_name):
loaders.insert(0, current_package_loader)

# Create a choice loader to handle template names without a prefix.
choice_loader = jinja2.ChoiceLoader(loaders)

env = jinja2.Environment(autoescape=True, loader=choice_loader)
env.globals.update({
"environment": qpy_env,
"attempt": attempt,
Expand Down

0 comments on commit 48164d7

Please sign in to comment.