blob: 7bc0b9831dab3c8a4f868e3629e5c78a5366e723 [file] [log] [blame]
# Copyright 2014 The LUCI Authors. All rights reserved.
# Use of this source code is governed under the Apache License, Version 2.0
# that can be found in the LICENSE file.
"""Setups jinja2 environment to be reused by all components and services."""
import datetime
import os
import urllib
from components import utils
utils.import_jinja2()
import jinja2
from google.appengine.api import app_identity
from google.appengine.api import users
from components import natsort
NON_BREAKING_HYPHEN = u'\u2011'
### Private stuff.
def _datetimeformat(value, f='%Y-%m-%d %H:%M:%S'):
if not value:
return NON_BREAKING_HYPHEN + NON_BREAKING_HYPHEN
return value.strftime(f)
def _succinctdatetimeformat(value, f='%H:%M:%S'):
"""Similar to datetimeformat but skips the dates when it's today."""
assert all(item not in f for item in ('%Y', '%m', '%d')), f
if not value:
return NON_BREAKING_HYPHEN + NON_BREAKING_HYPHEN
if utils.utcnow().date() != value.date():
# Prefix with the day.
f = '%Y-%m-%d ' + f
return value.strftime(f)
def _epochformat(value, f='%Y-%m-%d %H:%M:%S'):
"""Formats a float representing epoch to datetime."""
if not value:
return NON_BREAKING_HYPHEN + NON_BREAKING_HYPHEN
return _datetimeformat(utils.timestamp_to_datetime(value * 1000000), f)
def _natsorted(value):
"""Accepts None transparently."""
return natsort.natsorted(value or [])
def _timedeltaformat(value, include_ms=False):
"""Formats a timedelta in a sane way.
Ignores sub-second precision by default.
"""
if not value:
return NON_BREAKING_HYPHEN + NON_BREAKING_HYPHEN
total_seconds = value.total_seconds()
suffix = ''
if include_ms:
ms = int(round(total_seconds-int(total_seconds), 3) * 1000)
if ms:
suffix = '.%03d' % ms
hours, remainder = divmod(int(round(total_seconds)), 3600)
minutes, seconds = divmod(remainder, 60)
if hours:
return '%d:%02d:%02d%s' % (hours, minutes, seconds, suffix)
# Always prefix minutes, even if 0, otherwise this looks weird. Revisit this
# decision if bikeshedding is desired.
return '%d:%02d%s' % (minutes, seconds, suffix)
def _utf8(s):
"""Decodes an str to unicode."""
if s is None:
return s
return s.decode('utf-8')
# Filters available by default.
_DEFAULT_GLOBAL_FILTERS = {
'datetimeformat': _datetimeformat,
'encode_to_json': utils.encode_to_json,
'epochformat': _epochformat,
'natsort': _natsorted,
'succinctdatetimeformat': _succinctdatetimeformat,
'timedeltaformat': _timedeltaformat,
'utf8': _utf8,
}
# All the templates paths: prefix -> path.
_TEMPLATE_PATHS = {}
# Registered global variables.
_GLOBAL_ENV = {}
# Registered filters.
_GLOBAL_FILTERS = _DEFAULT_GLOBAL_FILTERS.copy()
### Public API.
def bootstrap(paths, global_env=None, filters=None):
"""Resets cached Jinja2 env to pick up new template paths.
This is purely additive and idempotent. So consecutive calls to this functions
with different arguments is fine.
Args:
paths: dict {prefix -> template_dir}, templates under template_dir would be
accessible as <prefix>/<path relative to template_dir>.
global_env: dict with variables to add to global template environment.
filters: dict with filters to add to global filter list.
"""
assert isinstance(paths, dict), paths
assert all(
_TEMPLATE_PATHS.get(k, v) == v for k, v in paths.iteritems()), paths
assert all(os.path.isabs(p) for p in paths.itervalues()), paths
assert all(os.path.isdir(p) for p in paths.itervalues()), paths
if global_env is not None:
assert isinstance(global_env, dict), global_env
assert all(isinstance(k, str) for k in global_env), global_env
assert all(
_GLOBAL_ENV.get(k, v) == v
for k, v in global_env.iteritems()), global_env
if filters is not None:
assert isinstance(filters, dict), filters
assert all(
isinstance(k, str) and callable(v)
for k, v in filters.iteritems()), filters
assert all(
_GLOBAL_FILTERS.get(k, v) == v
for k, v in filters.iteritems()), filters
_TEMPLATE_PATHS.update(paths)
if global_env:
_GLOBAL_ENV.update(global_env)
# These are immutable.
_GLOBAL_ENV.setdefault('app_id', app_identity.get_application_id())
_GLOBAL_ENV.setdefault('app_version', utils.get_app_version())
_GLOBAL_ENV.setdefault('app_revision_url', utils.get_app_revision_url())
if filters:
_GLOBAL_FILTERS.update(filters)
utils.clear_cache(get_jinja_env)
def reset():
"""To be used in tests only."""
global _TEMPLATE_PATHS
global _GLOBAL_ENV
global _GLOBAL_FILTERS
_TEMPLATE_PATHS = {}
_GLOBAL_ENV = {}
_GLOBAL_FILTERS = _DEFAULT_GLOBAL_FILTERS.copy()
utils.clear_cache(get_jinja_env)
@utils.cache
def get_jinja_env():
"""Returns jinja2.Environment object that knows how to render templates."""
# TODO(maruel): Add lstrip_blocks=True when jinja2 2.7 becomes available in
# the GAE SDK.
env = jinja2.Environment(
loader=jinja2.PrefixLoader({
prefix: jinja2.FileSystemLoader(path)
for prefix, path in _TEMPLATE_PATHS.iteritems()
}),
autoescape=True,
extensions=['jinja2.ext.autoescape'],
trim_blocks=True,
undefined=jinja2.StrictUndefined)
env.filters.update(_GLOBAL_FILTERS)
env.globals.update(_GLOBAL_ENV)
return env
def _get_defaults():
"""Returns parameters used by most templates/base.html."""
account = users.get_current_user()
return {
'nickname': account.email() if account else 'Sign in',
'now': utils.utcnow(),
# Hack to enable multi-login.
'signin_link': users.create_login_url('/').replace(
'/accounts/ServiceLogin', '/a/SelectSession', 1),
}
def render(name, params=None):
"""Shorthand to render a template.
It includes default useful parameters used in most templates/base.html.
TODO(maruel): Uniformize this.
"""
data = _get_defaults()
data.update(params or {})
return get_jinja_env().get_template(name).render(data)