Mercurial > libervia-backend
diff libervia/backend/tools/common/template.py @ 4071:4b842c1fb686
refactoring: renamed `sat` package to `libervia.backend`
author | Goffi <goffi@goffi.org> |
---|---|
date | Fri, 02 Jun 2023 11:49:51 +0200 |
parents | sat/tools/common/template.py@524856bd7b19 |
children | 47401850dec6 |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/backend/tools/common/template.py Fri Jun 02 11:49:51 2023 +0200 @@ -0,0 +1,1064 @@ +#!/usr/bin/env python3 + +# SAT: an XMPP client +# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +"""Template generation""" + +import os.path +import time +import re +import json +from datetime import datetime +from pathlib import Path +from collections import namedtuple +from typing import Optional, List, Tuple, Union +from xml.sax.saxutils import quoteattr +from babel import support +from babel import Locale +from babel.core import UnknownLocaleError +import pygments +from pygments import lexers +from pygments import formatters +from libervia.backend.core.constants import Const as C +from libervia.backend.core.i18n import _ +from libervia.backend.core import exceptions +from libervia.backend.tools import config +from libervia.backend.tools.common import date_utils +from libervia.backend.core.log import getLogger + +log = getLogger(__name__) + +try: + import sat_templates +except ImportError: + raise exceptions.MissingModule( + "sat_templates module is not available, please install it or check your path to " + "use template engine" + ) +else: + sat_templates # to avoid pyflakes warning + +try: + import jinja2 +except: + raise exceptions.MissingModule( + "Missing module jinja2, please install it from http://jinja.pocoo.org or with " + "pip install jinja2" + ) + +from lxml import etree +from jinja2 import Markup as safe +from jinja2 import is_undefined +from jinja2 import utils +from jinja2 import TemplateNotFound +from jinja2 import contextfilter +from jinja2.loaders import split_template_path + +HTML_EXT = ("html", "xhtml") +RE_ATTR_ESCAPE = re.compile(r"[^a-z_-]") +SITE_RESERVED_NAMES = ("sat",) +TPL_RESERVED_CHARS = r"()/." +RE_TPL_RESERVED_CHARS = re.compile("[" + TPL_RESERVED_CHARS + "]") +BROWSER_DIR = "_browser" +BROWSER_META_FILE = "browser_meta.json" + +TemplateData = namedtuple("TemplateData", ['site', 'theme', 'path']) + + +class TemplateLoader(jinja2.BaseLoader): + """A template loader which handle site, theme and absolute paths""" + # TODO: list_templates should be implemented + + def __init__(self, sites_paths, sites_themes, trusted=False): + """ + @param trusted(bool): if True, absolue template paths will be allowed + be careful when using this option and sure that you can trust the template, + as this allow the template to open any file on the system that the + launching user can access. + """ + if not sites_paths or not "" in sites_paths: + raise exceptions.InternalError("Invalid sites_paths") + super(jinja2.BaseLoader, self).__init__() + self.sites_paths = sites_paths + self.sites_themes = sites_themes + self.trusted = trusted + + @staticmethod + def parse_template(template): + """Parse template path and return site, theme and path + + @param template_path(unicode): path to template with parenthesis syntax + The site and/or theme can be specified in parenthesis just before the path + e.g.: (some_theme)path/to/template.html + (/some_theme)path/to/template.html (equivalent to previous one) + (other_site/other_theme)path/to/template.html + (other_site/)path/to/template.html (defaut theme for other_site) + /absolute/path/to/template.html (in trusted environment only) + @return (TemplateData): + site, theme and template_path. + if site is empty, SàT Templates are used + site and theme can be both None if absolute path is used + Relative path is the path from theme root dir e.g. blog/articles.html + """ + if template.startswith("("): + # site and/or theme are specified + try: + theme_end = template.index(")") + except IndexError: + raise ValueError("incorrect site/theme in template") + theme_data = template[1:theme_end] + theme_splitted = theme_data.split('/') + if len(theme_splitted) == 1: + site, theme = "", theme_splitted[0] + elif len(theme_splitted) == 2: + site, theme = theme_splitted + else: + raise ValueError("incorrect site/theme in template") + template_path = template[theme_end+1:] + if not template_path or template_path.startswith("/"): + raise ValueError("incorrect template path") + elif template.startswith("/"): + # this is an absolute path, so we have no site and no theme + site = None + theme = None + template_path = template + else: + # a default template + site = "" + theme = C.TEMPLATE_THEME_DEFAULT + template_path = template + + if site is not None: + site = site.strip() + if not site: + site = "" + elif site in SITE_RESERVED_NAMES: + raise ValueError(_("{site} can't be used as site name, " + "it's reserved.").format(site=site)) + + if theme is not None: + theme = theme.strip() + if not theme: + theme = C.TEMPLATE_THEME_DEFAULT + if RE_TPL_RESERVED_CHARS.search(theme): + raise ValueError(_("{theme} contain forbidden char. Following chars " + "are forbidden: {reserved}").format( + theme=theme, reserved=TPL_RESERVED_CHARS)) + + return TemplateData(site, theme, template_path) + + @staticmethod + def get_sites_and_themes( + site: str, + theme: str, + settings: Optional[dict] = None, + ) -> List[Tuple[str, str]]: + """Get sites and themes to check for template/file + + Will add default theme and default site in search list when suitable. Settings' + `fallback` can be used to modify behaviour: themes in this list will then be used + instead of default (it can also be empty list or None, in which case no fallback + is used). + + @param site: site requested + @param theme: theme requested + @return: site and theme couples to check + """ + if settings is None: + settings = {} + sites_and_themes = [[site, theme]] + fallback = settings.get("fallback", [C.TEMPLATE_THEME_DEFAULT]) + for fb_theme in fallback: + if theme != fb_theme: + sites_and_themes.append([site, fb_theme]) + if site: + for fb_theme in fallback: + sites_and_themes.append(["", fb_theme]) + return sites_and_themes + + def _get_template_f(self, site, theme, path_elts): + """Look for template and return opened file if found + + @param site(unicode): names of site to check + (default site will also checked) + @param theme(unicode): theme to check (default theme will also be checked) + @param path_elts(iterable[str]): elements of template path + @return (tuple[(File, None), (str, None)]): a tuple with: + - opened template, or None if not found + - absolute file path, or None if not found + """ + if site is None: + raise exceptions.InternalError( + "_get_template_f must not be used with absolute path") + settings = self.sites_themes[site][theme]['settings'] + for site_to_check, theme_to_check in self.get_sites_and_themes( + site, theme, settings): + try: + base_path = self.sites_paths[site_to_check] + except KeyError: + log.warning(_("Unregistered site requested: {site_to_check}").format( + site_to_check=site_to_check)) + filepath = os.path.join( + base_path, + C.TEMPLATE_TPL_DIR, + theme_to_check, + *path_elts + ) + f = utils.open_if_exists(filepath, 'r') + if f is not None: + return f, filepath + return None, None + + def get_source(self, environment, template): + """Retrieve source handling site and themes + + If the path is absolute it is used directly if in trusted environment + else and exception is raised. + if the path is just relative, "default" theme is used. + @raise PermissionError: absolute path used in untrusted environment + """ + site, theme, template_path = self.parse_template(template) + + if site is None: + # we have an abolute template + if theme is not None: + raise exceptions.InternalError("We can't have a theme with absolute " + "template.") + if not self.trusted: + log.error(_("Absolute template used while unsecure is disabled, hack " + "attempt? Template: {template}").format(template=template)) + raise exceptions.PermissionError("absolute template is not allowed") + filepath = template_path + f = utils.open_if_exists(filepath, 'r') + else: + # relative path, we have to deal with site and theme + assert theme and template_path + path_elts = split_template_path(template_path) + # if we have non default site, we check it first, else we only check default + f, filepath = self._get_template_f(site, theme, path_elts) + + if f is None: + if (site is not None and path_elts[0] == "error" + and os.path.splitext(template_path)[1][1:] in HTML_EXT): + # if an HTML error is requested but doesn't exist, we try again + # with base error. + f, filepath = self._get_template_f( + site, theme, ("error", "base.html")) + if f is None: + raise exceptions.InternalError("error/base.html should exist") + else: + raise TemplateNotFound(template) + + try: + contents = f.read() + finally: + f.close() + + mtime = os.path.getmtime(filepath) + + def uptodate(): + try: + return os.path.getmtime(filepath) == mtime + except OSError: + return False + + return contents, filepath, uptodate + + +class Indexer(object): + """Index global to a page""" + + def __init__(self): + self._indexes = {} + + def next(self, value): + if value not in self._indexes: + self._indexes[value] = 0 + return 0 + self._indexes[value] += 1 + return self._indexes[value] + + def current(self, value): + return self._indexes.get(value) + + +class ScriptsHandler(object): + def __init__(self, renderer, template_data): + self.renderer = renderer + self.template_data = template_data + self.scripts = [] # we don't use a set because order may be important + + def include(self, library_name, attribute="defer"): + """Mark that a script need to be imported. + + Must be used before base.html is extended, as <script> are generated there. + If called several time with the same library, it will be imported once. + @param library_name(unicode): name of the library to import + @param loading: + """ + if attribute not in ("defer", "async", ""): + raise exceptions.DataError( + _('Invalid attribute, please use one of "defer", "async" or ""') + ) + if not library_name.endswith(".js"): + library_name = library_name + ".js" + if (library_name, attribute) not in self.scripts: + self.scripts.append((library_name, attribute)) + return "" + + def generate_scripts(self): + """Generate the <script> elements + + @return (unicode): <scripts> HTML tags + """ + scripts = [] + tpl = "<script src={src} {attribute}></script>" + for library, attribute in self.scripts: + library_path = self.renderer.get_static_path(self.template_data, library) + if library_path is None: + log.warning(_("Can't find {libary} javascript library").format( + library=library)) + continue + path = self.renderer.get_front_url(library_path) + scripts.append(tpl.format(src=quoteattr(path), attribute=attribute)) + return safe("\n".join(scripts)) + + +class Environment(jinja2.Environment): + + def get_template(self, name, parent=None, globals=None): + if name[0] not in ('/', '('): + # if name is not an absolute path or a full template name (this happen on + # extend or import during rendering), we convert it to a full template name. + # This is needed to handle cache correctly when a base template is overriden. + # Without that, we could not distinguish something like base/base.html if + # it's launched from some_site/some_theme or from [default]/default + name = "({site}/{theme}){template}".format( + site=self._template_data.site, + theme=self._template_data.theme, + template=name) + + return super(Environment, self).get_template(name, parent, globals) + + +class Renderer(object): + + def __init__(self, host, front_url_filter=None, trusted=False, private=False): + """ + @param front_url_filter(callable): filter to retrieve real url of a directory/file + The callable will get a two arguments: + - a dict with a "template_data" key containing TemplateData instance of + current template. Only site and theme should be necessary. + - the relative URL of the file to retrieve, relative from theme root + None to use default filter which return real path on file + Need to be specified for web rendering, to reflect URL seen by end user + @param trusted(bool): if True, allow to access absolute path + Only set to True if environment is safe (e.g. command line tool) + @param private(bool): if True, also load sites from sites_path_private_dict + """ + self.host = host + self.trusted = trusted + self.sites_paths = { + "": os.path.dirname(sat_templates.__file__), + } + self.sites_themes = { + } + conf = config.parse_main_conf() + public_sites = config.config_get(conf, None, "sites_path_public_dict", {}) + sites_data = [public_sites] + if private: + private_sites = config.config_get(conf, None, "sites_path_private_dict", {}) + sites_data.append(private_sites) + for sites in sites_data: + normalised = {} + for name, path in sites.items(): + if RE_TPL_RESERVED_CHARS.search(name): + log.warning(_("Can't add \"{name}\" site, it contains forbidden " + "characters. Forbidden characters are {forbidden}.") + .format(name=name, forbidden=TPL_RESERVED_CHARS)) + continue + path = os.path.expanduser(os.path.normpath(path)) + if not path or not path.startswith("/"): + log.warning(_("Can't add \"{name}\" site, it should map to an " + "absolute path").format(name=name)) + continue + normalised[name] = path + self.sites_paths.update(normalised) + + for site, site_path in self.sites_paths.items(): + tpl_path = Path(site_path) / C.TEMPLATE_TPL_DIR + for p in tpl_path.iterdir(): + if not p.is_dir(): + continue + log.debug(f"theme found for {site or 'default site'}: {p.name}") + theme_data = self.sites_themes.setdefault(site, {})[p.name] = { + 'path': p, + 'settings': {}} + theme_settings = p / "settings.json" + if theme_settings.is_file: + try: + with theme_settings.open() as f: + settings = json.load(f) + except Exception as e: + log.warning(_( + "Can't load theme settings at {path}: {e}").format( + path=theme_settings, e=e)) + else: + log.debug( + f"found settings for theme {p.name!r} at {theme_settings}") + fallback = settings.get("fallback") + if fallback is None: + settings["fallback"] = [] + elif isinstance(fallback, str): + settings["fallback"] = [fallback] + elif not isinstance(fallback, list): + raise ValueError( + 'incorrect type for "fallback" in settings ' + f'({type(fallback)}) at {theme_settings}: {fallback}' + ) + theme_data['settings'] = settings + browser_path = p / BROWSER_DIR + if browser_path.is_dir(): + theme_data['browser_path'] = browser_path + browser_meta_path = browser_path / BROWSER_META_FILE + if browser_meta_path.is_file(): + try: + with browser_meta_path.open() as f: + theme_data['browser_meta'] = json.load(f) + except Exception as e: + log.error( + f"Can't parse browser metadata at {browser_meta_path}: {e}" + ) + continue + + self.env = Environment( + loader=TemplateLoader( + sites_paths=self.sites_paths, + sites_themes=self.sites_themes, + trusted=trusted + ), + autoescape=jinja2.select_autoescape(["html", "xhtml", "xml"]), + trim_blocks=True, + lstrip_blocks=True, + extensions=["jinja2.ext.i18n"], + ) + self.env._template_data = None + self._locale_str = C.DEFAULT_LOCALE + self._locale = Locale.parse(self._locale_str) + self.install_translations() + + # we want to have access to SàT constants in templates + self.env.globals["C"] = C + + # custom filters + self.env.filters["next_gidx"] = self._next_gidx + self.env.filters["cur_gidx"] = self._cur_gidx + self.env.filters["date_fmt"] = self._date_fmt + self.env.filters["timestamp_to_hour"] = self._timestamp_to_hour + self.env.filters["delta_to_human"] = date_utils.delta2human + self.env.filters["xmlui_class"] = self._xmlui_class + self.env.filters["attr_escape"] = self.attr_escape + self.env.filters["item_filter"] = self._item_filter + self.env.filters["adv_format"] = self._adv_format + self.env.filters["dict_ext"] = self._dict_ext + self.env.filters["highlight"] = self.highlight + self.env.filters["front_url"] = (self._front_url if front_url_filter is None + else front_url_filter) + # custom tests + self.env.tests["in_the_past"] = self._in_the_past + self.icons_path = os.path.join(host.media_dir, "fonts/fontello/svg") + + # policies + self.env.policies["ext.i18n.trimmed"] = True + self.env.policies["json.dumps_kwargs"] = { + "sort_keys": True, + # if object can't be serialised, we use None + "default": lambda o: o.to_json() if hasattr(o, "to_json") else None + } + + def get_front_url(self, template_data, path=None): + """Give front URL (i.e. URL seen by end-user) of a path + + @param template_data[TemplateData]: data of current template + @param path(unicode, None): relative path of file to get, + if set, will remplate template_data.path + """ + return self.env.filters["front_url"]({"template_data": template_data}, + path or template_data.path) + + def install_translations(self): + # TODO: support multi translation + # for now, only translations in sat_templates are handled + self.translations = {} + for site_key, site_path in self.sites_paths.items(): + site_prefix = "[{}] ".format(site_key) if site_key else '' + i18n_dir = os.path.join(site_path, "i18n") + for lang_dir in os.listdir(i18n_dir): + lang_path = os.path.join(i18n_dir, lang_dir) + if not os.path.isdir(lang_path): + continue + po_path = os.path.join(lang_path, "LC_MESSAGES/sat.mo") + try: + locale = Locale.parse(lang_dir) + with open(po_path, "rb") as f: + try: + translations = self.translations[locale] + except KeyError: + self.translations[locale] = support.Translations(f, "sat") + else: + translations.merge(support.Translations(f, "sat")) + except EnvironmentError: + log.error( + _("Can't find template translation at {path}").format( + path=po_path)) + except UnknownLocaleError as e: + log.error(_("{site}Invalid locale name: {msg}").format( + site=site_prefix, msg=e)) + else: + log.info(_("{site}loaded {lang} templates translations").format( + site = site_prefix, + lang=lang_dir)) + + default_locale = Locale.parse(self._locale_str) + if default_locale not in self.translations: + # default locale disable gettext, + # so we can use None instead of a Translations instance + self.translations[default_locale] = None + + self.env.install_null_translations(True) + # we generate a tuple of locales ordered by display name that templates can access + # through the "locales" variable + self.locales = tuple(sorted(list(self.translations.keys()), + key=lambda l: l.language_name.lower())) + + + def set_locale(self, locale_str): + """set current locale + + change current translation locale and self self._locale and self._locale_str + """ + if locale_str == self._locale_str: + return + if locale_str == "en": + # we default to GB English when it's not specified + # one of the main reason is to avoid the nonsense U.S. short date format + locale_str = "en_GB" + try: + locale = Locale.parse(locale_str) + except ValueError as e: + log.warning(_("invalid locale value: {msg}").format(msg=e)) + locale_str = self._locale_str = C.DEFAULT_LOCALE + locale = Locale.parse(locale_str) + + locale_str = str(locale) + if locale_str != C.DEFAULT_LOCALE: + try: + translations = self.translations[locale] + except KeyError: + log.warning(_("Can't find locale {locale}".format(locale=locale))) + locale_str = C.DEFAULT_LOCALE + locale = Locale.parse(self._locale_str) + else: + self.env.install_gettext_translations(translations, True) + log.debug(_("Switched to {lang}").format(lang=locale.english_name)) + + if locale_str == C.DEFAULT_LOCALE: + self.env.install_null_translations(True) + + self._locale = locale + self._locale_str = locale_str + + def get_theme_and_root(self, template): + """retrieve theme and root dir of a given template + + @param template(unicode): template to parse + @return (tuple[unicode, unicode]): theme and absolute path to theme's root dir + @raise NotFound: requested site has not been found + """ + # FIXME: check use in jp, and include site + site, theme, __ = self.env.loader.parse_template(template) + if site is None: + # absolute template + return "", os.path.dirname(template) + try: + site_root_dir = self.sites_paths[site] + except KeyError: + raise exceptions.NotFound + return theme, os.path.join(site_root_dir, C.TEMPLATE_TPL_DIR, theme) + + def get_themes_data(self, site_name): + try: + return self.sites_themes[site_name] + except KeyError: + raise exceptions.NotFound(f"no theme found for {site_name}") + + def get_static_path( + self, + template_data: TemplateData, + filename: str, + settings: Optional[dict]=None + ) -> Optional[TemplateData]: + """Retrieve path of a static file if it exists with current theme or default + + File will be looked at <site_root_dir>/<theme_dir>/<static_dir>/filename, + then <site_root_dir>/<default_theme_dir>/<static_dir>/filename anf finally + <default_site>/<default_theme_dir>/<static_dir> (i.e. sat_templates). + In case of absolute URL, base dir of template is used as base. For instance if + template is an absolute template to "/some/path/template.html", file will be + checked at "/some/path/<filename>" + @param template_data: data of current template + @param filename: name of the file to retrieve + @param settings: theme settings, can be used to modify behaviour + @return: built template data instance where .path is + the relative path to the file, from theme root dir. + None if not found. + """ + if template_data.site is None: + # we have an absolue path + if (not template_data.theme is None + or not template_data.path.startswith('/')): + raise exceptions.InternalError( + "invalid template data, was expecting absolute URL") + static_dir = os.path.dirname(template_data.path) + file_path = os.path.join(static_dir, filename) + if os.path.exists(file_path): + return TemplateData(site=None, theme=None, path=file_path) + else: + return None + + sites_and_themes = TemplateLoader.get_sites_and_themes(template_data.site, + template_data.theme, + settings) + for site, theme in sites_and_themes: + site_root_dir = self.sites_paths[site] + relative_path = os.path.join(C.TEMPLATE_STATIC_DIR, filename) + absolute_path = os.path.join(site_root_dir, C.TEMPLATE_TPL_DIR, + theme, relative_path) + if os.path.exists(absolute_path): + return TemplateData(site=site, theme=theme, path=relative_path) + + return None + + def _append_css_paths( + self, + template_data: TemplateData, + css_files: list, + css_files_noscript: list, + name_root: str, + settings: dict + + ) -> None: + """Append found css to css_files and css_files_noscript + + @param css_files: list to fill of relative path to found css file + @param css_files_noscript: list to fill of relative path to found css file + with "_noscript" suffix + """ + name = name_root + ".css" + css_path = self.get_static_path(template_data, name, settings) + if css_path is not None: + css_files.append(self.get_front_url(css_path)) + noscript_name = name_root + "_noscript.css" + noscript_path = self.get_static_path( + template_data, noscript_name, settings) + if noscript_path is not None: + css_files_noscript.append(self.get_front_url(noscript_path)) + + def get_css_files(self, template_data): + """Retrieve CSS files to use according template_data + + For each element of the path, a .css file is looked for in /static, and returned + if it exists. + Previous element are kept by replacing '/' with '_'. + styles_extra.css, styles.css, highlight.css and fonts.css are always used if they + exist. + For each found file, if a file with the same name and "_noscript" suffix exists, + it will be returned is second part of resulting tuple. + For instance, if template_data is (some_site, some_theme, blog/articles.html), + following files are returned, each time trying [some_site root] first, + then default site (i.e. sat_templates) root: + - some_theme/static/styles.css is returned if it exists + else default/static/styles.css + - some_theme/static/blog.css is returned if it exists + else default/static/blog.css (if it exists too) + - some_theme/static/blog_articles.css is returned if it exists + else default/static/blog_articles.css (if it exists too) + and for each found files, if same file with _noscript suffix exists, it is put + in noscript list (for instance (some_theme/static/styles_noscript.css)). + The behaviour may be changed with theme settings: if "fallback" is set, specified + themes will be checked instead of default. The theme will be checked in given + order, and "fallback" may be None or empty list to not check anything. + @param template_data(TemplateData): data of the current template + @return (tuple[list[unicode], list[unicode]]): a tuple with: + - front URLs of CSS files to use + - front URLs of CSS files to use when scripts are not enabled + """ + # TODO: some caching would be nice + css_files = [] + css_files_noscript = [] + path_elems = template_data.path.split('/') + path_elems[-1] = os.path.splitext(path_elems[-1])[0] + site = template_data.site + if site is None: + # absolute path + settings = {} + else: + settings = self.sites_themes[site][template_data.theme]['settings'] + + css_path = self.get_static_path(template_data, 'fonts.css', settings) + if css_path is not None: + css_files.append(self.get_front_url(css_path)) + + for name_root in ('styles', 'styles_extra', 'highlight'): + self._append_css_paths( + template_data, css_files, css_files_noscript, name_root, settings) + + for idx in range(len(path_elems)): + name_root = "_".join(path_elems[:idx+1]) + self._append_css_paths( + template_data, css_files, css_files_noscript, name_root, settings) + + return css_files, css_files_noscript + + ## custom filters ## + + @contextfilter + def _front_url(self, ctx, relative_url): + """Get front URL (URL seen by end-user) from a relative URL + + This default method return absolute full path + """ + template_data = ctx['template_data'] + if template_data.site is None: + assert template_data.theme is None + assert template_data.path.startswith("/") + return os.path.join(os.path.dirname(template_data.path, relative_url)) + + site_root_dir = self.sites_paths[template_data.site] + return os.path.join(site_root_dir, C.TEMPLATE_TPL_DIR, template_data.theme, + relative_url) + + @contextfilter + def _next_gidx(self, ctx, value): + """Use next current global index as suffix""" + next_ = ctx["gidx"].next(value) + return value if next_ == 0 else "{}_{}".format(value, next_) + + @contextfilter + def _cur_gidx(self, ctx, value): + """Use current current global index as suffix""" + current = ctx["gidx"].current(value) + return value if not current else "{}_{}".format(value, current) + + def _date_fmt( + self, + timestamp: Union[int, float], + fmt: str = "short", + date_only: bool = False, + auto_limit: int = 7, + auto_old_fmt: str = "short", + auto_new_fmt: str = "relative", + tz_name: Optional[str] = None + ) -> str: + if is_undefined(fmt): + fmt = "short" + + try: + return date_utils.date_fmt( + timestamp, fmt, date_only, auto_limit, auto_old_fmt, + auto_new_fmt, locale_str = self._locale_str, + tz_info=tz_name or date_utils.TZ_UTC + ) + except Exception as e: + log.warning(_("Can't parse date: {msg}").format(msg=e)) + return str(timestamp) + + def _timestamp_to_hour(self, timestamp: float) -> int: + """Get hour of day corresponding to a timestamp""" + dt = datetime.fromtimestamp(timestamp) + return dt.hour + + def attr_escape(self, text): + """escape a text to a value usable as an attribute + + remove spaces, and put in lower case + """ + return RE_ATTR_ESCAPE.sub("_", text.strip().lower())[:50] + + def _xmlui_class(self, xmlui_item, fields): + """return classes computed from XMLUI fields name + + will return a string with a series of escaped {name}_{value} separated by spaces. + @param xmlui_item(xmlui.XMLUIPanel): XMLUI containing the widgets to use + @param fields(iterable(unicode)): names of the widgets to use + @return (unicode, None): computer string to use as class attribute value + None if no field was specified + """ + classes = [] + for name in fields: + escaped_name = self.attr_escape(name) + try: + for value in xmlui_item.widgets[name].values: + classes.append(escaped_name + "_" + self.attr_escape(value)) + except KeyError: + log.debug( + _('ignoring field "{name}": it doesn\'t exists').format(name=name) + ) + continue + return " ".join(classes) or None + + @contextfilter + def _item_filter(self, ctx, item, filters): + """return item's value, filtered if suitable + + @param item(object): item to filter + value must have name and value attributes, + mostly used for XMLUI items + @param filters(dict[unicode, (callable, dict, None)]): map of name => filter + if filter is None, return the value unchanged + if filter is a callable, apply it + if filter is a dict, it can have following keys: + - filters: iterable of filters to apply + - filters_args: kwargs of filters in the same order as filters (use empty + dict if needed) + - template: template to format where {value} is the filtered value + """ + value = item.value + filter_ = filters.get(item.name, None) + if filter_ is None: + return value + elif isinstance(filter_, dict): + filters_args = filter_.get("filters_args") + for idx, f_name in enumerate(filter_.get("filters", [])): + kwargs = filters_args[idx] if filters_args is not None else {} + filter_func = self.env.filters[f_name] + try: + eval_context_filter = filter_func.evalcontextfilter + except AttributeError: + eval_context_filter = False + + if eval_context_filter: + value = filter_func(ctx.eval_ctx, value, **kwargs) + else: + value = filter_func(value, **kwargs) + template = filter_.get("template") + if template: + # format will return a string, so we need to check first + # if the value is safe or not, and re-mark it after formatting + is_safe = isinstance(value, safe) + value = template.format(value=value) + if is_safe: + value = safe(value) + return value + + def _adv_format(self, value, template, **kwargs): + """Advancer formatter + + like format() method, but take care or special values like None + @param value(unicode): value to format + @param template(None, unicode): template to use with format() method. + It will be formatted using value=value and **kwargs + None to return value unchanged + @return (unicode): formatted value + """ + if template is None: + return value + # jinja use string when no special char is used, so we have to convert to unicode + return str(template).format(value=value, **kwargs) + + def _dict_ext(self, source_dict, extra_dict, key=None): + """extend source_dict with extra dict and return the result + + @param source_dict(dict): dictionary to extend + @param extra_dict(dict, None): dictionary to use to extend first one + None to return source_dict unmodified + @param key(unicode, None): if specified extra_dict[key] will be used + if it doesn't exists, a copy of unmodified source_dict is returned + @return (dict): resulting dictionary + """ + if extra_dict is None: + return source_dict + if key is not None: + extra_dict = extra_dict.get(key, {}) + ret = source_dict.copy() + ret.update(extra_dict) + return ret + + def highlight(self, code, lexer_name=None, lexer_opts=None, html_fmt_opts=None): + """Do syntax highlighting on code + + Under the hood, pygments is used, check its documentation for options possible + values. + @param code(unicode): code or markup to highlight + @param lexer_name(unicode, None): name of the lexer to use + None to autodetect it + @param html_fmt_opts(dict, None): kword arguments to use for HtmlFormatter + @return (unicode): HTML markup with highlight classes + """ + if lexer_opts is None: + lexer_opts = {} + if html_fmt_opts is None: + html_fmt_opts = {} + if lexer_name is None: + lexer = lexers.guess_lexer(code, **lexer_opts) + else: + lexer = lexers.get_lexer_by_name(lexer_name, **lexer_opts) + formatter = formatters.HtmlFormatter(**html_fmt_opts) + return safe(pygments.highlight(code, lexer, formatter)) + + ## custom tests ## + + def _in_the_past(self, timestamp): + """check if a date is in the past + + @param timestamp(unicode, int): unix time + @return (bool): True if date is in the past + """ + return time.time() > int(timestamp) + + ## template methods ## + + def _icon_defs(self, *names): + """Define svg icons which will be used in the template. + + Their name is used as id + """ + svg_elt = etree.Element( + "svg", + nsmap={None: "http://www.w3.org/2000/svg"}, + width="0", + height="0", + style="display: block", + ) + defs_elt = etree.SubElement(svg_elt, "defs") + for name in names: + path = os.path.join(self.icons_path, name + ".svg") + icon_svg_elt = etree.parse(path).getroot() + # we use icon name as id, so we can retrieve them easily + icon_svg_elt.set("id", name) + if not icon_svg_elt.tag == "{http://www.w3.org/2000/svg}svg": + raise exceptions.DataError("invalid SVG element") + defs_elt.append(icon_svg_elt) + return safe(etree.tostring(svg_elt, encoding="unicode")) + + def _icon_use(self, name, cls=""): + return safe('<svg class="svg-icon{cls}" xmlns="http://www.w3.org/2000/svg" ' + 'viewBox="0 0 100 100">\n' + ' <use href="#{name}"/>' + '</svg>\n'.format(name=name, cls=(" " + cls) if cls else "")) + + def _icon_from_client(self, client): + """Get icon name to represent a disco client""" + if client is None: + return 'desktop' + elif 'pc' in client: + return 'desktop' + elif 'phone' in client: + return 'mobile' + elif 'web' in client: + return 'globe' + elif 'console' in client: + return 'terminal' + else: + return 'desktop' + + def render(self, template, site=None, theme=None, locale=C.DEFAULT_LOCALE, + media_path="", css_files=None, css_inline=False, **kwargs): + """Render a template + + @param template(unicode): template to render (e.g. blog/articles.html) + @param site(unicode): site name + None or empty string for defaut site (i.e. SàT templates) + @param theme(unicode): template theme + @param media_path(unicode): prefix of the SàT media path/URL to use for + template root. Must end with a u'/' + @param css_files(list[unicode],None): CSS files to use + CSS files must be in static dir of the template + use None for automatic selection of CSS files based on template category + None is recommended. General static/style.css and theme file name will be + used. + @param css_inline(bool): if True, CSS will be embedded in the HTML page + @param **kwargs: variable to transmit to the template + """ + if not template: + raise ValueError("template can't be empty") + if site is not None or theme is not None: + # user wants to set site and/or theme, so we add it to the template path + if site is None: + site = '' + if theme is None: + theme = C.TEMPLATE_THEME_DEFAULT + if template[0] == "(": + raise ValueError( + "you can't specify site or theme in template path and in argument " + "at the same time" + ) + + template_data = TemplateData(site, theme, template) + template = "({site}/{theme}){template}".format( + site=site, theme=theme, template=template) + else: + template_data = self.env.loader.parse_template(template) + + # we need to save template_data in environment, to load right templates when they + # are referenced from other templates (e.g. import) + # FIXME: this trick will not work anymore if we use async templates (it works + # here because we know that the rendering will be blocking until we unset + # _template_data) + self.env._template_data = template_data + + template_source = self.env.get_template(template) + + if css_files is None: + css_files, css_files_noscript = self.get_css_files(template_data) + else: + css_files_noscript = [] + + kwargs["icon_defs"] = self._icon_defs + kwargs["icon"] = self._icon_use + kwargs["icon_from_client"] = self._icon_from_client + + if css_inline: + css_contents = [] + for files, suffix in ((css_files, ""), + (css_files_noscript, "_noscript")): + site_root_dir = self.sites_paths[template_data.site] + for css_file in files: + css_file_path = os.path.join(site_root_dir, css_file) + with open(css_file_path) as f: + css_contents.append(f.read()) + if css_contents: + kwargs["css_content" + suffix] = "\n".join(css_contents) + + scripts_handler = ScriptsHandler(self, template_data) + self.set_locale(locale) + + # XXX: theme used in template arguments is the requested theme, which may differ + # from actual theme if the template doesn't exist in the requested theme. + rendered = template_source.render( + template_data=template_data, + media_path=media_path, + css_files=css_files, + css_files_noscript=css_files_noscript, + locale=self._locale, + locales=self.locales, + gidx=Indexer(), + script=scripts_handler, + **kwargs + ) + self.env._template_data = None + return rendered