Skip to content

Commit 8a9e55b

Browse files
committed
Introduce support for jinja2-based templates.
This addds the necessary logic both to maintain a store of templates and makiung them available for serving within any page via a new template object type. Make sure there is a separation of the test templates from the mainline stuff by wiring a TEMPLATES section into Configuration and reading the necessary paths via the loaded configuration. Doing so allows both source location and the cache directory to be optionally specified via the main ini file and thus are runtime accessible without hard-coding. Use this facility within the tests to adjust the paths.
1 parent 2db321f commit 8a9e55b

17 files changed

Lines changed: 449 additions & 19 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# Byte-compiled / optimized / DLL files
2+
__jinja__/
23
__pycache__/
34
*.py[cod]
45
*$py.class

mig/assets/templates/.gitkeep

Whitespace-only changes.

mig/lib/__init__.py

Whitespace-only changes.

mig/lib/templates/__init__.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
#!/usr/bin/python
2+
# -*- coding: utf-8 -*-
3+
#
4+
# --- BEGIN_HEADER ---
5+
#
6+
# base - shared base helper functions
7+
# Copyright (C) 2003-2024 The MiG Project by the Science HPC Center at UCPH
8+
#
9+
# This file is part of MiG.
10+
#
11+
# MiG is free software: you can redistribute it and/or modify
12+
# it under the terms of the GNU General Public License as published by
13+
# the Free Software Foundation; either version 2 of the License, or
14+
# (at your option) any later version.
15+
#
16+
# MiG is distributed in the hope that it will be useful,
17+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
18+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19+
# GNU General Public License for more details.
20+
#
21+
# You should have received a copy of the GNU General Public License
22+
# along with this program; if not, write to the Free Software
23+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
24+
#
25+
# -- END_HEADER ---
26+
#
27+
28+
import errno
29+
from collections import ChainMap
30+
from jinja2 import meta as jinja2_meta, select_autoescape, Environment, \
31+
FileSystemLoader, FileSystemBytecodeCache
32+
import os
33+
import weakref
34+
35+
from mig.shared.compat import PY2
36+
from mig.shared.defaults import MIG_BASE
37+
38+
39+
class _BonundTemplate:
40+
def __init__(self, template, template_args):
41+
self.tmpl = template
42+
self.args = template_args
43+
44+
45+
def render(self):
46+
return self.tmpl.render(**self.args)
47+
48+
49+
class _FormatContext:
50+
def __init__(self, configuration):
51+
self.output_format = None
52+
self.configuration = configuration
53+
self.script_map = {}
54+
self.style_map = {}
55+
56+
def __getitem__(self, key):
57+
return self.__dict__[key]
58+
59+
def __iter__(self):
60+
return iter(self.__dict__)
61+
62+
def extend(self, template, template_args):
63+
return _BonundTemplate(template, ChainMap(template_args, self))
64+
65+
66+
class TemplateStore:
67+
def __init__(self, template_dirs, cache_dir=None, extra_globals=None):
68+
assert cache_dir is not None
69+
70+
self._cache_dir = cache_dir
71+
self._template_globals = extra_globals
72+
self._template_environment = Environment(
73+
loader=FileSystemLoader(template_dirs),
74+
bytecode_cache=FileSystemBytecodeCache(cache_dir, '%s'),
75+
autoescape=select_autoescape()
76+
)
77+
78+
@property
79+
def cache_dir(self):
80+
return self._cache_dir
81+
82+
@property
83+
def context(self):
84+
return self._template_globals
85+
86+
def _get_template(self, template_fqname):
87+
return self._template_environment.get_template(template_fqname)
88+
89+
def grab_template(self, template_name, template_group, output_format, template_globals=None, **kwargs):
90+
template_fqname = "%s_%s.%s.jinja" % (
91+
template_group, template_name, output_format)
92+
return self._template_environment.get_template(template_fqname, globals=template_globals)
93+
94+
def list_templates(self):
95+
return [t for t in self._template_environment.list_templates() if t.endswith('.jinja')]
96+
97+
def extract_variables(self, template_fqname):
98+
template = self._template_environment.get_template(template_fqname)
99+
with open(template.filename) as f:
100+
template_source = f.read()
101+
ast = self._template_environment.parse(template_source)
102+
return jinja2_meta.find_undeclared_variables(ast)
103+
104+
@staticmethod
105+
def populated(template_dirs, cache_dir=None, context=None):
106+
assert cache_dir is not None
107+
108+
try:
109+
os.mkdir(cache_dir)
110+
except OSError as direxc:
111+
if direxc.errno != errno.EEXIST: # FileExistsError
112+
raise
113+
114+
store = TemplateStore(
115+
template_dirs, cache_dir=cache_dir, extra_globals=context)
116+
117+
for template_fqname in store.list_templates():
118+
store._get_template(template_fqname)
119+
120+
return store
121+
122+
123+
def init_global_templates(configuration):
124+
template_division = configuration.division(section_name="TEMPLATES")
125+
126+
_context = configuration.context()
127+
128+
try:
129+
return configuration.context(namespace='templates')
130+
except KeyError as exc:
131+
pass
132+
133+
store = TemplateStore.populated(
134+
[template_division.source_dir],
135+
cache_dir=template_division.cache_dir,
136+
context=_FormatContext(configuration)
137+
)
138+
return configuration.context_set(store, namespace='templates')

