Skip to content

Commit

Permalink
feat: move templates directory outside of python module
Browse files Browse the repository at this point in the history
  • Loading branch information
janbritz committed Nov 21, 2024
1 parent fba795c commit bf67709
Show file tree
Hide file tree
Showing 9 changed files with 46 additions and 52 deletions.
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 = "8d59f6f78f1b57648378b236d39a7db18d0da285" }
jinja2 = "^3.1.3"
aiohttp-jinja2 = "^1.6"
lxml = "~5.1.0"
Expand Down
6 changes: 1 addition & 5 deletions questionpy/_attempt.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import inspect
from abc import ABC, abstractmethod
from collections.abc import Mapping, Sequence
from functools import cached_property
Expand Down Expand Up @@ -238,10 +237,7 @@ def _compute_final_score(self) -> float:

@cached_property
def jinja2(self) -> jinja2.Environment:
# Get the caller module.
frame_info = inspect.stack(context=0)[2] # Second entry, because of the decorator.
module = inspect.getmodule(frame_info.frame)
return create_jinja2_environment(self, self.question, called_from=module)
return create_jinja2_environment(self, self.question)

@property
def variant(self) -> int:
Expand Down
75 changes: 36 additions & 39 deletions questionpy/_ui.py
Original file line number Diff line number Diff line change
@@ -1,77 +1,74 @@
import importlib.resources
import re
import os.path
from collections.abc import Callable
from types import ModuleType
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.constants import TEMPLATES_DIR
from questionpy_common.environment import Package, PackageNamespaceAndShortName, get_qpy_environment

if TYPE_CHECKING:
from questionpy import Attempt, Question


class _CurrentPackageTemplateLoader(jinja2.PackageLoader):
"""Same as :class:`jinja2.PackageLoader` but ensures that no prefix was given."""
class _TraversableTemplateLoader(jinja2.BaseLoader):
"""In contrast to the :class:`jinja2.FileSystemLoader` this does not support the auto-reload feature of jinja2."""

def __init__(self, package_name: str):
super().__init__(package_name)
self._pattern = re.compile(r"^.+\..+/.+")
def __init__(self, traversable: Traversable):
self.traversable = traversable

def get_source(self, environment: jinja2.Environment, template: str) -> tuple[str, str, Callable[[], bool] | None]:
if self._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():
# The package has no "templates" directory, which would cause PackageLoader to raise an unhelpful ValueError.
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


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))
return _TraversableTemplateLoader(templates_folder)


def create_jinja2_environment(
attempt: "Attempt", question: "Question", called_from: ModuleType | None
) -> jinja2.Environment:
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

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

loaders: list[jinja2.BaseLoader] = [jinja2.PrefixLoader(mapping=loader_mapping)]

# Get caller package template loader.
if called_from:
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)
module_parts = attempt.__module__.split(".", maxsplit=3)
if len(module_parts) < 2: # noqa: PLR2004
msg = "Please do not modify the '__module__' attribute."
raise ValueError(msg)

key = PackageNamespaceAndShortName(namespace=module_parts[0], short_name=module_parts[1])
package = qpy_env.packages[key]
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)
Expand Down
3 changes: 2 additions & 1 deletion questionpy_sdk/package/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from types import TracebackType

import questionpy
from questionpy_common.constants import DIST_DIR, MANIFEST_FILENAME
from questionpy_common.constants import DIST_DIR, MANIFEST_FILENAME, TEMPLATES_DIR
from questionpy_common.manifest import Manifest, PackageFile
from questionpy_sdk.models import BuildHookName
from questionpy_sdk.package.errors import PackageBuildError
Expand Down Expand Up @@ -102,6 +102,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 bf67709

Please sign in to comment.