# HG changeset patch # User Goffi # Date 1536562698 -7200 # Node ID 0fa217fafabfd984b2e874867b80d1ebb89711cf # Parent ef93fcbaa74904dc4a3bd690d1a6fc619325b21d tools (common/template), jp: refactoring to handle multiple sites: - site can now be specified in template header before theme, for instance: (some_site/some_theme)path/to/template.ext - absolute template paths are now implemented, but Renderer must be instanciated with trusted to True for security reason (it's the case for jp) - a new "front_url_filter" callable can be given to Renderer, which will convert template path to URL seen by end-user (default to real path). - the "front_url_filter" can be used in templates with… "front_url" filter - template_data is a new named tuple available in templates, which give site, theme and template relative URL - search order is site/theme, site/default_theme, and default/default_theme where default link to sat_pubsub templates - when loading CSS files, files with _noscript suffixes are now loaded, and used when javascript is not available - "styles_extra.css" is also loaded before "styles.css", useful when a theme want to reuse default style, and just override some rules - new site can be specified in sat.conf [DEFAULT] section, using sites_path_public_dict or sites_path_private_dict (where sites_path_private_dict won't be used in public frontends, like Libervia) - "private" argument of Renderer tells the renderer to load private sites or not - templates are now loaded from "templates" subdirectory, to differenciate them from other data like i18n - jp template output has been updated to handle those changes, and to manage absolute templates diff -r ef93fcbaa749 -r 0fa217fafabf sat/core/constants.py --- a/sat/core/constants.py Fri Aug 31 17:18:51 2018 +0200 +++ b/sat/core/constants.py Mon Sep 10 08:58:18 2018 +0200 @@ -225,6 +225,7 @@ ] ## Templates ## + TEMPLATE_TPL_DIR = u"templates" TEMPLATE_THEME_DEFAULT = u"default" TEMPLATE_STATIC_DIR = u"static" diff -r ef93fcbaa749 -r 0fa217fafabf sat/core/log.py --- a/sat/core/log.py Fri Aug 31 17:18:51 2018 +0200 +++ b/sat/core/log.py Mon Sep 10 08:58:18 2018 +0200 @@ -158,13 +158,16 @@ class ConfigureBase(object): LOGGER_CLASS = Logger - _color_location = False # True if color location is specified in fmt (with COLOR_START) + # True if color location is specified in fmt (with COLOR_START) + _color_location = False - def __init__(self, level=None, fmt=None, output=None, logger=None, colors=False, levels_taints_dict=None, force_colors=False, backend_data=None): + def __init__(self, level=None, fmt=None, output=None, logger=None, colors=False, + levels_taints_dict=None, force_colors=False, backend_data=None): """Configure a backend @param level: one of C.LOG_LEVELS - @param fmt: format string, pretty much as in std logging. Accept the following keywords (maybe more depending on backend): + @param fmt: format string, pretty much as in std logging. + Accept the following keywords (maybe more depending on backend): - "message" - "levelname" - "name" (logger name) diff -r ef93fcbaa749 -r 0fa217fafabf sat/tools/common/template.py --- a/sat/tools/common/template.py Fri Aug 31 17:18:51 2018 +0200 +++ b/sat/tools/common/template.py Mon Sep 10 08:58:18 2018 +0200 @@ -17,16 +17,16 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -""" template generation """ +"""Template generation""" +import os.path +from collections import namedtuple from sat.core.constants import Const as C from sat.core.i18n import _ from sat.core import exceptions +from sat.tools import config from sat.tools.common import date_utils from sat.core.log import getLogger - -log = getLogger(__name__) -import os.path from xml.sax.saxutils import quoteattr import time import re @@ -41,7 +41,8 @@ import sat_templates except ImportError: raise exceptions.MissingModule( - u"sat_templates module is not available, please install it or check your path to use template engine" + u"sat_templates module is not available, please install it or check your path to " + u"use template engine" ) else: sat_templates # to avoid pyflakes warning @@ -50,90 +51,206 @@ import jinja2 except: raise exceptions.MissingModule( - u"Missing module jinja2, please install it from http://jinja.pocoo.org or with pip install jinja2" + u"Missing module jinja2, please install it from http://jinja.pocoo.org or with " + u"pip install jinja2" ) from jinja2 import Markup as safe from jinja2 import is_undefined +from jinja2 import utils +from jinja2 import TemplateNotFound +from jinja2.loaders import split_template_path from lxml import etree +log = getLogger(__name__) + HTML_EXT = ("html", "xhtml") RE_ATTR_ESCAPE = re.compile(r"[^a-z_-]") -#  TODO: handle external path (an additional search path for templates should be settable by user -# TODO: handle absolute URL (should be used for trusted use cases) only (e.g. jp) for security reason +SITE_RESERVED_NAMES = (u"sat",) +TPL_RESERVED_CHARS = ur"()/." +RE_TPL_RESERVED_CHARS = re.compile(u"[" + TPL_RESERVED_CHARS + u"]") + +TemplateData = namedtuple("TemplateData", ['site', 'theme', 'path']) -class TemplateLoader(jinja2.FileSystemLoader): - def __init__(self): - searchpath = os.path.dirname(sat_templates.__file__) - super(TemplateLoader, self).__init__(searchpath, followlinks=True) +class TemplateLoader(jinja2.BaseLoader): + """A template loader which handle site, theme and absolute paths""" + # TODO: list_templates should be implemented - def parse_template(self, template): - """parse template path and return theme and relative URL + def __init__(self, sites_paths, 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 u"" in sites_paths: + raise exceptions.InternalError(u"Invalid sites_paths") + super(jinja2.BaseLoader, self).__init__() + self.sites_paths = sites_paths + 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 - @return (tuple[(unicode,None),unicode]): theme and template_path - theme can be None if relative path is used - relative path is the path from search path with theme specified - e.g. default/blog/articles.html + 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(u"("): + # site and/or theme are specified try: theme_end = template.index(u")") except IndexError: - raise ValueError(u"incorrect theme in template") - theme = template[1:theme_end] - template = template[theme_end + 1 :] - if not template or template.startswith(u"/"): - raise ValueError(u"incorrect path after template name") - template = os.path.join(theme, template) + raise ValueError(u"incorrect site/theme in template") + theme_data = template[1:theme_end] + theme_splitted = theme_data.split(u'/') + if len(theme_splitted) == 1: + site, theme = u"", theme_splitted + elif len(theme_splitted) == 2: + site, theme = theme_splitted + else: + raise ValueError(u"incorrect site/theme in template") + template_path = template[theme_end+1:] + if not template_path or template_path.startswith(u"/"): + raise ValueError(u"incorrect template path") elif template.startswith(u"/"): - # absolute path means no template + # this is an absolute path, so we have no site and no theme + site = None theme = None - raise NotImplementedError(u"absolute path is not implemented yet") + template_path = template else: + # a default template + site = u"" theme = C.TEMPLATE_THEME_DEFAULT - template = os.path.join(theme, template) - return theme, template + template_path = template + + if site is not None: + site = site.strip() + if not site: + site = u"" + elif site in SITE_RESERVED_NAMES: + raise ValueError(_(u"{site} can't be used as site name, " + u"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(_(u"{theme} contain forbidden char. Following chars " + u"are forbidden: {reserved}").format( + theme=theme, reserved=TPL_RESERVED_CHARS)) + + return TemplateData(site, theme, template_path) - def get_default_template(self, theme, template_path): - """return default template path + @staticmethod + def getSitesAndThemes(site, theme): + """Get sites and themes to check for template/file - @param theme(unicode): theme used - @param template_path(unicode): path to the not found template - @return (unicode, None): default path or None if there is not + Will add default theme and default site in search list when suitable + @param site(unicode): site requested + @param theme(unicode): theme requested + @return (list[tuple[unicode, unicode]]): site and theme couples to check """ - ext = os.path.splitext(template_path)[1][1:] - path_elems = template_path.split(u"/") - if ext in HTML_EXT: - if path_elems[1] == u"error": - # if an inexisting error page is requested, we return base page - default_path = os.path.join(theme, u"error/base.html") - return default_path + sites_and_themes = [[site, theme]] if theme != C.TEMPLATE_THEME_DEFAULT: - # if template doesn't exists for this theme, we try with default - return os.path.join(C.TEMPLATE_THEME_DEFAULT, path_elems[1:]) + sites_and_themes.append([site, C.TEMPLATE_THEME_DEFAULT]) + if site: + # the site is not the default one, so we add default at the end + sites_and_themes.append([u'', C.TEMPLATE_THEME_DEFAULT]) + 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( + u"_get_template_f must not be used with absolute path") + for site, theme in self.getSitesAndThemes(site, theme): + try: + base_path = self.sites_paths[site] + except KeyError: + log.warning(_(u"Unregistered site requested: {site}").format( + site=site)) + filepath = os.path.join(base_path, C.TEMPLATE_TPL_DIR, theme, *path_elts) + f = utils.open_if_exists(filepath) + if f is not None: + return f, filepath + return None, None def get_source(self, environment, template): - """relative path to template dir, with special theme handling + """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. - The theme can be specified in parenthesis just before the path - e.g.: (some_theme)path/to/template.html + @raise PermissionError: absolute path used in untrusted environment """ - theme, template_path = self.parse_template(template) + 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(u"We can't have a theme with absolute " + u"template.") + if not self.trusted: + log.error(_(u"Absolute template used while unsecure is disabled, hack " + u"attempt? Template: {template}").format(template=template)) + raise exceptions.PermissionError(u"absolute template is not allowed") + filepath = template_path + f = utils.open_if_exists(filepath) + 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] == u"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(u"error/base.html should exist") + else: + raise TemplateNotFound(template) + try: - return super(TemplateLoader, self).get_source(environment, template_path) - except jinja2.exceptions.TemplateNotFound as e: - # in some special cases, a defaut template is returned if nothing is found - if theme is not None: - default_path = self.get_default_template(theme, template_path) - if default_path is not None: - return super(TemplateLoader, self).get_source( - environment, default_path - ) - # if no default template is found, we re-raise the error - raise e + contents = f.read().decode('utf-8') + 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): @@ -154,12 +271,10 @@ class ScriptsHandler(object): - def __init__(self, renderer, template_path, template_root_dir, root_path): + def __init__(self, renderer, template_data): self.renderer = renderer - self.template_root_dir = template_root_dir - self.root_path = root_path + self.template_data = template_data self.scripts = [] #  we don't use a set because order may be important - dummy, self.theme, self.is_default_theme = renderer.getThemeData(template_path) def include(self, library_name, attribute="defer"): """Mark that a script need to be imported. @@ -169,13 +284,13 @@ @param library_name(unicode): name of the library to import @param loading: """ - if attribute not in ("defer", "async", ""): + if attribute not in (u"defer", u"async", u""): raise exceptions.DataError( _(u'Invalid attribute, please use one of "defer", "async" or ""') ) - if library_name.endswith(".js"): - library_name = library_name[:-3] - if library_name not in self.scripts: + if not library_name.endswith(u".js"): + library_name = library_name + u".js" + if (library_name, attribute) not in self.scripts: self.scripts.append((library_name, attribute)) return u"" @@ -187,25 +302,61 @@ scripts = [] tpl = u"" for library, attribute in self.scripts: - path = self.renderer.getStaticPath( - library, self.template_root_dir, self.theme, self.is_default_theme, ".js" - ) - if path is None: - log.warning(_(u"Can't find {}.js javascript library").format(library)) + library_path = self.renderer.getStaticPath(self.template_data, library) + if library_path is None: + log.warning(_(u"Can't find {libary} javascript library").format( + library=library)) continue - path = os.path.join(self.root_path, path) + path = self.renderer.getFrontURL(library_path) scripts.append(tpl.format(src=quoteattr(path), attribute=attribute)) return safe(u"\n".join(scripts)) class Renderer(object): - def __init__(self, host): + + 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 + """ + # TODO: self.host = host - self.base_dir = os.path.dirname( - sat_templates.__file__ - ) # FIXME: should be modified if we handle use extra dirs + self.trusted = trusted + self.sites_paths = { + u"": os.path.dirname(sat_templates.__file__), + } + conf = config.parseMainConf() + public_sites = config.getConfig(conf, None, u"sites_path_public_dict", {}) + sites_data = [public_sites] + if private: + private_sites = config.getConfig(conf, None, u"sites_path_private_dict", {}) + sites_data.append(private_sites) + for sites in sites_data: + normalised = {} + for name, path in sites.iteritems(): + if RE_TPL_RESERVED_CHARS.search(name): + log.warning(_(u"Can't add \"{name}\" site, it contains forbidden " + u"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(u"/"): + log.warning(_(u"Can't add \"{name}\" site, it should map to an " + u"absolute path").format(name=name)) + continue + normalised[name] = path + self.sites_paths.update(normalised) + self.env = jinja2.Environment( - loader=TemplateLoader(), + loader=TemplateLoader(sites_paths=self.sites_paths, trusted=trusted), autoescape=jinja2.select_autoescape(["html", "xhtml", "xml"]), trim_blocks=True, lstrip_blocks=True, @@ -217,41 +368,64 @@ # we want to have access to SàT constants in templates self.env.globals[u"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["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[u"next_gidx"] = self._next_gidx + self.env.filters[u"cur_gidx"] = self._cur_gidx + self.env.filters[u"date_fmt"] = self._date_fmt + self.env.filters[u"xmlui_class"] = self._xmlui_class + self.env.filters[u"attr_escape"] = self.attr_escape + self.env.filters[u"item_filter"] = self._item_filter + self.env.filters[u"adv_format"] = self._adv_format + self.env.filters[u"dict_ext"] = self._dict_ext + self.env.filters[u"highlight"] = self.highlight + self.env.filters[u"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.env.tests[u"in_the_past"] = self._in_the_past self.icons_path = os.path.join(host.media_dir, u"fonts/fontello/svg") + def getFrontURL(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[u"front_url"]({u"template_data": template_data}, + path or template_data.path) + def installTranslations(self): - i18n_dir = os.path.join(self.base_dir, "i18n") - self.translations = {} - 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: - with open(po_path, "rb") as f: - self.translations[Locale.parse(lang_dir)] = support.Translations( - f, "sat" - ) - except EnvironmentError: - log.error( - _(u"Can't find template translation at {path}").format(path=po_path) - ) - except UnknownLocaleError as e: - log.error(_(u"Invalid locale name: {msg}").format(msg=e)) - else: - log.info(_(u"loaded {lang} templates translations").format(lang=lang_dir)) - self.env.install_null_translations(True) + # TODO: support multi translation + # for now, only translations in sat_templates are handled + for site_key, site_path in self.sites_paths.iteritems(): + site_prefix = u"[{}] ".format(site_key) if site_key else u'' + i18n_dir = os.path.join(site_path, "i18n") + self.translations = {} + 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( + _(u"Can't find template translation at {path}").format( + path=po_path)) + except UnknownLocaleError as e: + log.error(_(u"{site}Invalid locale name: {msg}").format( + site=site_prefix, msg=e)) + else: + log.info(_(u"{site}loaded {lang} templates translations").format( + site = site_prefix, + lang=lang_dir)) + self.env.install_null_translations(True) def setLocale(self, locale_str): """set current locale @@ -294,79 +468,137 @@ @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 """ - theme, dummy = self.env.loader.parse_template(template) - return theme, os.path.join(self.base_dir, theme) - - def getStaticPath(self, name, template_root_dir, theme, is_default, ext=".css"): - """retrieve path of a static file if it exists with current theme or default + # FIXME: check use in jp, and include site + site, theme, __ = self.env.loader.parse_template(template) + if site is None: + # absolute template + return u"", 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) - File will be looked at [theme]/static/[name][ext], and then default - if not found. - @param name(unicode): name of the file to look for - @param template_root_dir(unicode): absolute path to template root used - @param theme(unicode): name of the template theme used - @param is_default(bool): True if theme is the default theme - @return (unicode, None): relative path if found, else None + def getStaticPath(self, template_data, filename): + """Retrieve path of a static file if it exists with current theme or default + + File will be looked at ///filename, + then ///filename anf finally + // (i.e. sat_templates). + In case of absolue 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/" + @param template_data(TemplateData): data of current template + @return (TemplateData, None): built template data instance where .path is + the relative path to the file, from theme root dir. + None if not found. """ - file_ = None - path = os.path.join(theme, C.TEMPLATE_STATIC_DIR, name + ext) - if os.path.exists(os.path.join(template_root_dir, path)): - file_ = path - elif not is_default: - path = os.path.join( - C.TEMPLATE_THEME_DEFAULT, C.TEMPLATE_STATIC_DIR, name + ext - ) - if os.path.exists(os.path.join(template_root_dir, path)): - file_.append(path) - return file_ + if template_data.site is None: + # we have and absolue path + if (not template_data.theme is None + or not template_data.path.startswith(u'/')): + raise exceptions.InternalError( + u"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 - def getThemeData(self, template_path): - """return template data got from template_path + sites_and_themes = TemplateLoader.getSitesAndThemes(template_data.site, + template_data.theme) + 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 _appendCSSPaths(self, template_data, css_files, css_files_noscript, name_root): + """Append found css to css_files and css_files_noscript - @return tuple(unicode, unicode, bool): - path_elems: elements of the path - theme: theme of the page - is_default: True if the theme is the default theme + @param css_files(list): list to fill of relative path to found css file + @param css_files_noscript(list): list to fill of relative path to found css file + with "_noscript" suffix """ - path_elems = [os.path.splitext(p)[0] for p in template_path.split(u"/")] - theme = path_elems.pop(0) - is_default = theme == C.TEMPLATE_THEME_DEFAULT - return (path_elems, theme, is_default) + name = name_root + u".css" + css_path = self.getStaticPath(template_data, name) + if css_path is not None: + css_files.append(self.getFrontURL(css_path)) + noscript_name = name_root + u"_noscript.css" + noscript_path = self.getStaticPath(template_data, noscript_name) + if noscript_path is not None: + css_files_noscript.append(self.getFrontURL(noscript_path)) + + def getCSSFiles(self, template_data): + """Retrieve CSS files to use according template_data - def getCSSFiles(self, template_path, template_root_dir): - """retrieve CSS files to use according to theme and template path - - 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 '_', and styles.css is always returned. - For instance, if template_path is some_theme/blog/articles.html: - 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) - @param template_path(unicode): relative path to template file (e.g. some_theme/blog/articles.html) - @param template_root_dir(unicode): absolute path of the theme root dir used - @return list[unicode]: relative path to CSS files to use + 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 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 return is second part of resulting tuple. + For instance, if template_data is (some_site, some_theme, blog/articles.html), + following files are returned, earch 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)). + @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 = [] - path_elems, theme, is_default = self.getThemeData(template_path) - for css in (u"fonts", u"styles"): - css_path = self.getStaticPath(css, template_root_dir, theme, is_default) - if css_path is not None: - css_files.append(css_path) + css_files_noscript = [] + path_elems = template_data.path.split(u'/') + path_elems[-1] = os.path.splitext(path_elems[-1])[0] + + for name_root in (u'styles_extra', u'styles'): + self._appendCSSPaths(template_data, css_files, css_files_noscript, name_root) - for idx, path in enumerate(path_elems): - css_path = self.getStaticPath( - u"_".join(path_elems[: idx + 1]), template_root_dir, theme, is_default - ) - if css_path is not None: - css_files.append(css_path) + css_path = self.getStaticPath(template_data, u'fonts.css') + if css_path is not None: + css_files.append(self.getFrontURL(css_path)) - return css_files + for idx in xrange(len(path_elems)): + name_root = u"_".join(path_elems[:idx+1]) + self._appendCSSPaths(template_data, css_files, css_files_noscript, name_root) + + return css_files, css_files_noscript ## custom filters ## @jinja2.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[u'template_data'] + if template_data.site is None: + assert template_data.theme is None + assert template_data.path.startswith(u"/") + 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) + + @jinja2.contextfilter def _next_gidx(self, ctx, value): """Use next current global index as suffix""" next_ = ctx["gidx"].next(value) @@ -433,7 +665,8 @@ 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) + - 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 @@ -500,7 +733,8 @@ 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 + 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 @@ -531,7 +765,10 @@ ## template methods ## def _icon_defs(self, *names): - """Define svg icons which will be used in the template, and use their name as id""" + """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"}, @@ -551,88 +788,78 @@ return safe(etree.tostring(svg_elt, encoding="unicode")) def _icon_use(self, name, cls=""): - return safe( - u""" - - - """.format( - name=name, cls=(" " + cls) if cls else "" - ) - ) + return safe(u'\n' + u' ' + u'\n'.format(name=name, cls=(" " + cls) if cls else "")) - def render( - self, - template, - theme=None, - locale=C.DEFAULT_LOCALE, - root_path=u"", - media_path=u"", - css_files=None, - css_inline=False, - **kwargs - ): - """render a template -. + def render(self, template, site=None, theme=None, locale=C.DEFAULT_LOCALE, + media_path=u"", css_files=None, css_inline=False, **kwargs): + """Render a template + @param template(unicode): template to render (e.g. blog/articles.html) + @param site(unicide): site name + None or empty string for defaut site (i.e. SàT templates) @param theme(unicode): template theme - @param root_path(unicode): prefix of the path/URL to use for template root - must end with a u'/' - @param media_path(unicode): prefix of the SàT media path/URL to use for template root - must end with a u'/' + @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 used 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. + 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(u"template can't be empty") - if theme is not None: - # use want to set a theme, we add it to the template path + 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 = u'' + if theme is None: + theme = C.TEMPLATE_THEME_DEFAULT if template[0] == u"(": raise ValueError( - u"you can't specify theme in template path and in argument at the same time" + u"you can't specify site or theme in template path and in argument " + u"at the same time" ) - elif template[0] == u"/": - raise ValueError(u"you can't specify theme with absolute paths") - template = u"(" + theme + u")" + template + + template_data = TemplateData(site, theme, template) + template = u"({site}/{theme}){template}".format( + site=site, theme=theme, template=template) else: - theme, dummy = self.env.loader.parse_template(template) + template_data = self.env.loader.parse_template(template) template_source = self.env.get_template(template) - template_root_dir = os.path.normpath( - self.base_dir - ) # FIXME: should be modified if we handle use extra dirs - #  XXX: template_path may have a different theme as first element than theme if a default page is used - template_path = template_source.filename[len(template_root_dir) + 1 :] if css_files is None: - css_files = self.getCSSFiles(template_path, template_root_dir) + css_files, css_files_noscript = self.getCSSFiles(template_data) kwargs["icon_defs"] = self._icon_defs kwargs["icon"] = self._icon_use if css_inline: css_contents = [] - for css_file in css_files: - css_file_path = os.path.join(template_root_dir, css_file) - with open(css_file_path) as f: - css_contents.append(f.read()) - if css_contents: - kwargs["css_content"] = "\n".join(css_contents) + for files, suffix in ((css_files, u""), + (css_files_noscript, u"_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[u"css_content" + suffix] = u"\n".join(css_contents) - scripts_handler = ScriptsHandler( - self, template_path, template_root_dir, root_path - ) + scripts_handler = ScriptsHandler(self, template_data) self.setLocale(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. + # 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. return template_source.render( - theme=theme, - root_path=root_path, + template_data=template_data, media_path=media_path, css_files=css_files, + css_files_noscript=css_files, locale=self._locale, gidx=Indexer(), script=scripts_handler, diff -r ef93fcbaa749 -r 0fa217fafabf sat/tools/config.py --- a/sat/tools/config.py Fri Aug 31 17:18:51 2018 +0200 +++ b/sat/tools/config.py Mon Sep 10 08:58:18 2018 +0200 @@ -47,7 +47,8 @@ config = SafeConfigParser() target_file = None for file_ in C.CONFIG_FILES[::-1]: - # we will eventually update the existing file with the highest priority, if it's a user personal file... + # we will eventually update the existing file with the highest priority, + # if it's a user personal file... if not silent: log.debug(_(u"Testing file %s") % file_) if os.path.isfile(file_): @@ -66,12 +67,9 @@ if not silent: if option in ("passphrase",): # list here the options storing a password value = "******" - log.warning( - _( - u"Config auto-update: %(option)s set to %(value)s in the file %(config_file)s" - ) - % {"option": option, "value": value, "config_file": target_file} - ) + log.warning(_(u"Config auto-update: {option} set to {value} in the file " + u"{config_file}.").format(option=option, value=value, + config_file=target_file)) def parseMainConf(): diff -r ef93fcbaa749 -r 0fa217fafabf sat_frontends/jp/base.py --- a/sat_frontends/jp/base.py Fri Aug 31 17:18:51 2018 +0200 +++ b/sat_frontends/jp/base.py Mon Sep 10 08:58:18 2018 +0200 @@ -554,7 +554,7 @@ if self._need_loop or self._auto_loop: self._start_loop() except KeyboardInterrupt: - log.info(_("User interruption: good bye")) + self.disp(_("User interruption: good bye")) def _start_loop(self): self.loop = JPLoop() diff -r ef93fcbaa749 -r 0fa217fafabf sat_frontends/jp/output_template.py --- a/sat_frontends/jp/output_template.py Fri Aug 31 17:18:51 2018 +0200 +++ b/sat_frontends/jp/output_template.py Mon Sep 10 08:58:18 2018 +0200 @@ -21,7 +21,10 @@ from sat_frontends.jp.constants import Const as C from sat.core.i18n import _ +from sat.core import log from sat.tools.common import template +from functools import partial +import logging import webbrowser import tempfile import os.path @@ -38,6 +41,19 @@ self.host = jp jp.register_output(C.OUTPUT_COMPLEX, TEMPLATE, self.render) + def _front_url_tmp_dir(self, ctx, relative_url, tmp_dir): + """Get front URL for temporary directory""" + template_data = ctx[u'template_data'] + return u"file://" + os.path.join(tmp_dir, template_data.theme, relative_url) + + def _do_render(self, template_path, css_inline, **kwargs): + try: + return self.renderer.render(template_path, css_inline=css_inline, **kwargs) + except template.TemplateNotFound: + self.host.disp(_(u"Can't find requested template: {template_path}") + .format(template_path=template_path), error=True) + self.host.quit(C.EXIT_NOT_FOUND) + def render(self, data): """render output data using requested template @@ -55,23 +71,23 @@ template_path = cmd.TEMPLATE except AttributeError: if not "template" in cmd.args.output_opts: - self.host.disp( - u"no default template set for this command, " - u"you need to specify a template using --oo template=[path/to/template.html]", + self.host.disp(_( + u"no default template set for this command, you need to specify a " + u"template using --oo template=[path/to/template.html]"), error=True, ) self.host.quit(C.EXIT_BAD_ARG) options = self.host.parse_output_options() self.host.check_output_options(OPTIONS, options) - self.renderer = template.Renderer(self.host) try: template_path = options["template"] except KeyError: # template is not specified, we use default one pass if template_path is None: - self.host.disp(u"Can't parse template, please check its syntax", error=True) + self.host.disp(_(u"Can't parse template, please check its syntax"), + error=True) self.host.quit(C.EXIT_BAD_ARG) try: @@ -82,28 +98,41 @@ kwargs = mapping_cb(data) css_inline = u"inline-css" in options - rendered = self.renderer.render(template_path, css_inline=css_inline, **kwargs) if "browser" in options: template_name = os.path.basename(template_path) tmp_dir = tempfile.mkdtemp() - self.host.disp( - _( - u"Browser opening requested.\nTemporary files are put in the following directory, " - u"you'll have to delete it yourself once finished viewing: {}" - ).format(tmp_dir) - ) + front_url_filter = partial(self._front_url_tmp_dir, tmp_dir=tmp_dir) + self.renderer = template.Renderer( + self.host, front_url_filter=front_url_filter, trusted=True) + rendered = self._do_render(template_path, css_inline=css_inline, **kwargs) + self.host.disp(_( + u"Browser opening requested.\n" + u"Temporary files are put in the following directory, you'll have to " + u"delete it yourself once finished viewing: {}").format(tmp_dir)) tmp_file = os.path.join(tmp_dir, template_name) with open(tmp_file, "w") as f: f.write(rendered.encode("utf-8")) theme, theme_root_path = self.renderer.getThemeAndRoot(template_path) + if theme is None: + # we have an absolute path + webbrowser static_dir = os.path.join(theme_root_path, C.TEMPLATE_STATIC_DIR) if os.path.exists(static_dir): + # we have to copy static files in a subdirectory, to avoid file download + # to be blocked by same origin policy import shutil - shutil.copytree( static_dir, os.path.join(tmp_dir, theme, C.TEMPLATE_STATIC_DIR) ) webbrowser.open(tmp_file) else: + # FIXME: Q&D way to disable template logging + # logs are overcomplicated, and need to be reworked + template_logger = log.getLogger(u"sat.tools.common.template") + template_logger.log = lambda *args: None + + logging.disable(logging.WARNING) + self.renderer = template.Renderer(self.host, trusted=True) + rendered = self._do_render(template_path, css_inline=css_inline, **kwargs) self.host.disp(rendered)