mig/lib/templates/__main__.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
#!/usr/bin/python
2+
# -*- coding: utf-8 -*-
3+
#
4+
# --- BEGIN_HEADER ---
5+
#
6+
# base - shared base helper functions
7+
# Copyright (C) 2003-2024 The MiG Project by the Science HPC Center at UCPH
8+
#
9+
# This file is part of MiG.
10+
#
11+
# MiG is free software: you can redistribute it and/or modify
12+
# it under the terms of the GNU General Public License as published by
13+
# the Free Software Foundation; either version 2 of the License, or
14+
# (at your option) any later version.
15+
#
16+
# MiG is distributed in the hope that it will be useful,
17+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
18+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19+
# GNU General Public License for more details.
20+
#
21+
# You should have received a copy of the GNU General Public License
22+
# along with this program; if not, write to the Free Software
23+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
24+
#
25+
# -- END_HEADER ---
26+
#
27+
28+
from types import SimpleNamespace
29+
import os
30+
import sys
31+
32+
from mig.lib.templates import init_global_templates
33+
from mig.shared.conf import get_configuration_object
34+
35+
36+
def warn(message):
37+
print(message, file=sys.stderr, flush=True)
38+
39+
40+
def main(args, _print=print):
41+
configuration = get_configuration_object(config_file=args.config_file)
42+
template_store = init_global_templates(configuration)
43+
44+
command = args.command
45+
if command == 'show':
46+
print(template_store.list_templates())
47+
elif command == 'prime':
48+
try:
49+
os.mkdir(template_store.cache_dir)
50+
except FileExistsError:
51+
pass
52+
53+
for template_fqname in template_store.list_templates():
54+
template_store._get_template(template_fqname)
55+
elif command == 'vars':
56+
for template_ref in template_store.list_templates():
57+
_print("<%s>" % (template_ref,))
58+
for var in template_store.extract_variables(template_ref):
59+
_print(" {{%s}}" % (var,))
60+
_print("</%s>" % (template_ref,))
61+
else:
62+
raise RuntimeError("unknown command: %s" % (command,))
63+
64+
65+
if __name__ == '__main__':
66+
import argparse
67+
68+
parser = argparse.ArgumentParser()
69+
parser.add_argument('-c', dest='config_file', default=None)
70+
parser.add_argument('command')
71+
args = parser.parse_args()
72+
73+
try:
74+
main(args)
75+
sys.exit(0)
76+
except Exception as exc:
77+
warn(str(exc))
78+
sys.exit(1)

mig/shared/configuration.py

Lines changed: 82 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
import socket
4545
import sys
4646
import time
47+
from types import SimpleNamespace
4748

4849
# Init future py2/3 compatibility helpers
4950

@@ -60,7 +61,9 @@
6061
# NOTE: protect migrid import from autopep8 reordering
6162
try:
6263
from mig.shared.base import force_native_str
63-
from mig.shared.defaults import CSRF_MINIMAL, CSRF_WARN, CSRF_MEDIUM, \
64+
from mig.shared.base import force_native_str
65+
from mig.shared.defaults import MIG_BASE, \
66+
CSRF_MINIMAL, CSRF_WARN, CSRF_MEDIUM, \
6467
CSRF_FULL, POLICY_NONE, POLICY_WEAK, POLICY_MEDIUM, POLICY_HIGH, \
6568
POLICY_MODERN, POLICY_CUSTOM, freeze_flavors, cert_field_order, \
6669
default_css_filename, keyword_any, keyword_auto, keyword_all, \
@@ -180,14 +183,15 @@ def expand_external_sources(logger, val):
180183
return expanded
181184

182185

183-
def fix_missing(config_file, verbose=True):
184-
"""Add missing configuration options - used by checkconf script"""
186+
_MARKER_ADMIN_EMAIL = object()
187+
_MARKER_FQDN = object()
188+
_MARKER_USER = object()
185189

186-
config = ConfigParser()
187-
config.read([config_file])
188190

