Skip to content
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Byte-compiled / optimized / DLL files
__jinja__/
__pycache__/
*.py[cod]
*$py.class
Expand Down
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ testconfig: ./envhelp/output/testconfs
./envhelp/output/testconfs:
@./envhelp/makeconfig test --docker
@./envhelp/makeconfig test
@echo "prime any configured templates"
@$(LOCAL_PYTHON_BIN) -m mig.lib.templates prime \
-c ./envhelp/output/testconfs-local/MiGserver.conf

ifeq ($(MIG_ENV),'local')
./envhelp/local.depends: $(REQS_PATH) local-requirements.txt
Expand Down
Empty file added mig/assets/templates/.gitkeep
Empty file.
4 changes: 4 additions & 0 deletions mig/install/MiGserver-template.conf
Original file line number Diff line number Diff line change
Expand Up @@ -790,3 +790,7 @@ logo_right = /images/skin/__SKIN__/logo-right.png
# Optional data safety notice and popup on Files page
datasafety_link = __DATASAFETY_LINK__
datasafety_text = __DATASAFETY_TEXT__

[TEMPLATES]
base_packages = __TEMPLATES_BASE_PACKAGES__
cache_dir = __TEMPLATES_CACHE_DIR__
280 changes: 280 additions & 0 deletions mig/lib/templates/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
# -*- coding: utf-8 -*-
#
# --- BEGIN_HEADER ---
#
# templates/__init__ - main logic for template support
# Copyright (C) 2003-2026 The MiG Project by the Science HPC Center at UCPH
#
# This file is part of MiG.
#
# MiG is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# MiG is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301,
# USA.
#
# -- END_HEADER ---
#

"""
Template support library code.
"""

import importlib
import os
from operator import itemgetter

from jinja2 import (
Environment,
FileSystemBytecodeCache,
PackageLoader,
Template,
)
from jinja2 import meta as jinja2_meta
from jinja2 import (
select_autoescape,
)


def _expand_base_packages(base_packages):
template_packages = []
for package_name in base_packages:
try:
package = importlib.import_module(package_name)
except (ImportError, ModuleNotFoundError):
raise UnknownTemplateError(package_name)
template_packages.extend(package.TEMPLATE_PACKAGES)
return template_packages


def _strip_template_ext(template_name_with_ext):
return os.path.splitext(os.path.splitext(template_name_with_ext)[0])[0]


class _NoopContext:
"""
Adapter class to allow templates to be directly rendered.

Note that this is in contrast to further work making use of the
same provisions that allows the selection of translations.
"""

def __init__(self, *args):
self._tmpl = None
self._tmpl_args = None

def extend(self, template, template_args):
self._tmpl = template
self._tmpl_args = template_args
return self

def render(self):
return self._tmpl.render(**self._tmpl_args)


class TemplateStore:
"""
An abstraction for interacting with an enable series of template packages.
"""

def __init__(self, packages, cache_dir=None, extra_globals=None):
assert cache_dir is not None

self._packages = packages
self._cache_dir = cache_dir
self._template_globals = extra_globals
self._template_env_by_package = {}

@property
def cache_dir(self):
return self._cache_dir

@property
def context(self):
return self._template_globals

def _env_for_package(self, package_name):
"""
Direct access to a jinja2 Environment for a package exposing templates.
"""

if package_name not in self._packages:
raise UnknownTemplateError(package_name)

if package_name in self._template_env_by_package:
return self._template_env_by_package[package_name]

package_cache_dir = os.path.join(self.cache_dir, package_name)
template_env = Environment(
loader=PackageLoader(package_name),
bytecode_cache=FileSystemBytecodeCache(package_cache_dir, "%s"),
autoescape=select_autoescape(),
)
self._template_env_by_package[package_name] = template_env
return template_env

def grab_template(
self,
template_name,
template_group,
output_format,
template_globals=None,
**kwargs
):
"""
Directly access an enabled template.
"""

template_env = self._env_for_package(template_group)
template_fqname = "%s.%s.jinja" % (template_name, output_format)
Copy link
Copy Markdown
Contributor

@rasmunk rasmunk Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should properly be emphasized in the documentation or elsewhere that it is required that any apps that provide templates are required to use this format. Might even turn into [TEMPLATES] configuration option in the future

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree on the documentation side, but I don''t quite follow the mention of the templates section.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you be able to clarify your comment?

try:
return template_env.get_template(
template_fqname, globals=template_globals
)
except FileNotFoundError:
raise UnknownTemplateError(template_group, template_name)

def list_templates(self):
"""
Return a list of templates for all enabled packages.
"""

