1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2006-2010 Edgewall Software
4# All rights reserved.
5#
6# This software is licensed as described in the file COPYING, which
7# you should have received as part of this distribution. The terms
8# are also available at http://genshi.edgewall.org/wiki/License.
9#
10# This software consists of voluntary contributions made by many
11# individuals. For the exact contribution history, see the revision
12# history and logs, available at http://genshi.edgewall.org/log/.
13
14"""Template loading and caching."""
15
16try:
17 from importlib.resources import open_binary as resources_open_binary
18except ImportError:
19 from importlib_resources import open_binary as resources_open_binary
20import os
21try:
22 import threading
23except ImportError:
24 import dummy_threading as threading
25
26from genshi.compat import string_types
27from genshi.template.base import TemplateError
28from genshi.util import LRUCache
29
30__all__ = ['TemplateLoader', 'TemplateNotFound', 'directory', 'package',
31 'prefixed']
32__docformat__ = 'restructuredtext en'
33
34
35class TemplateNotFound(TemplateError):
36 """Exception raised when a specific template file could not be found."""
37
38 def __init__(self, name, search_path):
39 """Create the exception.
40
41 :param name: the filename of the template
42 :param search_path: the search path used to lookup the template
43 """
44 TemplateError.__init__(self, 'Template "%s" not found' % name)
45 self.search_path = search_path
46
47
48class TemplateLoader(object):
49 """Responsible for loading templates from files on the specified search
50 path.
51
52 >>> import tempfile
53 >>> fd, path = tempfile.mkstemp(suffix='.html', prefix='template')
54 >>> os.write(fd, u'<p>$var</p>'.encode('utf-8'))
55 11
56 >>> os.close(fd)
57
58 The template loader accepts a list of directory paths that are then used
59 when searching for template files, in the given order:
60
61 >>> loader = TemplateLoader([os.path.dirname(path)])
62
63 The `load()` method first checks the template cache whether the requested
64 template has already been loaded. If not, it attempts to locate the
65 template file, and returns the corresponding `Template` object:
66
67 >>> from genshi.template import MarkupTemplate
68 >>> template = loader.load(os.path.basename(path))
69 >>> isinstance(template, MarkupTemplate)
70 True
71
72 Template instances are cached: requesting a template with the same name
73 results in the same instance being returned:
74
75 >>> loader.load(os.path.basename(path)) is template
76 True
77
78 The `auto_reload` option can be used to control whether a template should
79 be automatically reloaded when the file it was loaded from has been
80 changed. Disable this automatic reloading to improve performance.
81
82 >>> os.remove(path)
83 """
84 def __init__(self, search_path=None, auto_reload=False,
85 default_encoding=None, max_cache_size=25, default_class=None,
86 variable_lookup='strict', allow_exec=True, callback=None):
87 """Create the template laoder.
88
89 :param search_path: a list of absolute path names that should be
90 searched for template files, or a string containing
91 a single absolute path; alternatively, any item on
92 the list may be a ''load function'' that is passed
93 a filename and returns a file-like object and some
94 metadata
95 :param auto_reload: whether to check the last modification time of
96 template files, and reload them if they have changed
97 :param default_encoding: the default encoding to assume when loading
98 templates; defaults to UTF-8
99 :param max_cache_size: the maximum number of templates to keep in the
100 cache
101 :param default_class: the default `Template` subclass to use when
102 instantiating templates
103 :param variable_lookup: the variable lookup mechanism; either "strict"
104 (the default), "lenient", or a custom lookup
105 class
106 :param allow_exec: whether to allow Python code blocks in templates
107 :param callback: (optional) a callback function that is invoked after a
108 template was initialized by this loader; the function
109 is passed the template object as only argument. This
110 callback can be used for example to add any desired
111 filters to the template
112 :see: `LenientLookup`, `StrictLookup`
113
114 :note: Changed in 0.5: Added the `allow_exec` argument
115 """
116 from genshi.template.markup import MarkupTemplate
117
118 self.search_path = search_path
119 if self.search_path is None:
120 self.search_path = []
121 elif not isinstance(self.search_path, (list, tuple)):
122 self.search_path = [self.search_path]
123
124 self.auto_reload = auto_reload
125 """Whether templates should be reloaded when the underlying file is
126 changed"""
127
128 self.default_encoding = default_encoding
129 self.default_class = default_class or MarkupTemplate
130 self.variable_lookup = variable_lookup
131 self.allow_exec = allow_exec
132 if callback is not None and not hasattr(callback, '__call__'):
133 raise TypeError('The "callback" parameter needs to be callable')
134 self.callback = callback
135 self._cache = LRUCache(max_cache_size)
136 self._uptodate = {}
137 self._lock = threading.RLock()
138
139 def __getstate__(self):
140 state = self.__dict__.copy()
141 state['_lock'] = None
142 return state
143
144 def __setstate__(self, state):
145 self.__dict__ = state
146 self._lock = threading.RLock()
147
148 def load(self, filename, relative_to=None, cls=None, encoding=None):
149 """Load the template with the given name.
150
151 If the `filename` parameter is relative, this method searches the
152 search path trying to locate a template matching the given name. If the
153 file name is an absolute path, the search path is ignored.
154
155 If the requested template is not found, a `TemplateNotFound` exception
156 is raised. Otherwise, a `Template` object is returned that represents
157 the parsed template.
158
159 Template instances are cached to avoid having to parse the same
160 template file more than once. Thus, subsequent calls of this method
161 with the same template file name will return the same `Template`
162 object (unless the ``auto_reload`` option is enabled and the file was
163 changed since the last parse.)
164
165 If the `relative_to` parameter is provided, the `filename` is
166 interpreted as being relative to that path.
167
168 :param filename: the relative path of the template file to load
169 :param relative_to: the filename of the template from which the new
170 template is being loaded, or ``None`` if the
171 template is being loaded directly
172 :param cls: the class of the template object to instantiate
173 :param encoding: the encoding of the template to load; defaults to the
174 ``default_encoding`` of the loader instance
175 :return: the loaded `Template` instance
176 :raises TemplateNotFound: if a template with the given name could not
177 be found
178 """
179 if cls is None:
180 cls = self.default_class
181 search_path = self.search_path
182
183 # Make the filename relative to the template file its being loaded
184 # from, but only if that file is specified as a relative path, or no
185 # search path has been set up
186 if relative_to and (not search_path or not os.path.isabs(relative_to)):
187 filename = os.path.join(os.path.dirname(relative_to), filename)
188
189 filename = os.path.normpath(filename)
190 cachekey = filename
191
192 self._lock.acquire()
193 try:
194 # First check the cache to avoid reparsing the same file
195 try:
196 tmpl = self._cache[cachekey]
197 if not self.auto_reload:
198 return tmpl
199 uptodate = self._uptodate[cachekey]
200 if uptodate is not None and uptodate():
201 return tmpl
202 except (KeyError, OSError):
203 pass
204
205 isabs = False
206
207 if os.path.isabs(filename):
208 # Bypass the search path if the requested filename is absolute
209 search_path = [os.path.dirname(filename)]
210 isabs = True
211
212 elif relative_to and os.path.isabs(relative_to):
213 # Make sure that the directory containing the including
214 # template is on the search path
215 dirname = os.path.dirname(relative_to)
216 if dirname not in search_path:
217 search_path = list(search_path) + [dirname]
218 isabs = True
219
220 elif not search_path:
221 # Uh oh, don't know where to look for the template
222 raise TemplateError('Search path for templates not configured')
223
224 for loadfunc in search_path:
225 if isinstance(loadfunc, string_types):
226 loadfunc = directory(loadfunc)
227 try:
228 filepath, filename, fileobj, uptodate = loadfunc(filename)
229 except IOError:
230 continue
231 else:
232 try:
233 if isabs:
234 # If the filename of either the included or the
235 # including template is absolute, make sure the
236 # included template gets an absolute path, too,
237 # so that nested includes work properly without a
238 # search path
239 filename = filepath
240 tmpl = self._instantiate(cls, fileobj, filepath,
241 filename, encoding=encoding)
242 if self.callback:
243 self.callback(tmpl)
244 self._cache[cachekey] = tmpl
245 self._uptodate[cachekey] = uptodate
246 finally:
247 if hasattr(fileobj, 'close'):
248 fileobj.close()
249 return tmpl
250
251 raise TemplateNotFound(filename, search_path)
252
253 finally:
254 self._lock.release()
255
256 def _instantiate(self, cls, fileobj, filepath, filename, encoding=None):
257 """Instantiate and return the `Template` object based on the given
258 class and parameters.
259
260 This function is intended for subclasses to override if they need to
261 implement special template instantiation logic. Code that just uses
262 the `TemplateLoader` should use the `load` method instead.
263
264 :param cls: the class of the template object to instantiate
265 :param fileobj: a readable file-like object containing the template
266 source
267 :param filepath: the absolute path to the template file
268 :param filename: the path to the template file relative to the search
269 path
270 :param encoding: the encoding of the template to load; defaults to the
271 ``default_encoding`` of the loader instance
272 :return: the loaded `Template` instance
273 :rtype: `Template`
274 """
275 if encoding is None:
276 encoding = self.default_encoding
277 return cls(fileobj, filepath=filepath, filename=filename, loader=self,
278 encoding=encoding, lookup=self.variable_lookup,
279 allow_exec=self.allow_exec)
280
281 @staticmethod
282 def directory(path):
283 """Loader factory for loading templates from a local directory.
284
285 :param path: the path to the local directory containing the templates
286 :return: the loader function to load templates from the given directory
287 :rtype: ``function``
288 """
289 def _load_from_directory(filename):
290 filepath = os.path.join(path, filename)
291 fileobj = open(filepath, 'rb')
292 mtime = os.path.getmtime(filepath)
293 def _uptodate():
294 return mtime == os.path.getmtime(filepath)
295 return filepath, filename, fileobj, _uptodate
296 return _load_from_directory
297
298 @staticmethod
299 def package(name, path):
300 """Loader factory for loading templates from egg package data.
301
302 :param name: the name of the package containing the resources
303 :param path: the path inside the package data
304 :return: the loader function to load templates from the given package
305 :rtype: ``function``
306 """
307 def _load_from_package(filename):
308 filepath = os.path.join(path, filename)
309 return (
310 filepath,
311 filename,
312 resources_open_binary(name, filepath),
313 None,
314 )
315 return _load_from_package
316
317 @staticmethod
318 def prefixed(**delegates):
319 """Factory for a load function that delegates to other loaders
320 depending on the prefix of the requested template path.
321
322 The prefix is stripped from the filename when passing on the load
323 request to the delegate.
324
325 >>> load = prefixed(
326 ... app1 = lambda filename: ('app1', filename, None, None),
327 ... app2 = lambda filename: ('app2', filename, None, None)
328 ... )
329 >>> print(load('app1/foo.html'))
330 ('app1', 'app1/foo.html', None, None)
331 >>> print(load('app2/bar.html'))
332 ('app2', 'app2/bar.html', None, None)
333
334 :param delegates: mapping of path prefixes to loader functions
335 :return: the loader function
336 :rtype: ``function``
337 """
338 def _dispatch_by_prefix(filename):
339 for prefix, delegate in delegates.items():
340 if filename.startswith(prefix):
341 if isinstance(delegate, string_types):
342 delegate = directory(delegate)
343 filepath, _, fileobj, uptodate = delegate(
344 filename[len(prefix):].lstrip('/\\')
345 )
346 return filepath, filename, fileobj, uptodate
347 raise TemplateNotFound(filename, list(delegates.keys()))
348 return _dispatch_by_prefix
349
350
351directory = TemplateLoader.directory
352package = TemplateLoader.package
353prefixed = TemplateLoader.prefixed