From d7fe80aa729c548b95a1186b07328b950c453fe5 Mon Sep 17 00:00:00 2001 From: Jan Britz Date: Thu, 7 Nov 2024 15:16:54 +0100 Subject: [PATCH] feat: enable `self.jinja2.get_template` without prefix --- .../local/full_example/question_type.py | 2 +- .../local/minimal_example/question_type.py | 2 +- .../static_files_example/question_type.py | 2 +- questionpy/_attempt.py | 6 ++- questionpy/_ui.py | 42 +++++++++++++++++-- 5 files changed, 47 insertions(+), 7 deletions(-) diff --git a/examples/full/python/local/full_example/question_type.py b/examples/full/python/local/full_example/question_type.py index d6335f18..6c40e24c 100644 --- a/examples/full/python/local/full_example/question_type.py +++ b/examples/full/python/local/full_example/question_type.py @@ -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): diff --git a/examples/minimal/python/local/minimal_example/question_type.py b/examples/minimal/python/local/minimal_example/question_type.py index 8b4c97db..36119e31 100644 --- a/examples/minimal/python/local/minimal_example/question_type.py +++ b/examples/minimal/python/local/minimal_example/question_type.py @@ -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): diff --git a/examples/static-files/python/local/static_files_example/question_type.py b/examples/static-files/python/local/static_files_example/question_type.py index b8c40c36..6c40e24c 100644 --- a/examples/static-files/python/local/static_files_example/question_type.py +++ b/examples/static-files/python/local/static_files_example/question_type.py @@ -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): diff --git a/questionpy/_attempt.py b/questionpy/_attempt.py index 1ec3e20e..4260aef4 100644 --- a/questionpy/_attempt.py +++ b/questionpy/_attempt.py @@ -1,3 +1,4 @@ +import inspect from abc import ABC, abstractmethod from collections.abc import Mapping, Sequence from functools import cached_property @@ -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: diff --git a/questionpy/_ui.py b/questionpy/_ui.py index e4c00b7b..7c1fcf39 100644 --- a/questionpy/_ui.py +++ b/questionpy/_ui.py @@ -1,5 +1,7 @@ import importlib.resources -from typing import TYPE_CHECKING +import re +from types import ModuleType +from typing import TYPE_CHECKING, Callable import jinja2 @@ -9,6 +11,26 @@ from questionpy import Attempt, Question +class _CurrentPackageTemplateLoader(jinja2.PackageLoader): + """Same as :class:`jinja2.PackageLoader` but ensures that no prefix was given.""" + + def get_source( + self, environment: jinja2.Environment, template: str + ) -> tuple[str, str, Callable[[], bool] | None]: + pattern = re.compile(r"^.+\..+/.+") + if 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(): @@ -20,11 +42,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 ``./``. + - 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() @@ -38,7 +63,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. + assert called_from is not None + 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,