Skip to content

Commit

Permalink
feat: enable self.jinja2.get_template without prefix
Browse files Browse the repository at this point in the history
additionally: move templates directory outside of python module
and change template prefix to `@ns/sn`
  • Loading branch information
janbritz committed Jan 7, 2025
1 parent 02b6bf1 commit 4dc1bba
Show file tree
Hide file tree
Showing 14 changed files with 85 additions and 62 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
10 changes: 5 additions & 5 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
88 changes: 72 additions & 16 deletions questionpy/_ui.py
Original file line number Diff line number Diff line change
@@ -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/<namespace>/<short_name>/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 ``<namespace>.<short_name>/``.
- 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()

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,
Expand Down
20 changes: 0 additions & 20 deletions questionpy/templates/question.xhtml.j2

This file was deleted.

16 changes: 0 additions & 16 deletions questionpy/templates/subquestion.xhtml.j2

This file was deleted.

1 change: 1 addition & 0 deletions questionpy_sdk/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
# (c) Technische Universität Berlin, innoCampus <[email protected]>

PACKAGE_CONFIG_FILENAME = "qpy_config.yml"
TEMPLATES_DIR = "templates"
2 changes: 2 additions & 0 deletions questionpy_sdk/package/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion tests/questionpy_sdk/package/test_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down

0 comments on commit 4dc1bba

Please sign in to comment.