189-
fqdn = socket.getfqdn()
190-
user = os.environ['USER']
191+
def _generate_fix_missing_definitions():
192+
fqdn = _MARKER_FQDN
193+
user = _MARKER_USER
194+
191195
global_section = {
192196
'enable_server_dist': False,
193197
'auto_add_cert_user': False,
@@ -200,7 +204,7 @@ def fix_missing(config_file, verbose=True):
200204
'auto_add_filter_fields': '',
201205
'server_fqdn': fqdn,
202206
'support_email': '',
203-
'admin_email': '%s@%s' % (user, fqdn),
207+
'admin_email': _MARKER_ADMIN_EMAIL,
204208
'admin_list': '',
205209
'ca_fqdn': '',
206210
'ca_smtp': '',
@@ -399,23 +403,53 @@ def fix_missing(config_file, verbose=True):
399403
quota_section = {'backend': 'lustre',
400404
'user_limit': 1024**4,
401405
'vgrid_limit': 1024**4}
402-
defaults = {
406+
407+
default_template_source_dir = os.path.join(MIG_BASE, 'mig/assets/templates')
408+
default_template_cache_dir = os.path.join(default_template_source_dir, '__jinja__')
409+
410+
templates_section = {'source_dir': default_template_source_dir,
411+
'cache_dir': default_template_cache_dir}
412+
413+
return {
403414
'GLOBAL': global_section,
404415
'SCHEDULER': scheduler_section,
405416
'MONITOR': monitor_section,
406417
'SETTINGS': settings_section,
407418
'FEASIBILITY': feasibility_section,
408419
'WORKFLOWS': workflows_section,
409420
'QUOTA': quota_section,
421+
'TEMPLATES': templates_section,
410422
}
411-
for section in defaults:
423+
424+
425+
_FIX_MISSING_DEFINITIONS = _generate_fix_missing_definitions()
426+
427+
428+
def fix_missing():
429+
"""Add missing configuration options used by checkconf script"""
430+
431+
fqdn = socket.getfqdn()
432+
user = os.environ['USER']
433+
434+
_marker_substitutions = {
435+
_MARKER_ADMIN_EMAIL: "%s@%s" % (user, fqdn),
436+
_MARKER_FQDN: fqdn,
437+
_MARKER_USER: user,
438+
}
439+
440+
config = ConfigParser()
441+
config.read([config_file])
442+
443+
modified = False
444+
for (section, settings) in _FIX_MISSING_DEFINITIONS.items():
412445
if not section in config.sections():
413446
config.add_section(section)
414447

415-
modified = False
416-
for (section, settings) in defaults.items():
417448
for (option, value) in settings.items():
418449
if not config.has_option(section, option):
450+
if value in _marker_substitutions:
451+
value = _marker_substitutions[value]
452+
419453
if verbose:
420454
print('setting %s->%s to %s' % (section, option,
421455
value))
@@ -435,6 +469,11 @@ def fix_missing(config_file, verbose=True):
435469
fd.close()
436470

437471

472+
def _only_valid_for_section(candididates_dict, /, section_name):
473+
section_defaults = dict(_FIX_MISSING_DEFINITIONS[section_name])
474+
return {k: v for k, v in candididates_dict.items() if k in section_defaults}
475+
476+
438477
class NativeConfigParser(ConfigParser):
439478
"""Wraps configparser.ConfigParser to force get method to return native
440479
string instead of always returning unicode.
@@ -767,6 +806,9 @@ def __init__(self, config_file, verbose=False, skip_log=False,
767806
self.auth_logger_obj = None
768807
self.gdp_logger_obj = None
769808

809+
# structured
810+
self._divisions = {}
811+
770812
configuration_options = copy.deepcopy(_CONFIGURATION_DEFAULTS)
771813

772814
for k, v in configuration_options.items():
@@ -1038,6 +1080,9 @@ def reload_config(self, verbose, skip_log=False, disable_auth_log=False,
10381080
pass
10391081
raise Exception('Failed to parse configuration: %s' % err)
10401082

1083+
# handle structured sections
1084+
self.apply_loaded_config_to_division(config, section_name='TEMPLATES')
1085+
10411086
# Remaining options in order of importance - i.e. options needed for
10421087
# later parsing must be parsed and set first.
10431088

@@ -2844,6 +2889,31 @@ def reload_config(self, verbose, skip_log=False, disable_auth_log=False,
28442889
% keyword_all)
28452890
self.site_twofactor_mandatory_protos = [keyword_all]
28462891

2892+
def apply_loaded_config_to_division(self, loaded_config, /, section_name):
2893+
active_division = self.division(section_name=section_name)
2894+
2895+
try:
2896+
templates_candidates = dict(loaded_config[section_name])
2897+
templates_overrides = _only_valid_for_section(templates_candidates, section_name=section_name)
2898+
except KeyError:
2899+
templates_overrides = {}
2900+
2901+
active_division.__dict__.update(templates_overrides)
2902+
2903+
self._divisions[section_name] = active_division
2904+
2905+
2906+
def division(self, /, section_name):
2907+
if section_name not in _FIX_MISSING_DEFINITIONS:
2908+
raise NotImplementedError()
2909+
2910+
try:
2911+
return self._divisions[section_name]
2912+
except KeyError:
2913+
new_division = SimpleNamespace(**_FIX_MISSING_DEFINITIONS[section_name])
2914+
self._divisions[section_name] = new_division
2915+
return new_division
2916+
28472917
def parse_peers(self, peerfile):
28482918

28492919
# read peer information from peerfile

0 commit comments

Comments
 (0)