Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: enable self.jinja2.get_template without prefix #139

Open
wants to merge 6 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
61 changes: 48 additions & 13 deletions questionpy/_ui.py
Original file line number Diff line number Diff line change
@@ -1,44 +1,79 @@
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 _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:
source = source_path.read_text("utf-8")
except FileNotFoundError as e:
raise jinja2.TemplateNotFound(template) from e
else:
return source, template, None
larsbonczek marked this conversation as resolved.
Show resolved Hide resolved


def _get_loader(package: Package) -> jinja2.BaseLoader | None:
templates_folder = package.get_path(f"{TEMPLATES_DIR}/")
if not templates_folder.is_dir():
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)
if os.path.exists(str(templates_folder)):
return jinja2.FileSystemLoader(str(templates_folder))
janbritz marked this conversation as resolved.
Show resolved Hide resolved
return _TraversableTemplateLoader(templates_folder)


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.
janbritz marked this conversation as resolved.
Show resolved Hide resolved
"""
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
larsbonczek marked this conversation as resolved.
Show resolved Hide resolved

# Add a place for SDK-Templates, such as the one used by ComposedAttempt etc.
loader_mapping["qpy"] = jinja2.PackageLoader(__package__)
janbritz marked this conversation as resolved.
Show resolved Hide resolved

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.
module_parts = attempt.__module__.split(".", maxsplit=2)
if len(module_parts) < 2: # noqa: PLR2004
msg = "Please do not modify the '__module__' attribute."
janbritz marked this conversation as resolved.
Show resolved Hide resolved
raise ValueError(msg)
larsbonczek marked this conversation as resolved.
Show resolved Hide resolved

key = PackageNamespaceAndShortName(namespace=module_parts[0], short_name=module_parts[1])
package = qpy_env.packages[key]
larsbonczek marked this conversation as resolved.
Show resolved Hide resolved
if current_package_loader := _get_loader(package):
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
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