diff --git a/.gitignore b/.gitignore
index 3cb78bc1d..910fef556 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
# Byte-compiled / optimized / DLL files
+__jinja__/
__pycache__/
*.py[cod]
*$py.class
diff --git a/mig/assets/templates/.gitkeep b/mig/assets/templates/.gitkeep
new file mode 100644
index 000000000..e69de29bb
diff --git a/mig/lib/__init__.py b/mig/lib/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/mig/lib/templates/__init__.py b/mig/lib/templates/__init__.py
new file mode 100644
index 000000000..92efdc0e6
--- /dev/null
+++ b/mig/lib/templates/__init__.py
@@ -0,0 +1,138 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+#
+# --- BEGIN_HEADER ---
+#
+# base - shared base helper functions
+# Copyright (C) 2003-2024 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 ---
+#
+
+import errno
+from collections import ChainMap
+from jinja2 import meta as jinja2_meta, select_autoescape, Environment, \
+ FileSystemLoader, FileSystemBytecodeCache
+import os
+import weakref
+
+from mig.shared.compat import PY2
+from mig.shared.defaults import MIG_BASE
+
+
+class _BonundTemplate:
+ def __init__(self, template, template_args):
+ self.tmpl = template
+ self.args = template_args
+
+
+ def render(self):
+ return self.tmpl.render(**self.args)
+
+
+class _FormatContext:
+ def __init__(self, configuration):
+ self.output_format = None
+ self.configuration = configuration
+ self.script_map = {}
+ self.style_map = {}
+
+ def __getitem__(self, key):
+ return self.__dict__[key]
+
+ def __iter__(self):
+ return iter(self.__dict__)
+
+ def extend(self, template, template_args):
+ return _BonundTemplate(template, ChainMap(template_args, self))
+
+
+class TemplateStore:
+ def __init__(self, template_dirs, cache_dir=None, extra_globals=None):
+ assert cache_dir is not None
+
+ self._cache_dir = cache_dir
+ self._template_globals = extra_globals
+ self._template_environment = Environment(
+ loader=FileSystemLoader(template_dirs),
+ bytecode_cache=FileSystemBytecodeCache(cache_dir, '%s'),
+ autoescape=select_autoescape()
+ )
+
+ @property
+ def cache_dir(self):
+ return self._cache_dir
+
+ @property
+ def context(self):
+ return self._template_globals
+
+ def _get_template(self, template_fqname):
+ return self._template_environment.get_template(template_fqname)
+
+ def grab_template(self, template_name, template_group, output_format, template_globals=None, **kwargs):
+ template_fqname = "%s_%s.%s.jinja" % (
+ template_group, template_name, output_format)
+ return self._template_environment.get_template(template_fqname, globals=template_globals)
+
+ def list_templates(self):
+ return [t for t in self._template_environment.list_templates() if t.endswith('.jinja')]
+
+ def extract_variables(self, template_fqname):
+ template = self._template_environment.get_template(template_fqname)
+ with open(template.filename) as f:
+ template_source = f.read()
+ ast = self._template_environment.parse(template_source)
+ return jinja2_meta.find_undeclared_variables(ast)
+
+ @staticmethod
+ def populated(template_dirs, cache_dir=None, context=None):
+ assert cache_dir is not None
+
+ try:
+ os.mkdir(cache_dir)
+ except OSError as direxc:
+ if direxc.errno != errno.EEXIST: # FileExistsError
+ raise
+
+ store = TemplateStore(
+ template_dirs, cache_dir=cache_dir, extra_globals=context)
+
+ for template_fqname in store.list_templates():
+ store._get_template(template_fqname)
+
+ return store
+
+
+def init_global_templates(configuration):
+ template_division = configuration.division(section_name="TEMPLATES")
+
+ _context = configuration.context()
+
+ try:
+ return configuration.context(namespace='templates')
+ except KeyError as exc:
+ pass
+
+ store = TemplateStore.populated(
+ [template_division.source_dir],
+ cache_dir=template_division.cache_dir,
+ context=_FormatContext(configuration)
+ )
+ return configuration.context_set(store, namespace='templates')
diff --git a/mig/lib/templates/__main__.py b/mig/lib/templates/__main__.py
new file mode 100644
index 000000000..32453b66d
--- /dev/null
+++ b/mig/lib/templates/__main__.py
@@ -0,0 +1,78 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+#
+# --- BEGIN_HEADER ---
+#
+# base - shared base helper functions
+# Copyright (C) 2003-2024 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 ---
+#
+
+from types import SimpleNamespace
+import os
+import sys
+
+from mig.lib.templates import init_global_templates
+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)
+ template_store = init_global_templates(configuration)
+
+ command = args.command
+ if command == 'show':
+ print(template_store.list_templates())
+ elif command == 'prime':
+ try:
+ os.mkdir(template_store.cache_dir)
+ except FileExistsError:
+ pass
+
+ for template_fqname in template_store.list_templates():
+ template_store._get_template(template_fqname)
+ elif command == 'vars':
+ for template_ref in template_store.list_templates():
+ _print("<%s>" % (template_ref,))
+ for var in template_store.extract_variables(template_ref):
+ _print(" {{%s}}" % (var,))
+ _print("%s>" % (template_ref,))
+ else:
+ raise RuntimeError("unknown command: %s" % (command,))
+
+
+if __name__ == '__main__':
+ import argparse
+
+ parser = argparse.ArgumentParser()
+ parser.add_argument('-c', dest='config_file', default=None)
+ parser.add_argument('command')
+ args = parser.parse_args()
+
+ try:
+ main(args)
+ sys.exit(0)
+ except Exception as exc:
+ warn(str(exc))
+ sys.exit(1)
diff --git a/mig/shared/conf.py b/mig/shared/conf.py
index 0de37b321..32d49852d 100644
--- a/mig/shared/conf.py
+++ b/mig/shared/conf.py
@@ -32,6 +32,7 @@
import os
import sys
+from mig.shared.configuration import RuntimeConfiguration
from mig.shared.defaults import MIG_ENV
from mig.shared.fileio import unpickle
@@ -42,7 +43,6 @@ def get_configuration_object(config_file=None, skip_log=False,
and disable_auth_log arguments are passed on to allow skipping the default
log initialization and disabling auth log for unit tests.
"""
- from mig.shared.configuration import Configuration
if config_file:
_config_file = config_file
elif os.environ.get('MIG_CONF', None):
@@ -63,7 +63,7 @@ def get_configuration_object(config_file=None, skip_log=False,
skip_log = True
disable_auth_log = True
- configuration = Configuration(_config_file, False, skip_log,
+ configuration = RuntimeConfiguration(_config_file, False, skip_log,
disable_auth_log)
return configuration
diff --git a/mig/shared/configuration.py b/mig/shared/configuration.py
index 91a24549a..b817857cd 100644
--- a/mig/shared/configuration.py
+++ b/mig/shared/configuration.py
@@ -37,12 +37,14 @@
import copy
import datetime
import functools
+import inspect
import os
import pwd
import re
import socket
import sys
import time
+from types import SimpleNamespace
# Init future py2/3 compatibility helpers
@@ -59,7 +61,9 @@
# NOTE: protect migrid import from autopep8 reordering
try:
from mig.shared.base import force_native_str
- from mig.shared.defaults import CSRF_MINIMAL, CSRF_WARN, CSRF_MEDIUM, \
+ from mig.shared.base import force_native_str
+ from mig.shared.defaults import MIG_BASE, \
+ CSRF_MINIMAL, CSRF_WARN, CSRF_MEDIUM, \
CSRF_FULL, POLICY_NONE, POLICY_WEAK, POLICY_MEDIUM, POLICY_HIGH, \
POLICY_MODERN, POLICY_CUSTOM, freeze_flavors, cert_field_order, \
default_css_filename, keyword_any, keyword_auto, keyword_all, \
@@ -67,7 +71,7 @@
generic_valid_days, DEFAULT_USER_ID_FORMAT, valid_user_id_formats, \
valid_filter_methods, default_twofactor_auth_apps, \
mig_conf_section_dirname
- from mig.shared.logger import Logger, SYSLOG_GDP
+ from mig.shared.logger import Logger, BareLoggerAdapter, SYSLOG_GDP
from mig.shared.htmlgen import menu_items, vgrid_items
from mig.shared.fileio import read_file, load_json, write_file
except ImportError as ioe:
@@ -179,14 +183,15 @@ def expand_external_sources(logger, val):
return expanded
-def fix_missing(config_file, verbose=True):
- """Add missing configuration options - used by checkconf script"""
+_MARKER_ADMIN_EMAIL = object()
+_MARKER_FQDN = object()
+_MARKER_USER = object()
- config = ConfigParser()
- config.read([config_file])
- fqdn = socket.getfqdn()
- user = os.environ['USER']
+def _generate_fix_missing_definitions():
+ fqdn = _MARKER_FQDN
+ user = _MARKER_USER
+
global_section = {
'enable_server_dist': False,
'auto_add_cert_user': False,
@@ -199,7 +204,7 @@ def fix_missing(config_file, verbose=True):
'auto_add_filter_fields': '',
'server_fqdn': fqdn,
'support_email': '',
- 'admin_email': '%s@%s' % (user, fqdn),
+ 'admin_email': _MARKER_ADMIN_EMAIL,
'admin_list': '',
'ca_fqdn': '',
'ca_smtp': '',
@@ -398,7 +403,14 @@ def fix_missing(config_file, verbose=True):
quota_section = {'backend': 'lustre',
'user_limit': 1024**4,
'vgrid_limit': 1024**4}
- defaults = {
+
+ default_template_source_dir = os.path.join(MIG_BASE, 'mig/assets/templates')
+ default_template_cache_dir = os.path.join(default_template_source_dir, '__jinja__')
+
+ templates_section = {'source_dir': default_template_source_dir,
+ 'cache_dir': default_template_cache_dir}
+
+ return {
'GLOBAL': global_section,
'SCHEDULER': scheduler_section,
'MONITOR': monitor_section,
@@ -406,15 +418,38 @@ def fix_missing(config_file, verbose=True):
'FEASIBILITY': feasibility_section,
'WORKFLOWS': workflows_section,
'QUOTA': quota_section,
+ 'TEMPLATES': templates_section,
+ }
+
+
+_FIX_MISSING_DEFINITIONS = _generate_fix_missing_definitions()
+
+
+def fix_missing(config_file, verbose=False):
+ """Add missing configuration options used by checkconf script"""
+
+ fqdn = socket.getfqdn()
+ user = os.environ['USER']
+
+ _marker_substitutions = {
+ _MARKER_ADMIN_EMAIL: "%s@%s" % (user, fqdn),
+ _MARKER_FQDN: fqdn,
+ _MARKER_USER: user,
}
- for section in defaults:
+
+ config = ConfigParser()
+ config.read([config_file])
+
+ modified = False
+ for (section, settings) in _FIX_MISSING_DEFINITIONS.items():
if not section in config.sections():
config.add_section(section)
- modified = False
- for (section, settings) in defaults.items():
for (option, value) in settings.items():
if not config.has_option(section, option):
+ if value in _marker_substitutions:
+ value = _marker_substitutions[value]
+
if verbose:
print('setting %s->%s to %s' % (section, option,
value))
@@ -434,6 +469,11 @@ def fix_missing(config_file, verbose=True):
fd.close()
+def _only_valid_for_section(candididates_dict, section_name):
+ section_defaults = dict(_FIX_MISSING_DEFINITIONS[section_name])
+ return {k: v for k, v in candididates_dict.items() if k in section_defaults}
+
+
class NativeConfigParser(ConfigParser):
"""Wraps configparser.ConfigParser to force get method to return native
string instead of always returning unicode.
@@ -693,7 +733,6 @@ def get(self, *args, **kwargs):
'logfile': '',
'loglevel': '',
'logger_obj': None,
- 'logger': None,
'gdp_logger_obj': None,
'gdp_logger': None,
'auth_logger_obj': None,
@@ -757,9 +796,19 @@ def __init__(self, config_file, verbose=False, skip_log=False,
# Explicitly init a few helpers hot-plugged and used in ways where it's
# less obvious if they are always guaranteed to already be initialized.
self.default_page = None
+
+ # internal state
+ self._loaded = False
+
+ # logging related
+ self.logger_obj = None
+ self._logger = None
self.auth_logger_obj = None
self.gdp_logger_obj = None
+ # structured
+ self._divisions = {}
+
configuration_options = copy.deepcopy(_CONFIGURATION_DEFAULTS)
for k, v in configuration_options.items():
@@ -770,6 +819,33 @@ def __init__(self, config_file, verbose=False, skip_log=False,
disable_auth_log=disable_auth_log,
_config_file=config_file)
+ @property
+ def logger(self):
+ assert self._logger, "logging attempt prior to logger availability"
+ return self._logger
+
+ @logger.setter
+ def logger(self, logger):
+ """Setter method that correctly sets logger related properties."""
+
+ # attempt to determine what type of objetc we were given - this logic
+ # exists to deal with some fallout from having both logger_obj and
+ # logger properties and which of them should be set
+
+ if inspect.ismethod(getattr(logger, 'reopen', None)):
+ # we have a logger_obj, not a plain logger
+ # record it that way to ensure it could be corrctly reopened where
+ # otherwise refefences to the object that has it may be lost and it
+ # may not occur
+ self.logger_obj = logger
+ self._logger = logger.logger
+ elif inspect.ismethod(getattr(logger, 'info', None)):
+ # we have a bare logger object based on the sanity check
+ self._logger = logger
+ self.logger_obj = BareLoggerAdapter(logger)
+ else:
+ raise AssertionError("attempted assignment of unsupported logger")
+
def reload_config(self, verbose, skip_log=False, disable_auth_log=False,
_config_file=None):
"""Re-read and parse configuration file. Optional skip_log arg
@@ -786,12 +862,6 @@ def reload_config(self, verbose, skip_log=False, disable_auth_log=False,
_config_file = _config_file or self.config_file
assert _config_file is not None
- try:
- if self.logger:
- self.logger.info('reloading configuration and reopening log')
- except:
- pass
-
try:
config_file_is_path = os.path.isfile(_config_file)
except TypeError:
@@ -841,13 +911,17 @@ def reload_config(self, verbose, skip_log=False, disable_auth_log=False,
# reopen or initialize logger
- if self.logger_obj:
+ if self._loaded:
+ self.logger.info('reloading configuration and reopening log')
self.logger_obj.reopen()
else:
- self.logger_obj = Logger(self.loglevel, logfile=self.log_path)
+ self.logger = Logger(self.loglevel, logfile=self.log_path)
+ self.logger.info('loading configuration and opening log')
- logger = self.logger_obj.logger
- self.logger = logger
+ # record that the object has been populated
+ self._loaded = True
+
+ logger = self.logger
# print "logger initialized (level " + logger_obj.loglevel() + ")"
# logger.debug("logger initialized")
@@ -1006,6 +1080,9 @@ def reload_config(self, verbose, skip_log=False, disable_auth_log=False,
pass
raise Exception('Failed to parse configuration: %s' % err)
+ # handle structured sections
+ self.apply_loaded_config_to_division(config, section_name='TEMPLATES')
+
# Remaining options in order of importance - i.e. options needed for
# later parsing must be parsed and set first.
@@ -2812,6 +2889,31 @@ def reload_config(self, verbose, skip_log=False, disable_auth_log=False,
% keyword_all)
self.site_twofactor_mandatory_protos = [keyword_all]
+ def apply_loaded_config_to_division(self, loaded_config, section_name):
+ active_division = self.division(section_name=section_name)
+
+ try:
+ templates_candidates = dict(loaded_config[section_name])
+ templates_overrides = _only_valid_for_section(templates_candidates, section_name=section_name)
+ except KeyError:
+ templates_overrides = {}
+
+ active_division.__dict__.update(templates_overrides)
+
+ self._divisions[section_name] = active_division
+
+
+ def division(self, section_name):
+ if section_name not in _FIX_MISSING_DEFINITIONS:
+ raise NotImplementedError()
+
+ try:
+ return self._divisions[section_name]
+ except KeyError:
+ new_division = SimpleNamespace(**_FIX_MISSING_DEFINITIONS[section_name])
+ self._divisions[section_name] = new_division
+ return new_division
+
def parse_peers(self, peerfile):
# read peer information from peerfile
@@ -2851,6 +2953,47 @@ def parse_peers(self, peerfile):
return peers_dict
+class RuntimeConfiguration(Configuration):
+ """A more specific version of the Configuration which additionally supports
+ the notion of a context.
+
+ Contextual information that is relevant to the duration of a request is
+ required in certain cases e.g. to support templating. Given Configuration
+ objects are threaded into and throough almost all the necessary codepaths
+ to make this information available, they are an attractive place to put
+ this - but a Configuration is currently loaded from static per-site data.
+
+ Resolv this ambiguity with this subclass - a raw Confioguration will
+ continute to represent the static data while a specialised but entirely
+ compatible object is handed to request processing codepaths.
+ """
+
+ def __init__(self, config_file, verbose=False, skip_log=False,
+ disable_auth_log=False):
+ super().__init__(config_file, verbose, skip_log, disable_auth_log)
+ self._context = None
+
+ def context(self, namespace=None):
+ """Retrieve the context or a previously registered namespace.
+ """
+
+ if self._context is None:
+ self._context = {}
+ if namespace is None:
+ return self._context
+ # allow the KeyError to escape if the registered namespace is missing
+ return self._context[namespace]
+
+ def context_set(self, value, namespace=None):
+ """Attach a value as named namespace within the active congifuration.
+ """
+ assert namespace is not None
+
+ context = self.context()
+ context[namespace] = value
+ return value
+
+
if '__main__' == __name__:
conf = Configuration(os.path.expanduser('~/mig/server/MiGserver.conf'),
True)
diff --git a/mig/shared/logger.py b/mig/shared/logger.py
index 5b949fca0..a2244f511 100644
--- a/mig/shared/logger.py
+++ b/mig/shared/logger.py
@@ -67,6 +67,23 @@ def _name_to_format(name):
return formats[name]
+class BareLoggerAdapter:
+ """Small wrapper to adapt an arbitrary bare logger to the MiG Logger API"""
+
+ def __init__(self, logger):
+ self._logger = logger
+
+ @property
+ def logger(self):
+ return self._logger
+
+ def reopen(self):
+ pass
+
+ def shutdown(self):
+ pass
+
+
class SysLogLibHandler(logging.Handler):
"""A logging handler that emits messages to syslog.syslog."""
diff --git a/mig/shared/objecttypes.py b/mig/shared/objecttypes.py
index 038d28ef3..52009cb9f 100644
--- a/mig/shared/objecttypes.py
+++ b/mig/shared/objecttypes.py
@@ -28,9 +28,13 @@
""" Defines valid objecttypes and provides a method to verify if an object is correct """
+from mig.lib.templates import init_global_templates
+
+
start = {'object_type': 'start', 'required': [], 'optional': ['headers'
]}
end = {'object_type': 'end', 'required': [], 'optional': []}
+template = {'object_type': 'template'}
timing_info = {'object_type': 'timing_info', 'required': [],
'optional': []}
title = {'object_type': 'title', 'required': ['text'],
@@ -324,6 +328,7 @@
valid_types_list = [
start,
end,
+ template,
timing_info,
title,
text,
@@ -423,6 +428,8 @@
table_pager,
]
+base_template_required = set(('template_name', 'template_group', 'template_args,'))
+
# valid_types_dict = {"title":title, "link":link, "header":header}
# autogenerate dict based on list. Dictionary access is prefered to allow
@@ -463,8 +470,8 @@ def get_object_type_info(object_type_list):
return out
-def validate(input_object):
- """ validate input_object """
+def validate(input_object, configuration=None):
+ """ validate presented objects against their definitions """
if not type(input_object) == type([]):
return (False, 'validate object must be a list' % ())
@@ -484,6 +491,19 @@ def validate(input_object):
this_object_type = obj['object_type']
valid_object_type = valid_types_dict[this_object_type]
+
+ if this_object_type == 'template':
+ # the required keys stuff below is not applicable to templates
+ # because templates know what they need in terms of data thus
+ # are self-documenting - use this fact to perform validation
+ #template_ref = "%s_%s.html" % (obj['template_group'], )
+ store = init_global_templates(configuration)
+ template = store.grab_template(obj['template_name'], obj['template_group'], 'html')
+ valid_object_type = {
+ 'required': store.extract_variables(template)
+ }
+ obj = obj.get('template_args', None)
+
if 'required' in valid_object_type:
for req in valid_object_type['required']:
if req not in obj:
diff --git a/mig/shared/output.py b/mig/shared/output.py
index 2db2c0ca1..91a66431e 100644
--- a/mig/shared/output.py
+++ b/mig/shared/output.py
@@ -43,6 +43,7 @@
import time
import traceback
+from mig.lib.templates import init_global_templates
from mig.shared import returnvalues
from mig.shared.bailout import bailout_title, crash_helper, \
filter_output_objects
@@ -740,6 +741,15 @@ def html_format(configuration, ret_val, ret_msg, out_obj):
for i in out_obj:
if i['object_type'] == 'start':
pass
+ elif i['object_type'] == 'template':
+ store = init_global_templates(configuration)
+ template = store.grab_template(
+ i['template_name'],
+ i['template_group'],
+ 'html',
+ )
+ bound = store.context.extend(template, i['template_args'])
+ lines.append(bound.render())
elif i['object_type'] == 'error_text':
msg = "%(text)s" % i
if i.get('exc', False):
@@ -2818,7 +2828,7 @@ def format_output(
logger = configuration.logger
#logger.debug("format output to %s" % outputformat)
valid_formats = get_valid_outputformats()
- (val_ret, val_msg) = validate(out_obj)
+ (val_ret, val_msg) = validate(out_obj, configuration)
if not val_ret:
logger.error("%s formatting failed: %s (%s)" %
(outputformat, val_msg, val_ret))
diff --git a/mig/wsgi-bin/migwsgi.py b/mig/wsgi-bin/migwsgi.py
index 073958880..07b1102a8 100755
--- a/mig/wsgi-bin/migwsgi.py
+++ b/mig/wsgi-bin/migwsgi.py
@@ -132,7 +132,7 @@ def stub(configuration, client_id, import_path, backend, user_arguments_dict,
crash_helper(configuration, backend, output_objects)
return (output_objects, returnvalues.ERROR)
- (val_ret, val_msg) = validate(output_objects)
+ (val_ret, val_msg) = validate(output_objects, configuration=configuration)
if not val_ret:
(ret_code, ret_msg) = returnvalues.OUTPUT_VALIDATION_ERROR
bailout_helper(configuration, backend, output_objects,
diff --git a/requirements.txt b/requirements.txt
index 5c2b1bc8f..f04b99f31 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -26,6 +26,10 @@ email-validator;python_version >= "3.7"
email-validator<2.0;python_version >= "3" and python_version < "3.7"
email-validator<1.3;python_version < "3"
+jinja2<3;python_version < "3"
+jinja2==3.0.*;python_version >= "3" and python_version < "3.7"
+jinja2;python_version >= "3.7"
+
# NOTE: additional optional dependencies depending on site conf are listed
# in recommended.txt and can be installed in the same manner by pointing
# pip there.
diff --git a/tests/data/MiGserver--customised.conf b/tests/data/MiGserver--customised.conf
index a39336d73..f5b2960fb 100644
--- a/tests/data/MiGserver--customised.conf
+++ b/tests/data/MiGserver--customised.conf
@@ -806,3 +806,5 @@ logo_right = /images/skin/migrid-basic/logo-right.png
# Optional data safety notice and popup on Files page
datasafety_link =
datasafety_text =
+
+[TEMPLATES]
diff --git a/tests/data/templates/test_other.html.jinja b/tests/data/templates/test_other.html.jinja
new file mode 100644
index 000000000..f22ed0101
--- /dev/null
+++ b/tests/data/templates/test_other.html.jinja
@@ -0,0 +1 @@
+{{ other }}
diff --git a/tests/data/templates/test_something.html.jinja b/tests/data/templates/test_something.html.jinja
new file mode 100644
index 000000000..4c912a491
--- /dev/null
+++ b/tests/data/templates/test_something.html.jinja
@@ -0,0 +1 @@
+
diff --git a/tests/fixture/mig_shared_configuration--new.json b/tests/fixture/mig_shared_configuration--new.json
index 2d4edb02c..54b3d9673 100644
--- a/tests/fixture/mig_shared_configuration--new.json
+++ b/tests/fixture/mig_shared_configuration--new.json
@@ -104,8 +104,6 @@
],
"log_dir": "",
"logfile": "",
- "logger": null,
- "logger_obj": null,
"loglevel": "",
"lrmstypes": [],
"mig_code_base": "",
diff --git a/tests/snapshots/test_objects_with_type_template.html b/tests/snapshots/test_objects_with_type_template.html
new file mode 100644
index 000000000..8eaf5bde5
--- /dev/null
+++ b/tests/snapshots/test_objects_with_type_template.html
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/tests/test_mig_lib_templates.py b/tests/test_mig_lib_templates.py
new file mode 100644
index 000000000..413b4d439
--- /dev/null
+++ b/tests/test_mig_lib_templates.py
@@ -0,0 +1,51 @@
+import os
+import shutil
+
+from tests.support import MigTestCase, testmain, \
+ MIG_BASE, TEST_DATA_DIR, TEST_OUTPUT_DIR
+
+from mig.lib.templates import TemplateStore, init_global_templates
+
+TEST_CACHE_DIR = os.path.join(TEST_OUTPUT_DIR, '__template_cache__')
+TEST_TMPL_DIR = os.path.join(TEST_DATA_DIR, 'templates')
+
+
+class TestMigSharedTemplates_instance(MigTestCase):
+ def after_each(self):
+ shutil.rmtree(TEST_CACHE_DIR, ignore_errors=True)
+
+ def _provide_configuration(self):
+ return 'testconfig'
+
+ def test_the_creation_of_a_template_store(self):
+ store = TemplateStore.populated(TEST_TMPL_DIR, cache_dir=TEST_CACHE_DIR)
+ self.assertIsInstance(store, TemplateStore)
+
+ def test_a_listing_all_templates(self):
+ store = TemplateStore.populated(TEST_TMPL_DIR, cache_dir=TEST_CACHE_DIR)
+ self.assertEqual(len(store.list_templates()), 2)
+
+ def test_grab_template(self):
+ store = TemplateStore.populated(TEST_TMPL_DIR, cache_dir=TEST_CACHE_DIR)
+ template = store.grab_template('other', 'test', 'html')
+ pass
+
+ def test_variables_for_remplate_ref(self):
+ store = TemplateStore.populated(TEST_TMPL_DIR, cache_dir=TEST_CACHE_DIR)
+ template_vars = store.extract_variables('test_something.html.jinja')
+ self.assertEqual(template_vars, set(['content']))
+
+
+class TestMigSharedTemplates_global(MigTestCase):
+ def _provide_configuration(self):
+ return 'testconfig'
+
+ def test_cache_location(self):
+ store = init_global_templates(self.configuration)
+
+ relative_cache_dir = os.path.relpath(store.cache_dir, MIG_BASE)
+ self.assertEqual(relative_cache_dir, 'mig/assets/templates/__jinja__')
+
+
+if __name__ == '__main__':
+ testmain()
diff --git a/tests/test_mig_shared_configuration.py b/tests/test_mig_shared_configuration.py
index 7c9aef06e..64c39b5da 100644
--- a/tests/test_mig_shared_configuration.py
+++ b/tests/test_mig_shared_configuration.py
@@ -31,9 +31,10 @@
import os
import unittest
-from tests.support import MigTestCase, TEST_DATA_DIR, PY2, testmain, \
- fixturefile
+from tests.support import MigTestCase, MIG_BASE, TEST_DATA_DIR, PY2, \
+ testmain, fixturefile
from mig.shared.configuration import Configuration
+from mig.shared.logger import null_logger
def _is_method(value):
@@ -42,7 +43,7 @@ def _is_method(value):
def _to_dict(obj):
return {k: v for k, v in inspect.getmembers(obj)
- if not (k.startswith('__') or _is_method(v))}
+ if not (k.startswith('_') or _is_method(v) or k.startswith('logger'))}
class MigSharedConfiguration(MigTestCase):
@@ -314,12 +315,28 @@ def test_argument_include_sections_multi_ignores_other_sections(self):
# TODO: rename file to valid section name we can check and enable next?
# self.assertEqual(configuration.multi, 'blabla')
+ def test_structured_templates_defaults(self):
+ test_conf_file = os.path.join(
+ TEST_DATA_DIR, 'MiGserver--customised.conf')
+
+ configuration = Configuration(
+ test_conf_file, skip_log=True, disable_auth_log=True)
+
+ division = configuration.division(section_name='TEMPLATES')
+ self.assertEqual(division.__dict__, {
+ 'source_dir': os.path.join(MIG_BASE, 'mig/assets/templates'),
+ 'cache_dir': os.path.join(MIG_BASE, 'mig/assets/templates/__jinja__'),
+ })
+
@unittest.skipIf(PY2, "Python 3 only")
def test_default_object(self):
prepared_fixture = self.prepareFixtureAssert(
'mig_shared_configuration--new', fixture_format='json')
configuration = Configuration(None)
+ # attach a null logger to sidestep the useful logger before available
+ # assertion which would otherwise blow when the object is inspected
+ configuration.logger = null_logger("test_configuration")
# TODO: the following work-around default values set for these on the
# instance that no longer make total sense but fiddling with them
# is better as a follow-up.
diff --git a/tests/test_mig_wsgibin.py b/tests/test_mig_wsgibin.py
index 857d03b99..724331d34 100644
--- a/tests/test_mig_wsgibin.py
+++ b/tests/test_mig_wsgibin.py
@@ -34,7 +34,8 @@
import stat
import sys
-from tests.support import PY2, MIG_BASE, MigTestCase, testmain, is_path_within
+from tests.support import PY2, MIG_BASE, TEST_DATA_DIR, TEST_OUTPUT_DIR, \
+ MigTestCase, testmain
from tests.support.snapshotsupp import SnapshotAssertMixin
from tests.support.wsgisupp import prepare_wsgi, WsgiAssertMixin
@@ -49,6 +50,18 @@
from html.parser import HTMLParser
+def _force_test_templates(configuration):
+ templates_division = configuration.division('TEMPLATES')
+ # overwrite the template source and cache directory to known locations for
+ # the duration of the tests. thyis allows us to control which templates are
+ # available as well as ensures the template cache directory is cleaned out
+ # on test completion as part of the standard output directory cleanup
+ templates_division.__dict__.update({
+ 'source_dir': os.path.join(TEST_DATA_DIR, 'templates'),
+ 'cache_dir': os.path.join(TEST_OUTPUT_DIR, '__jinja__'),
+ })
+
+
class DocumentBasicsHtmlParser(HTMLParser):
"""An HTML parser using builtin machinery to check basic html structure."""
@@ -303,6 +316,33 @@ def test_objects_with_type_text(self):
output, _ = self.assertWsgiResponse(wsgi_result, self.fake_wsgi, 200)
self.assertSnapshotOfHtmlContent(output)
+ def test_objects_with_type_template(self):
+ output_objects = [
+ # workaround invalid HTML being generated with no title object
+ {
+ 'object_type': 'title',
+ 'text': 'TEST'
+ },
+ {
+ 'object_type': 'template',
+ 'template_name': 'something',
+ 'template_group': 'test',
+ 'template_args': {
+ 'content': 'here!!'
+ }
+ }
+ ]
+ self.fake_backend.set_response(output_objects, returnvalues.OK)
+ _force_test_templates(self.configuration)
+
+ wsgi_result = migwsgi.application(
+ *self.application_args,
+ **self.application_kwargs
+ )
+
+ output, _ = self.assertWsgiResponse(wsgi_result, self.fake_wsgi, 200)
+ self.assertSnapshotOfHtmlContent(output)
+
if __name__ == '__main__':
testmain()