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
4 changes: 4 additions & 0 deletions mig/install/MiGserver-template.conf
Original file line number Diff line number Diff line change
Expand Up @@ -785,3 +785,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__
2 changes: 2 additions & 0 deletions mig/install/generateconfs.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
Expand Down Expand Up @@ -218,6 +218,8 @@
'ca_smtp',
'datasafety_link',
'datasafety_text',
'templates_cache_dir',
'templates_base_package',
]
int_names = [
'cert_valid_days',
Expand Down
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_key = "%s-%%s.jinja_cache" % (package_name,)
template_env = Environment(
loader=PackageLoader(package_name),
bytecode_cache=FileSystemBytecodeCache(self._cache_dir, package_cache_key),

Check warning on line 119 in mig/lib/templates/__init__.py

View workflow job for this annotation

GitHub Actions / Style check python and annotate

line too long (87 > 80 characters)
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)

@rasmunk rasmunk Apr 24, 2026

Copy link
Copy Markdown
Contributor

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?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The thought about the templates section, was simply that we would at some point expose the expected template lookup format in the MiGServer.conf template section:

For example as

[TEMPLATES]
base_packages = migux
cache_dir = AUTO
templates_lookup_format = "%s.%s.jinja"

Not that we would customise this initially, but it atleast exposes to the users/admins the expected structure that the base_packages would have to abide by.

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.

Hmmm. I’d be very hesitant to do that - it’s an implementation detail and things will not work if it is changed.

My sense about what you’re really after here is documenting of things like how to lay things out, the format, etc. I’d rather work towards capturing that stuff.

@rasmunk rasmunk Apr 27, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I would say, that in my mind it is more part of the interface specification for introducing templates. That is that for an external package (or internal) that provides templates must abide by this structure to be compatible with how the TemplateStore loads templates. But as hinted at, this is not a ticket item for me that it could be customisable, I would also be just fine if a constant is defined that defines this structure, such as TEMPLATES_LOAD_FORMAT="%s.%s.jinja" or whatever name works best, that we can include in the docs/description of templates and their requirements when they are implemented.

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