From 4dc1bba5f349eed40efab4ff6bf4b6155f6a4004 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 additionally: move templates directory outside of python module and change template prefix to `@ns/sn` --- .../local/full_example/question_type.py | 2 +- .../templates/formulation.xhtml.j2 | 0 .../local/minimal_example/question_type.py | 2 +- .../templates/formulation.xhtml.j2 | 0 .../static_files_example/question_type.py | 2 +- .../templates/formulation.xhtml.j2 | 0 poetry.lock | 10 +-- pyproject.toml | 2 +- questionpy/_ui.py | 88 +++++++++++++++---- questionpy/templates/question.xhtml.j2 | 20 ----- questionpy/templates/subquestion.xhtml.j2 | 16 ---- questionpy_sdk/constants.py | 1 + questionpy_sdk/package/builder.py | 2 + tests/questionpy_sdk/package/test_builder.py | 2 +- 14 files changed, 85 insertions(+), 62 deletions(-) rename examples/full/{python/local/full_example => }/templates/formulation.xhtml.j2 (100%) rename examples/minimal/{python/local/minimal_example => }/templates/formulation.xhtml.j2 (100%) rename examples/static-files/{python/local/static_files_example => }/templates/formulation.xhtml.j2 (100%) delete mode 100644 questionpy/templates/question.xhtml.j2 delete mode 100644 questionpy/templates/subquestion.xhtml.j2 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/full/python/local/full_example/templates/formulation.xhtml.j2 b/examples/full/templates/formulation.xhtml.j2 similarity index 100% rename from examples/full/python/local/full_example/templates/formulation.xhtml.j2 rename to examples/full/templates/formulation.xhtml.j2 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/minimal/python/local/minimal_example/templates/formulation.xhtml.j2 b/examples/minimal/templates/formulation.xhtml.j2 similarity index 100% rename from examples/minimal/python/local/minimal_example/templates/formulation.xhtml.j2 rename to examples/minimal/templates/formulation.xhtml.j2 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/examples/static-files/python/local/static_files_example/templates/formulation.xhtml.j2 b/examples/static-files/templates/formulation.xhtml.j2 similarity index 100% rename from examples/static-files/python/local/static_files_example/templates/formulation.xhtml.j2 rename to examples/static-files/templates/formulation.xhtml.j2 diff --git a/poetry.lock b/poetry.lock index e72d5983..c6857b39 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -1389,7 +1389,7 @@ files = [ [[package]] name = "questionpy-server" -version = "0.3.0" +version = "0.4.0" description = "QuestionPy application server" optional = false python-versions = "^3.11" @@ -1409,8 +1409,8 @@ watchdog = "^4.0.0" [package.source] type = "git" url = "https://github.com/questionpy-org/questionpy-server.git" -reference = "3ae1fcc79b1c9440d40cbe5b48d16ac4c68061b9" -resolved_reference = "3ae1fcc79b1c9440d40cbe5b48d16ac4c68061b9" +reference = "43e0990181afe6f7f93fae02eb1d37a73decba53" +resolved_reference = "43e0990181afe6f7f93fae02eb1d37a73decba53" [[package]] name = "ruff" @@ -1770,4 +1770,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "535aa62ce190f0383bd6ee8fd31e5a07954c3142a81ba23bffbfad0a0054f50a" +content-hash = "541327434d7d3eabae4e0936e1dd88bf63462b363b733376ce5ed5d852cc668d" diff --git a/pyproject.toml b/pyproject.toml index ea7a159a..3e775634 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ python = "^3.11" aiohttp = "^3.9.3" pydantic = "^2.6.4" PyYAML = "^6.0.1" -questionpy-server = { git = "https://github.com/questionpy-org/questionpy-server.git", rev = "3ae1fcc79b1c9440d40cbe5b48d16ac4c68061b9" } +questionpy-server = { git = "https://github.com/questionpy-org/questionpy-server.git", rev = "43e0990181afe6f7f93fae02eb1d37a73decba53" } jinja2 = "^3.1.3" aiohttp-jinja2 = "^1.6" lxml = "~5.1.0" diff --git a/questionpy/_ui.py b/questionpy/_ui.py index e4c00b7b..1cd92557 100644 --- a/questionpy/_ui.py +++ b/questionpy/_ui.py @@ -1,44 +1,100 @@ -import importlib.resources +import os.path +from collections.abc import Callable +from importlib.resources.abc import Traversable from typing import TYPE_CHECKING import jinja2 -from questionpy_common.environment import Package, get_qpy_environment +from questionpy_common.environment import Package, PackageNamespaceAndShortName, get_qpy_environment +from questionpy_sdk.constants import TEMPLATES_DIR if TYPE_CHECKING: from questionpy import Attempt, Question -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(): - # The package has no "templates" directory, which would cause PackageLoader to raise an unhelpful ValueError. +class _CustomPrefixLoader(jinja2.PrefixLoader): + """In contrast to the :class:`jinja2.PrefixLoader` this splits at the second occurrence of the delimiter. + + It enables us to handle paths like "@namespace/short_name/custom/path" with a prefix like "@namespace/short_name". + """ + + def get_prefix_and_name(self, template: str) -> tuple[str, str]: + namespace, shortname, rest = template.split(self.delimiter, maxsplit=2) + return namespace + self.delimiter + shortname, rest + + def get_loader(self, template: str) -> tuple[jinja2.BaseLoader, str]: + try: + prefix, name = self.get_prefix_and_name(template) + loader = self.mapping[prefix] + except (ValueError, KeyError) as e: + raise jinja2.TemplateNotFound(template) from e + return loader, name + + +class _TraversableTemplateLoader(jinja2.BaseLoader): + """In contrast to the :class:`jinja2.FileSystemLoader` this does not support the auto-reload feature of jinja2.""" + + def __init__(self, traversable: Traversable): + self.traversable = traversable + + def get_source(self, environment: jinja2.Environment, template: str) -> tuple[str, str, Callable[[], bool] | None]: + source_path = self.traversable.joinpath(template) + try: + return source_path.read_text("utf-8"), template, None + except FileNotFoundError as e: + raise jinja2.TemplateNotFound(template) from e + + +def _get_loader(package: Package) -> jinja2.BaseLoader | None: + templates_directory = package.get_path(f"{TEMPLATES_DIR}/") + + if not templates_directory.is_dir(): + # The package has no "templates" directory which would cause a template loader to raise an unhelpful ValueError. return None - # TODO: This looks for templates in python///templates, we might want to support a different - # directory, such as resources/templates. - return jinja2.PackageLoader(pkg_name) + # Check whether the templates folder is inside a zip. + if os.path.exists(str(templates_directory)): + return jinja2.FileSystemLoader(str(templates_directory)) + return _TraversableTemplateLoader(templates_directory) def create_jinja2_environment(attempt: "Attempt", question: "Question") -> 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 ``./``. + - 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() loader_mapping = {} for package in qpy_env.packages.values(): - loader = _loader_for_package(package) + loader = _get_loader(package) if loader: - loader_mapping[f"{package.manifest.namespace}.{package.manifest.short_name}"] = loader + loader_mapping[f"@{package.manifest.namespace}/{package.manifest.short_name}"] = loader + + loaders: list[jinja2.BaseLoader] = [_CustomPrefixLoader(mapping=loader_mapping)] + + # Get caller package template loader. + try: + module_parts = attempt.__module__.split(".", maxsplit=2) + namespace, short_name, *_ = module_parts + key = PackageNamespaceAndShortName(namespace=namespace, short_name=short_name) + package = qpy_env.packages[key] + except (KeyError, ValueError) as e: + msg = ( + "Current package namespace and shortname could not be determined from '__module__' attribute. Please do " + "not modify the '__module__' attribute." + ) + raise ValueError(msg) from e + + if current_package_loader := _get_loader(package): + loaders.insert(0, current_package_loader) - # Add a place for SDK-Templates, such as the one used by ComposedAttempt etc. - loader_mapping["qpy"] = jinja2.PackageLoader(__package__) + # Create a choice loader to handle template names without a prefix. + choice_loader = jinja2.ChoiceLoader(loaders) - env = jinja2.Environment(autoescape=True, loader=jinja2.PrefixLoader(mapping=loader_mapping)) + env = jinja2.Environment(autoescape=True, loader=choice_loader) env.globals.update({ "environment": qpy_env, "attempt": attempt, diff --git a/questionpy/templates/question.xhtml.j2 b/questionpy/templates/question.xhtml.j2 deleted file mode 100644 index 2a77ef94..00000000 --- a/questionpy/templates/question.xhtml.j2 +++ /dev/null @@ -1,20 +0,0 @@ - - - {{ formulation.content | safe }} - - {% if general_feedback %} - - {{ general_feedback.content | safe }} - - {% endif %} - {% if specific_feedback %} - - {{ specific_feedback.content | safe }} - - {% endif %} - {% if right_answer %} - - {{ right_answer.content | safe }} - - {% endif %} - diff --git a/questionpy/templates/subquestion.xhtml.j2 b/questionpy/templates/subquestion.xhtml.j2 deleted file mode 100644 index 5fefa69e..00000000 --- a/questionpy/templates/subquestion.xhtml.j2 +++ /dev/null @@ -1,16 +0,0 @@ -{# TODO: This should become qpy:subquestion when that is implemented. #} -
-
- {{ formulation.content | safe }} -
- {% if general_feedback %} -
- {{ general_feedback.content | safe }} -
- {% endif %} - {% if specific_feedback %} -
- {{ specific_feedback.content | safe }} -
- {% endif %} -
diff --git a/questionpy_sdk/constants.py b/questionpy_sdk/constants.py index faa69cd5..7da73181 100644 --- a/questionpy_sdk/constants.py +++ b/questionpy_sdk/constants.py @@ -3,3 +3,4 @@ # (c) Technische Universität Berlin, innoCampus PACKAGE_CONFIG_FILENAME = "qpy_config.yml" +TEMPLATES_DIR = "templates" diff --git a/questionpy_sdk/package/builder.py b/questionpy_sdk/package/builder.py index b15a099b..cd6295b9 100644 --- a/questionpy_sdk/package/builder.py +++ b/questionpy_sdk/package/builder.py @@ -19,6 +19,7 @@ import questionpy from questionpy_common.constants import DIST_DIR, MANIFEST_FILENAME from questionpy_common.manifest import Manifest, PackageFile +from questionpy_sdk.constants import TEMPLATES_DIR from questionpy_sdk.models import BuildHookName from questionpy_sdk.package.errors import PackageBuildError from questionpy_sdk.package.source import PackageSource @@ -102,6 +103,7 @@ def _write_package_files(self) -> None: """Writes custom package files.""" static_path = Path(DIST_DIR) / "static" self._write_glob(self._source.path, "python/**/*", DIST_DIR) + self._write_glob(self._source.path, f"{TEMPLATES_DIR}/**/*", DIST_DIR) self._write_glob(self._source.path, "css/**/*", static_path, add_to_static_files=True) self._write_glob(self._source.path, "js/**/*", static_path, add_to_static_files=True) self._write_glob(self._source.path, "static/**/*", DIST_DIR, add_to_static_files=True) diff --git a/tests/questionpy_sdk/package/test_builder.py b/tests/questionpy_sdk/package/test_builder.py index 64ebaa3f..ba3aaf56 100644 --- a/tests/questionpy_sdk/package/test_builder.py +++ b/tests/questionpy_sdk/package/test_builder.py @@ -42,7 +42,7 @@ def test_creates_proper_directory_entries(qpy_pkg_path: Path) -> None: assert zipfile.getinfo(f"{DIST_DIR}/python/").is_dir() assert zipfile.getinfo(f"{DIST_DIR}/python/local/").is_dir() assert zipfile.getinfo(f"{DIST_DIR}/python/local/minimal_example/").is_dir() - assert zipfile.getinfo(f"{DIST_DIR}/python/local/minimal_example/templates/").is_dir() + assert zipfile.getinfo(f"{DIST_DIR}/templates/").is_dir() assert zipfile.getinfo(f"{DIST_DIR}/dependencies/").is_dir() assert zipfile.getinfo(f"{DIST_DIR}/dependencies/site-packages/").is_dir() assert zipfile.getinfo(f"{DIST_DIR}/dependencies/site-packages/questionpy/").is_dir()