1# mako/lookup.py
2# Copyright 2006-2023 the Mako authors and contributors <see AUTHORS file>
3#
4# This module is part of Mako and is released under
5# the MIT License: http://www.opensource.org/licenses/mit-license.php
6
7import os
8import posixpath
9import re
10import stat
11import threading
12
13from mako import exceptions
14from mako import util
15from mako.template import Template
16
17
18class TemplateCollection:
19
20    """Represent a collection of :class:`.Template` objects,
21    identifiable via URI.
22
23    A :class:`.TemplateCollection` is linked to the usage of
24    all template tags that address other templates, such
25    as ``<%include>``, ``<%namespace>``, and ``<%inherit>``.
26    The ``file`` attribute of each of those tags refers
27    to a string URI that is passed to that :class:`.Template`
28    object's :class:`.TemplateCollection` for resolution.
29
30    :class:`.TemplateCollection` is an abstract class,
31    with the usual default implementation being :class:`.TemplateLookup`.
32
33    """
34
35    def has_template(self, uri):
36        """Return ``True`` if this :class:`.TemplateLookup` is
37        capable of returning a :class:`.Template` object for the
38        given ``uri``.
39
40        :param uri: String URI of the template to be resolved.
41
42        """
43        try:
44            self.get_template(uri)
45            return True
46        except exceptions.TemplateLookupException:
47            return False
48
49    def get_template(self, uri, relativeto=None):
50        """Return a :class:`.Template` object corresponding to the given
51        ``uri``.
52
53        The default implementation raises
54        :class:`.NotImplementedError`. Implementations should
55        raise :class:`.TemplateLookupException` if the given ``uri``
56        cannot be resolved.
57
58        :param uri: String URI of the template to be resolved.
59        :param relativeto: if present, the given ``uri`` is assumed to
60         be relative to this URI.
61
62        """
63        raise NotImplementedError()
64
65    def filename_to_uri(self, uri, filename):
66        """Convert the given ``filename`` to a URI relative to
67        this :class:`.TemplateCollection`."""
68
69        return uri
70
71    def adjust_uri(self, uri, filename):
72        """Adjust the given ``uri`` based on the calling ``filename``.
73
74        When this method is called from the runtime, the
75        ``filename`` parameter is taken directly to the ``filename``
76        attribute of the calling template. Therefore a custom
77        :class:`.TemplateCollection` subclass can place any string
78        identifier desired in the ``filename`` parameter of the
79        :class:`.Template` objects it constructs and have them come back
80        here.
81
82        """
83        return uri
84
85
86class TemplateLookup(TemplateCollection):
87
88    """Represent a collection of templates that locates template source files
89    from the local filesystem.
90
91    The primary argument is the ``directories`` argument, the list of
92    directories to search:
93
94    .. sourcecode:: python
95
96        lookup = TemplateLookup(["/path/to/templates"])
97        some_template = lookup.get_template("/index.html")
98
99    The :class:`.TemplateLookup` can also be given :class:`.Template` objects
100    programatically using :meth:`.put_string` or :meth:`.put_template`:
101
102    .. sourcecode:: python
103
104        lookup = TemplateLookup()
105        lookup.put_string("base.html", '''
106            <html><body>${self.next()}</body></html>
107        ''')
108        lookup.put_string("hello.html", '''
109            <%include file='base.html'/>
110
111            Hello, world !
112        ''')
113
114
115    :param directories: A list of directory names which will be
116     searched for a particular template URI. The URI is appended
117     to each directory and the filesystem checked.
118
119    :param collection_size: Approximate size of the collection used
120     to store templates. If left at its default of ``-1``, the size
121     is unbounded, and a plain Python dictionary is used to
122     relate URI strings to :class:`.Template` instances.
123     Otherwise, a least-recently-used cache object is used which
124     will maintain the size of the collection approximately to
125     the number given.
126
127    :param filesystem_checks: When at its default value of ``True``,
128     each call to :meth:`.TemplateLookup.get_template()` will
129     compare the filesystem last modified time to the time in
130     which an existing :class:`.Template` object was created.
131     This allows the :class:`.TemplateLookup` to regenerate a
132     new :class:`.Template` whenever the original source has
133     been updated. Set this to ``False`` for a very minor
134     performance increase.
135
136    :param modulename_callable: A callable which, when present,
137     is passed the path of the source file as well as the
138     requested URI, and then returns the full path of the
139     generated Python module file. This is used to inject
140     alternate schemes for Python module location. If left at
141     its default of ``None``, the built in system of generation
142     based on ``module_directory`` plus ``uri`` is used.
143
144    All other keyword parameters available for
145    :class:`.Template` are mirrored here. When new
146    :class:`.Template` objects are created, the keywords
147    established with this :class:`.TemplateLookup` are passed on
148    to each new :class:`.Template`.
149
150    """
151
152    def __init__(
153        self,
154        directories=None,
155        module_directory=None,
156        filesystem_checks=True,
157        collection_size=-1,
158        format_exceptions=False,
159        error_handler=None,
160        output_encoding=None,
161        encoding_errors="strict",
162        cache_args=None,
163        cache_impl="beaker",
164        cache_enabled=True,
165        cache_type=None,
166        cache_dir=None,
167        cache_url=None,
168        modulename_callable=None,
169        module_writer=None,
170        default_filters=None,
171        buffer_filters=(),
172        strict_undefined=False,
173        imports=None,
174        future_imports=None,
175        enable_loop=True,
176        input_encoding=None,
177        preprocessor=None,
178        lexer_cls=None,
179        include_error_handler=None,
180    ):
181        self.directories = [
182            posixpath.normpath(d) for d in util.to_list(directories, ())
183        ]
184        self.module_directory = module_directory
185        self.modulename_callable = modulename_callable
186        self.filesystem_checks = filesystem_checks
187        self.collection_size = collection_size
188
189        if cache_args is None:
190            cache_args = {}
191        # transfer deprecated cache_* args
192        if cache_dir:
193            cache_args.setdefault("dir", cache_dir)
194        if cache_url:
195            cache_args.setdefault("url", cache_url)
196        if cache_type:
197            cache_args.setdefault("type", cache_type)
198
199        self.template_args = {
200            "format_exceptions": format_exceptions,
201            "error_handler": error_handler,
202            "include_error_handler": include_error_handler,
203            "output_encoding": output_encoding,
204            "cache_impl": cache_impl,
205            "encoding_errors": encoding_errors,
206            "input_encoding": input_encoding,
207            "module_directory": module_directory,
208            "module_writer": module_writer,
209            "cache_args": cache_args,
210            "cache_enabled": cache_enabled,
211            "default_filters": default_filters,
212            "buffer_filters": buffer_filters,
213            "strict_undefined": strict_undefined,
214            "imports": imports,
215            "future_imports": future_imports,
216            "enable_loop": enable_loop,
217            "preprocessor": preprocessor,
218            "lexer_cls": lexer_cls,
219        }
220
221        if collection_size == -1:
222            self._collection = {}
223            self._uri_cache = {}
224        else:
225            self._collection = util.LRUCache(collection_size)
226            self._uri_cache = util.LRUCache(collection_size)
227        self._mutex = threading.Lock()
228
229    def get_template(self, uri):
230        """Return a :class:`.Template` object corresponding to the given
231        ``uri``.
232
233        .. note:: The ``relativeto`` argument is not supported here at
234           the moment.
235
236        """
237
238        try:
239            if self.filesystem_checks:
240                return self._check(uri, self._collection[uri])
241            else:
242                return self._collection[uri]
243        except KeyError as e:
244            u = re.sub(r"^\/+", "", uri)
245            for dir_ in self.directories:
246                # make sure the path seperators are posix - os.altsep is empty
247                # on POSIX and cannot be used.
248                dir_ = dir_.replace(os.path.sep, posixpath.sep)
249                srcfile = posixpath.normpath(posixpath.join(dir_, u))
250                if os.path.isfile(srcfile):
251                    return self._load(srcfile, uri)
252            else:
253                raise exceptions.TopLevelLookupException(
254                    "Can't locate template for uri %r" % uri
255                ) from e
256
257    def adjust_uri(self, uri, relativeto):
258        """Adjust the given ``uri`` based on the given relative URI."""
259
260        key = (uri, relativeto)
261        if key in self._uri_cache:
262            return self._uri_cache[key]
263
264        if uri[0] == "/":
265            v = self._uri_cache[key] = uri
266        elif relativeto is not None:
267            v = self._uri_cache[key] = posixpath.join(
268                posixpath.dirname(relativeto), uri
269            )
270        else:
271            v = self._uri_cache[key] = "/" + uri
272        return v
273
274    def filename_to_uri(self, filename):
275        """Convert the given ``filename`` to a URI relative to
276        this :class:`.TemplateCollection`."""
277
278        try:
279            return self._uri_cache[filename]
280        except KeyError:
281            value = self._relativeize(filename)
282            self._uri_cache[filename] = value
283            return value
284
285    def _relativeize(self, filename):
286        """Return the portion of a filename that is 'relative'
287        to the directories in this lookup.
288
289        """
290
291        filename = posixpath.normpath(filename)
292        for dir_ in self.directories:
293            if filename[0 : len(dir_)] == dir_:
294                return filename[len(dir_) :]
295        else:
296            return None
297
298    def _load(self, filename, uri):
299        self._mutex.acquire()
300        try:
301            try:
302                # try returning from collection one
303                # more time in case concurrent thread already loaded
304                return self._collection[uri]
305            except KeyError:
306                pass
307            try:
308                if self.modulename_callable is not None:
309                    module_filename = self.modulename_callable(filename, uri)
310                else:
311                    module_filename = None
312                self._collection[uri] = template = Template(
313                    uri=uri,
314                    filename=posixpath.normpath(filename),
315                    lookup=self,
316                    module_filename=module_filename,
317                    **self.template_args,
318                )
319                return template
320            except:
321                # if compilation fails etc, ensure
322                # template is removed from collection,
323                # re-raise
324                self._collection.pop(uri, None)
325                raise
326        finally:
327            self._mutex.release()
328
329    def _check(self, uri, template):
330        if template.filename is None:
331            return template
332
333        try:
334            template_stat = os.stat(template.filename)
335            if template.module._modified_time >= template_stat[stat.ST_MTIME]:
336                return template
337            self._collection.pop(uri, None)
338            return self._load(template.filename, uri)
339        except OSError as e:
340            self._collection.pop(uri, None)
341            raise exceptions.TemplateLookupException(
342                "Can't locate template for uri %r" % uri
343            ) from e
344
345    def put_string(self, uri, text):
346        """Place a new :class:`.Template` object into this
347        :class:`.TemplateLookup`, based on the given string of
348        ``text``.
349
350        """
351        self._collection[uri] = Template(
352            text, lookup=self, uri=uri, **self.template_args
353        )
354
355    def put_template(self, uri, template):
356        """Place a new :class:`.Template` object into this
357        :class:`.TemplateLookup`, based on the given
358        :class:`.Template` object.
359
360        """
361        self._collection[uri] = template
362