template_and_group_pairs = []
for template_group in self._packages:
template_env = self._env_for_package(template_group)
pairs = (
(_strip_template_ext(template), template_group)
for template in template_env.list_templates()
)
template_and_group_pairs.extend(pairs)
template_and_group_pairs.sort(key=itemgetter(1, 0))
return template_and_group_pairs

def list_templates_groups(self):
"""
Return the set of enabled packages that expose templates.
"""

nonunique_template_groups = (
template_group for _, template_group in self.list_templates()
)
return set(nonunique_template_groups)

def prime_templates(self):
"""
Precompile all templates across the enabled packages.
"""

os.makedirs(self.cache_dir, exist_ok=True)

for template_group in self.list_templates_groups():
template_group_cache_dir = os.path.join(
self.cache_dir, template_group
)
os.makedirs(template_group_cache_dir, exist_ok=True)

primed_count = 0

for template_name, template_group in self.list_templates():
primed_count += 1
self.grab_template(template_name, template_group, "html")

return primed_count

def extract_variables(
self,
template_or_name,
template_group,
output_format=None,
template_globals=None,
):
"""
Return the expected variables for a given template.
"""

template_env = self._env_for_package(template_group)
if isinstance(template_or_name, Template):
raise NotImplementedError()
else:
template = self.grab_template(
template_or_name,
template_group,
output_format,
globals=template_globals,
)
with open(template.filename) as f:
template_source = f.read()
ast = template_env.parse(template_source)
return jinja2_meta.find_undeclared_variables(ast)

@staticmethod
def from_configuration(configuration):
"""
Create a TemplateStore instance for a specified configuration.
"""

template_division = configuration.division(section_name="TEMPLATES")

return TemplateStore.from_names(
template_division.base_packages,
cache_dir=template_division.cache_dir,
context=_NoopContext(configuration),
)

@staticmethod
def from_names(template_packages, *, cache_dir=None, context=None):
"""
Create a template store from a list of package names.
"""

assert cache_dir is not None
if context is None:
context = _NoopContext()

packages = _expand_base_packages(template_packages)

return TemplateStore(
packages,
cache_dir=cache_dir,
extra_globals=context,
)


def init_global_templates(runtime_configuration):
"""
Make a TemplateStore available within the active request context.
"""

store = runtime_configuration.context_get("templates")
if store:
return store
store = TemplateStore.from_configuration(runtime_configuration)
runtime_configuration.context_set("templates", store)
return store


def render_html_template(
runtime_configuration, template_name, template_group, template_args
):
"""
Render a template available within the active request context.
"""

store = init_global_templates(runtime_configuration)
template = store.grab_template(template_name, template_group, "html")
bound = store.context.extend(template, template_args)
return bound.render()


class UnknownTemplateError(KeyError):
def __init__(self, template_group, template_name="*"):
super().__init__("%s.%s" % (template_group, template_name))
84 changes: 84 additions & 0 deletions mig/lib/templates/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
#
# --- BEGIN_HEADER ---
#
# templates/__main__ - templates CLI
# Copyright (C) 2003-2026 The MiG Project by the Science HPC Center at UCPH
#
# This file is part of MiG.
#
# MiG is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# MiG is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301,
# USA.
#
# -- END_HEADER ---
#

"""
Template support CLI code.
"""

import sys

from mig.lib.templates import TemplateStore
from mig.shared.conf import get_configuration_object


def warn(message):
print(message, file=sys.stderr, flush=True)


def main(args, _print=print):
configuration = get_configuration_object(
config_file=args.config_file, skip_log=True, disable_auth_log=True
)
template_store = TemplateStore.from_configuration(configuration)

command = args.command
if command == "cache":
templates_division = configuration.division(section_name="TEMPLATES")
_print(templates_division.cache_dir)
elif command == "show":
_print(template_store.list_templates())
elif command == "prime":
primed_count = template_store.prime_templates()
if primed_count == 0:
_print("No templates were specified.")
elif command == "vars":
for template_name, template_group in template_store.list_templates():
_print("<%s.%s>" % (template_group, template_name))
for var in template_store.extract_variables(
template_name, template_group, "html"
):
_print(" {{%s}}" % (var,))
_print("</%s.%s>" % (template_group, template_name))
else:
raise RuntimeError("unknown command: %s" % (command,))


if __name__ == "__main__":
import argparse

parser = argparse.ArgumentParser()
parser.add_argument("-c", dest="config_file", required=True)
parser.add_argument("command")
args = parser.parse_args()

try:
main(args)
sys.exit(0)
except Exception as exc:
warn(str(exc))
sys.exit(1)
Loading
Loading