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 all 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
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
Loading