Permalink
Fetching contributors…
Cannot retrieve contributors at this time
496 lines (382 sloc) 16.4 KB
import inspect
import os
import posixpath
import sys
from jinja2 import Environment as _Jinja2Environment
from jinja2.exceptions import TemplateNotFound
from jinja2.loaders import FileSystemLoader
from jinja2.utils import open_if_exists
from pyramid.asset import abspath_from_asset_spec
from pyramid.path import DottedNameResolver
from zope.deprecation import deprecated
from zope.interface import Interface
from .compat import text_type
from .settings import (
parse_env_options_from_settings,
parse_loader_options_from_settings,
parse_multiline,
)
ENV_CONFIG_PHASE = 0
EXTRAS_CONFIG_PHASE = 1
PARENT_RELATIVE_DELIM = '@@FROM_PARENT@@'
class IJinja2Environment(Interface):
pass
class Environment(_Jinja2Environment):
def join_path(self, uri, parent):
if os.path.isabs(uri) or ':' in uri:
# we have an asset spec or absolute path
return uri
# uri may be relative to the parent, shuffle it through to the loader
return uri + PARENT_RELATIVE_DELIM + parent
class FileInfo(object):
open_if_exists = staticmethod(open_if_exists)
getmtime = staticmethod(os.path.getmtime)
def __init__(self, filename, encoding='utf-8'):
self.filename = filename
self.encoding = encoding
def _delay_init(self):
if '_mtime' in self.__dict__:
return
f = self.open_if_exists(self.filename)
if f is None:
raise TemplateNotFound(self.filename)
self._mtime = self.getmtime(self.filename)
data = ''
try:
data = f.read()
finally:
f.close()
if not isinstance(data, text_type):
data = data.decode(self.encoding)
self._contents = data
@property
def contents(self):
self._delay_init()
return self._contents
@property
def mtime(self):
self._delay_init()
return self._mtime
def uptodate(self):
try:
return os.path.getmtime(self.filename) == self.mtime
except OSError:
return False
class _PackageFinder(object):
inspect = staticmethod(inspect)
def caller_package(self, excludes=()):
"""A list of excluded patterns, optionally containing a `.` suffix.
For example, ``'pyramid.'`` would exclude exclude ``'pyramid.config'``
but not ``'pyramid'``.
"""
f = None
for t in self.inspect.stack():
f = t[0]
name = f.f_globals.get('__name__')
if name:
excluded = False
for pattern in excludes:
if pattern[-1] == '.' and name.startswith(pattern):
excluded = True
break
elif name == pattern:
excluded = True
break
if not excluded:
break
if f is None:
return None
pname = f.f_globals.get('__name__') or '__main__'
m = sys.modules[pname]
f = getattr(m, '__file__', '')
if (('__init__.py' in f) or ('__init__$py' in f)): # empty at >>>
return m
pname = m.__name__.rsplit('.', 1)[0]
return sys.modules[pname]
_caller_package = _PackageFinder().caller_package
class SmartAssetSpecLoader(FileSystemLoader):
'''A Jinja2 template loader that knows how to handle
asset specifications.
'''
def __init__(self, searchpath=(), encoding='utf-8', debug=False):
FileSystemLoader.__init__(self, searchpath, encoding)
self.debug = debug
def list_templates(self):
raise TypeError('this loader cannot iterate over all templates')
def _get_absolute_source(self, template):
filename = abspath_from_asset_spec(template)
fi = FileInfo(filename, self.encoding)
if os.path.isfile(fi.filename):
return fi.contents, fi.filename, fi.uptodate
def _relative_searchpath(self, chain):
""" Combine paths in the chain to construct search paths.
The precedence is for the most-specific paths to be tested first,
anchored at an absolute path or asset spec. From there, less-specific
paths are tested.
For example::
chain = [
'../forms.jinja2', 'sub/nav.jinja2',
'base.jinja2', 'myapp:templates/index.jinja2',
]
searchpath = ['myapp:templates/sub/..', 'sub/..', '..', '']
"""
# the initial empty string is important because not only does it allow
# the stack to always contain something join, but it allows the
# later for-loops to fallback to the original search path by
# joining to an empty string since os.path.join('', 'foo') == 'foo'
stack = ['']
for path in chain:
is_abspath = os.path.isabs(path)
is_spec = not is_abspath and ':' in path
if not is_abspath and is_spec:
ppkg, ppath = path.split(':', 1)
path = '{0}:{1}'.format(ppkg, posixpath.dirname(ppath))
else:
# this should split windows and posix paths
path = os.path.dirname(path)
if not path:
# skip empty directories
continue
subpath = stack[-1]
path = posixpath.join(path, subpath)
stack.append(path)
# do not continue further, all paths are relative to this
if is_abspath or is_spec:
break
return list(reversed(stack))
def get_source(self, environment, template):
# keep legacy asset: prefix checking that bypasses
# source path checking altogether
if template.startswith('asset:'):
template = template[6:]
# split the template into the chain of relative-imports
rel_chain = template.split(PARENT_RELATIVE_DELIM)
template, rel_chain = rel_chain[0], rel_chain[1:]
# load the template directly if it's an absolute path or asset spec
if os.path.isabs(template) or ':' in template:
src = self._get_absolute_source(template)
if src is not None:
return src
else:
# fallback to the search path just incase
return FileSystemLoader.get_source(self, environment, template)
# try to import the template as an asset spec or absolute path
# relative to its parents
rel_searchpath = self._relative_searchpath(rel_chain)
for parent in rel_searchpath:
if os.path.isabs(parent):
uri = os.path.join(parent, template)
# avoid recursive includes
if uri not in rel_chain:
src = self._get_absolute_source(uri)
if src is not None:
return src
# avoid doing "':' in" and then redundant "split"
parts = parent.split(':', 1)
if len(parts) > 1:
# parent is an asset spec
ppkg, ppath = parts
ppath = posixpath.join(ppath, template)
uri = '{0}:{1}'.format(ppkg, ppath)
# avoid recursive includes
if uri not in rel_chain:
src = self._get_absolute_source(uri)
if src is not None:
return src
# try to load the template from the default search path
for parent in rel_searchpath:
try:
uri = os.path.join(parent, template)
# avoid recursive includes
if uri not in rel_chain:
return FileSystemLoader.get_source(self, environment, uri)
except TemplateNotFound:
pass
# we're here because of an exception during the last step so extend
# the message and raise an appropriate error
# there should always be an exception because the rel_searchpath is
# guaranteed to contain at least one element ('')
searchpath = [p for p in rel_searchpath if p] + self.searchpath
message = '{0}; searchpath={1}'.format(template, searchpath)
raise TemplateNotFound(name=template, message=message)
class Jinja2TemplateRenderer(object):
'''Renderer for a jinja2 template'''
def __init__(self, template_loader):
self.template_loader = template_loader
def __call__(self, value, system):
try:
system.update(value)
except (TypeError, ValueError) as ex:
raise ValueError('renderer was passed non-dictionary '
'as value: %s' % str(ex))
template = self.template_loader()
return template.render(system)
class Jinja2RendererFactory(object):
environment = None
def __call__(self, info):
name, package = info.name, info.package
def template_loader():
# attempt to turn the name into a caller-relative asset spec
if ':' not in name and package is not None:
try:
name_with_package = '%s:%s' % (package.__name__, name)
return self.environment.get_template(name_with_package)
except TemplateNotFound:
pass
return self.environment.get_template(name)
return Jinja2TemplateRenderer(template_loader)
def renderer_factory(info):
registry = info.registry
env = registry.queryUtility(IJinja2Environment, name='.jinja2')
if env is None:
raise ValueError(
'As of pyramid_jinja2 2.3, the use of the '
'"pyramid_jinja2.renderer_factory" requires that pyramid_jinja2 '
'be configured via config.include("pyramid_jinja2") or the '
'equivalent "pyramid.includes" setting.')
factory = Jinja2RendererFactory()
factory.environment = env
return factory(info)
deprecated(
'renderer_factory',
'The pyramid_jinja2.renderer_factory was deprecated in version 2.0 and '
'will be removed in the future. You should upgrade to the newer '
'config.add_jinja2_renderer() API.')
def add_jinja2_search_path(config, searchpath, name='.jinja2', prepend=False):
"""
This function is added as a method of a :term:`Configurator`, and
should not be called directly. Instead it should be called like so after
``pyramid_jinja2`` has been passed to ``config.include``:
.. code-block:: python
config.add_jinja2_search_path('anotherpackage:templates/')
It will add the directory or :term:`asset specification` passed as
``searchpath`` to the current search path of the
:class:`jinja2.Environment` used by the renderer identified by ``name``.
By default the path is appended to the end of the search path. If
``prepend`` is set to ``True`` then the path will be inserted at the start
of the search path.
"""
def register():
env = get_jinja2_environment(config, name)
searchpaths = parse_multiline(searchpath)
for folder in searchpaths:
path = abspath_from_asset_spec(folder, config.package)
if prepend:
env.loader.searchpath.insert(0, path)
else:
env.loader.searchpath.append(path)
config.action(None, register, order=EXTRAS_CONFIG_PHASE)
def add_jinja2_extension(config, ext, name='.jinja2'):
"""
This function is added as a method of a :term:`Configurator`, and
should not be called directly. Instead it should be called like so after
``pyramid_jinja2`` has been passed to ``config.include``:
.. code-block:: python
config.add_jinja2_extension(myext)
It will add the Jinja2 extension passed as ``ext`` to the current
:class:`jinja2.Environment` used by the renderer named ``name``.
"""
ext = config.maybe_dotted(ext)
def register():
env = get_jinja2_environment(config, name)
env.add_extension(ext)
config.action(None, register, order=EXTRAS_CONFIG_PHASE)
def get_jinja2_environment(config, name='.jinja2'):
"""
This function is added as a method of a :term:`Configurator`, and
should not be called directly. Instead it should be called like so after
``pyramid_jinja2`` has been passed to ``config.include``:
.. code-block:: python
config.get_jinja2_environment()
It will return the configured ``jinja2.Environment`` for the
renderer named ``name``. The environment is created as an :term:`action`
which is deferred to allow users to override the configuration. In order
to get back the configured environment, you must either force a commit
via ``config.commit`` or schedule an action which can setup the
environment after it has been created:
.. code-block:: python
def setup_jinja2_env():
env = config.get_jinja2_environment()
# ...
config.action(None, setup_jinja2_env, order=999)
"""
registry = config.registry
return registry.queryUtility(IJinja2Environment, name=name)
def create_environment_from_options(env_opts, loader_opts):
loader = SmartAssetSpecLoader(**loader_opts)
newstyle = env_opts.pop('newstyle', False)
gettext = env_opts.pop('gettext', None)
filters = env_opts.pop('filters', {})
tests = env_opts.pop('tests', {})
globals = env_opts.pop('globals', {})
env = Environment(
loader=loader,
**env_opts
)
env.install_gettext_callables(
gettext.gettext, gettext.ngettext, newstyle=newstyle)
env.filters.update(filters)
env.tests.update(tests)
env.globals.update(globals)
return env
def add_jinja2_renderer(config, name, settings_prefix='jinja2.', package=None):
"""
This function is added as a method of a :term:`Configurator`, and
should not be called directly. Instead it should be called like so after
``pyramid_jinja2`` has been passed to ``config.include``:
.. code-block:: python
config.add_jinja2_renderer('.html', settings_prefix='jinja2.')
It will register a new renderer, loaded from settings at the specified
``settings_prefix`` prefix. This renderer will be active for files using
the specified extension ``name``.
"""
renderer_factory = Jinja2RendererFactory()
config.add_renderer(name, renderer_factory)
package = package or config.package
resolver = DottedNameResolver(package=package)
def register():
registry = config.registry
settings = config.get_settings()
loader_opts = parse_loader_options_from_settings(
settings,
settings_prefix,
resolver.maybe_resolve,
package,
)
env_opts = parse_env_options_from_settings(
settings,
settings_prefix,
resolver.maybe_resolve,
package,
)
env = create_environment_from_options(env_opts, loader_opts)
renderer_factory.environment = env
registry.registerUtility(env, IJinja2Environment, name=name)
config.action(
('jinja2-renderer', name), register, order=ENV_CONFIG_PHASE)
def includeme(config):
"""Set up standard configurator registrations. Use via:
.. code-block:: python
config = Configurator()
config.include('pyramid_jinja2')
Once this function has been invoked, the ``.jinja2`` renderer is
available for use in Pyramid and these new directives are available as
methods of the configurator:
- ``add_jinja2_renderer``: Add a new Jinja2 renderer, with a different
file extension and/or settings.
- ``add_jinja2_search_path``: Add a new location to the search path
for the specified renderer.
- ``add_jinja2_extension``: Add a list of extensions to the Jinja2
environment used by the specified renderer.
- ``get_jinja2_environment``: Return the :class:`jinja2.Environment`
used by the specified renderer.
"""
config.add_directive('add_jinja2_renderer', add_jinja2_renderer)
config.add_directive('add_jinja2_search_path', add_jinja2_search_path)
config.add_directive('add_jinja2_extension', add_jinja2_extension)
config.add_directive('get_jinja2_environment', get_jinja2_environment)
package = _caller_package(('pyramid', 'pyramid.', 'pyramid_jinja2'))
config.add_jinja2_renderer('.jinja2', package=package)
# always insert default search path relative to package
default_search_path = '%s:' % (package.__name__,)
config.add_jinja2_search_path(default_search_path, name='.jinja2')