comparison piecrust/templating/jinjaengine.py @ 851:2c7e57d80bba

optimize: Don't load Jinja unless we need to.
author Ludovic Chabant <ludovic@chabant.com>
date Sat, 29 Apr 2017 21:42:22 -0700
parents 8f8bbb2e70e1
children 4850f8c21b6e
comparison
equal deleted inserted replaced
850:370e74941d32 851:2c7e57d80bba
1 import re
2 import time
3 import os.path 1 import os.path
4 import hashlib
5 import logging 2 import logging
6 import email.utils
7 import strict_rfc3339
8 from jinja2 import Environment, FileSystemLoader, TemplateNotFound
9 from jinja2.exceptions import TemplateSyntaxError
10 from jinja2.ext import Extension, Markup
11 from jinja2.lexer import Token, describe_token
12 from jinja2.nodes import CallBlock, Const
13 from compressinja.html import HtmlCompressor, StreamProcessContext
14 from pygments import highlight
15 from pygments.formatters import HtmlFormatter
16 from pygments.lexers import get_lexer_by_name, guess_lexer
17 from piecrust.data.paginator import Paginator
18 from piecrust.environment import AbortedSourceUseError 3 from piecrust.environment import AbortedSourceUseError
19 from piecrust.rendering import format_text
20 from piecrust.templating.base import (TemplateEngine, TemplateNotFoundError, 4 from piecrust.templating.base import (TemplateEngine, TemplateNotFoundError,
21 TemplatingError) 5 TemplatingError)
22 from piecrust.uriutil import multi_replace
23 6
24 7
25 logger = logging.getLogger(__name__) 8 logger = logging.getLogger(__name__)
26 9
27 10
30 ENGINE_NAMES = ['jinja', 'jinja2', 'j2', 'twig'] 13 ENGINE_NAMES = ['jinja', 'jinja2', 'j2', 'twig']
31 EXTENSIONS = ['html', 'jinja', 'jinja2', 'j2', 'twig'] 14 EXTENSIONS = ['html', 'jinja', 'jinja2', 'j2', 'twig']
32 15
33 def __init__(self): 16 def __init__(self):
34 self.env = None 17 self.env = None
18 self._jinja_syntax_error = None
19 self._jinja_not_found = None
35 20
36 def renderSegmentPart(self, path, seg_part, data): 21 def renderSegmentPart(self, path, seg_part, data):
37 self._ensureLoaded() 22 self._ensureLoaded()
38 23
39 if not _string_needs_render(seg_part.content): 24 if not _string_needs_render(seg_part.content):
40 return seg_part.content 25 return seg_part.content
41 26
42 part_path = _make_segment_part_path(path, seg_part.offset) 27 part_path = _make_segment_part_path(path, seg_part.offset)
43 self.env.loader.segment_parts_cache[part_path] = ( 28 self.env.loader.segment_parts_cache[part_path] = (
44 path, seg_part.content) 29 path, seg_part.content)
45 try: 30 try:
46 tpl = self.env.get_template(part_path) 31 tpl = self.env.get_template(part_path)
47 except TemplateSyntaxError as tse: 32 except self._jinja_syntax_error as tse:
48 raise self._getTemplatingError(tse, filename=path) 33 raise self._getTemplatingError(tse, filename=path)
49 except TemplateNotFound: 34 except self._jinja_not_found:
50 raise TemplateNotFoundError() 35 raise TemplateNotFoundError()
51 36
52 try: 37 try:
53 return tpl.render(data) 38 return tpl.render(data)
54 except TemplateSyntaxError as tse: 39 except self._jinja_syntax_error as tse:
55 raise self._getTemplatingError(tse) 40 raise self._getTemplatingError(tse)
56 except AbortedSourceUseError: 41 except AbortedSourceUseError:
57 raise 42 raise
58 except Exception as ex: 43 except Exception as ex:
59 if self.app.debug: 44 if self.app.debug:
70 for p in paths: 55 for p in paths:
71 try: 56 try:
72 tpl = self.env.get_template(p) 57 tpl = self.env.get_template(p)
73 rendered_path = p 58 rendered_path = p
74 break 59 break
75 except TemplateSyntaxError as tse: 60 except self._jinja_syntax_error as tse:
76 raise self._getTemplatingError(tse) 61 raise self._getTemplatingError(tse)
77 except TemplateNotFound: 62 except self._jinja_not_found:
78 pass 63 pass
79 64
80 if tpl is None: 65 if tpl is None:
81 raise TemplateNotFoundError() 66 raise TemplateNotFoundError()
82 67
83 try: 68 try:
84 return tpl.render(data) 69 return tpl.render(data)
85 except TemplateSyntaxError as tse: 70 except self._jinja_syntax_error as tse:
86 raise self._getTemplatingError(tse) 71 raise self._getTemplatingError(tse)
87 except AbortedSourceUseError: 72 except AbortedSourceUseError:
88 raise 73 raise
89 except Exception as ex: 74 except Exception as ex:
90 msg = "Error rendering Jinja markup" 75 msg = "Error rendering Jinja markup"
116 autoescape = self.app.config.get('jinja/auto_escape', True) 101 autoescape = self.app.config.get('jinja/auto_escape', True)
117 if autoescape: 102 if autoescape:
118 ext_names.append('autoescape') 103 ext_names.append('autoescape')
119 104
120 # Create the final list of extensions. 105 # Create the final list of extensions.
106 from piecrust.templating.jinja.extensions import (
107 PieCrustHighlightExtension, PieCrustCacheExtension,
108 PieCrustSpacelessExtension, PieCrustFormatExtension)
121 extensions = [ 109 extensions = [
122 PieCrustHighlightExtension, 110 PieCrustHighlightExtension,
123 PieCrustCacheExtension, 111 PieCrustCacheExtension,
124 PieCrustSpacelessExtension, 112 PieCrustSpacelessExtension,
125 PieCrustFormatExtension] 113 PieCrustFormatExtension]
126 for n in ext_names: 114 for n in ext_names:
127 if '.' not in n: 115 if '.' not in n:
128 n = 'jinja2.ext.' + n 116 n = 'jinja2.ext.' + n
129 extensions.append(n) 117 extensions.append(n)
130 for je in self.app.plugin_loader.getTemplateEngineExtensions('jinja'): 118 for je in self.app.plugin_loader.getTemplateEngineExtensions('jinja'):
131 extensions.append(je) 119 extensions.append(je)
132 120
133 # Create the Jinja environment. 121 # Create the Jinja environment.
134 logger.debug("Creating Jinja environment with folders: %s" % 122 logger.debug("Creating Jinja environment with folders: %s" %
135 self.app.templates_dirs) 123 self.app.templates_dirs)
124 from piecrust.templating.jinja.loader import PieCrustLoader
136 loader = PieCrustLoader(self.app.templates_dirs) 125 loader = PieCrustLoader(self.app.templates_dirs)
126 from piecrust.templating.jinja.environment import PieCrustEnvironment
137 self.env = PieCrustEnvironment( 127 self.env = PieCrustEnvironment(
138 self.app, 128 self.app,
139 loader=loader, 129 loader=loader,
140 extensions=extensions) 130 extensions=extensions)
131
132 # Get types we need later.
133 from jinja2 import TemplateNotFound
134 from jinja2.exceptions import TemplateSyntaxError
135 self._jinja_syntax_error = TemplateSyntaxError
136 self._jinja_not_found = TemplateNotFound
141 137
142 138
143 def _string_needs_render(txt): 139 def _string_needs_render(txt):
144 index = txt.find('{') 140 index = txt.find('{')
145 while index >= 0: 141 while index >= 0:
152 148
153 def _make_segment_part_path(path, start): 149 def _make_segment_part_path(path, start):
154 return '$part=%s:%d' % (path, start) 150 return '$part=%s:%d' % (path, start)
155 151
156 152
157 class PieCrustLoader(FileSystemLoader):
158 def __init__(self, searchpath, encoding='utf-8'):
159 super(PieCrustLoader, self).__init__(searchpath, encoding)
160 self.segment_parts_cache = {}
161
162 def get_source(self, environment, template):
163 if template.startswith('$part='):
164 filename, seg_part = self.segment_parts_cache[template]
165
166 mtime = os.path.getmtime(filename)
167
168 def uptodate():
169 try:
170 return os.path.getmtime(filename) == mtime
171 except OSError:
172 return False
173
174 return seg_part, filename, uptodate
175
176 return super(PieCrustLoader, self).get_source(environment, template)
177
178
179 class PieCrustEnvironment(Environment):
180 def __init__(self, app, *args, **kwargs):
181 self.app = app
182
183 # Before we create the base Environement, let's figure out the options
184 # we want to pass to it.
185 twig_compatibility_mode = app.config.get('jinja/twig_compatibility')
186
187 # Disable auto-reload when we're baking.
188 if app.config.get('baker/is_baking'):
189 kwargs.setdefault('auto_reload', False)
190
191 # Let the user override most Jinja options via the site config.
192 for name in ['block_start_string', 'block_end_string',
193 'variable_start_string', 'variable_end_string',
194 'comment_start_string', 'comment_end_string',
195 'line_statement_prefix', 'line_comment_prefix',
196 'trim_blocks', 'lstrip_blocks',
197 'newline_sequence', 'keep_trailing_newline']:
198 val = app.config.get('jinja/' + name)
199 if val is not None:
200 kwargs.setdefault(name, val)
201
202 # Twig trims blocks.
203 if twig_compatibility_mode is True:
204 kwargs['trim_blocks'] = True
205
206 # All good! Create the Environment.
207 super(PieCrustEnvironment, self).__init__(*args, **kwargs)
208
209 # Now add globals and filters.
210 self.globals.update({
211 'now': get_now_date(),
212 'fail': raise_exception,
213 'highlight_css': get_highlight_css})
214
215 self.filters.update({
216 'keys': get_dict_keys,
217 'values': get_dict_values,
218 'paginate': self._paginate,
219 'formatwith': self._formatWith,
220 'markdown': lambda v: self._formatWith(v, 'markdown'),
221 'textile': lambda v: self._formatWith(v, 'textile'),
222 'nocache': add_no_cache_parameter,
223 'wordcount': get_word_count,
224 'stripoutertag': strip_outer_tag,
225 'stripslash': strip_slash,
226 'titlecase': title_case,
227 'md5': make_md5,
228 'atomdate': get_xml_date,
229 'xmldate': get_xml_date,
230 'emaildate': get_email_date,
231 'date': get_date})
232
233 # Backwards compatibility with Twig.
234 if twig_compatibility_mode is True:
235 self.filters['raw'] = self.filters['safe']
236 self.globals['pcfail'] = raise_exception
237
238 def _paginate(self, value, items_per_page=5):
239 cpi = self.app.env.exec_info_stack.current_page_info
240 if cpi is None or cpi.page is None or cpi.render_ctx is None:
241 raise Exception("Can't paginate when no page has been pushed "
242 "on the execution stack.")
243 return Paginator(cpi.page, value,
244 page_num=cpi.render_ctx.page_num,
245 items_per_page=items_per_page)
246
247 def _formatWith(self, value, format_name):
248 return format_text(self.app, format_name, value)
249
250
251 def raise_exception(msg):
252 raise Exception(msg)
253
254
255 def get_dict_keys(value):
256 if isinstance(value, list):
257 return [i[0] for i in value]
258 return value.keys()
259
260
261 def get_dict_values(value):
262 if isinstance(value, list):
263 return [i[1] for i in value]
264 return value.values()
265
266
267 def add_no_cache_parameter(value, param_name='t', param_value=None):
268 if not param_value:
269 param_value = time.time()
270 if '?' in value:
271 value += '&'
272 else:
273 value += '?'
274 value += '%s=%s' % (param_name, param_value)
275 return value
276
277
278 def get_word_count(value):
279 return len(value.split())
280
281
282 def strip_outer_tag(value, tag=None):
283 tag_pattern = '[a-z]+[a-z0-9]*'
284 if tag is not None:
285 tag_pattern = re.escape(tag)
286 pat = r'^\<' + tag_pattern + r'\>(.*)\</' + tag_pattern + '>$'
287 m = re.match(pat, value)
288 if m:
289 return m.group(1)
290 return value
291
292
293 def strip_slash(value):
294 return value.rstrip('/')
295
296
297 def title_case(value):
298 return value.title()
299
300
301 def make_md5(value):
302 return hashlib.md5(value.lower().encode('utf8')).hexdigest()
303
304
305 def get_xml_date(value):
306 """ Formats timestamps like 1985-04-12T23:20:50.52Z
307 """
308 if value == 'now':
309 value = time.time()
310 return strict_rfc3339.timestamp_to_rfc3339_localoffset(int(value))
311
312
313 def get_email_date(value, localtime=False):
314 """ Formats timestamps like Fri, 09 Nov 2001 01:08:47 -0000
315 """
316 if value == 'now':
317 value = time.time()
318 return email.utils.formatdate(value, localtime=localtime)
319
320
321 def get_now_date():
322 return time.time()
323
324
325 def get_date(value, fmt):
326 if value == 'now':
327 value = time.time()
328 if '%' not in fmt:
329 suggest = php_format_to_strftime_format(fmt)
330 if suggest != fmt:
331 suggest_message = ("You probably want a format that looks "
332 "like: '%s'." % suggest)
333 else:
334 suggest_message = ("We can't suggest a proper date format "
335 "for you right now, though.")
336 raise Exception("Got incorrect date format: '%s\n"
337 "PieCrust 1 date formats won't work in PieCrust 2. "
338 "%s\n"
339 "Please check the `strftime` formatting page here: "
340 "https://docs.python.org/3/library/datetime.html"
341 "#strftime-and-strptime-behavior" %
342 (fmt, suggest_message))
343 return time.strftime(fmt, time.localtime(value))
344
345
346 class PieCrustFormatExtension(Extension):
347 tags = set(['pcformat'])
348
349 def __init__(self, environment):
350 super(PieCrustFormatExtension, self).__init__(environment)
351
352 def parse(self, parser):
353 lineno = next(parser.stream).lineno
354 args = [parser.parse_expression()]
355 body = parser.parse_statements(['name:endpcformat'], drop_needle=True)
356 return CallBlock(self.call_method('_format', args),
357 [], [], body).set_lineno(lineno)
358
359 def _format(self, format_name, caller=None):
360 body = caller()
361 text = format_text(self.environment.app,
362 format_name,
363 Markup(body.rstrip()).unescape(),
364 exact_format=True)
365 return text
366
367
368 class PieCrustHighlightExtension(Extension):
369 tags = set(['highlight', 'geshi'])
370
371 def __init__(self, environment):
372 super(PieCrustHighlightExtension, self).__init__(environment)
373
374 def parse(self, parser):
375 lineno = next(parser.stream).lineno
376
377 # Extract the language name.
378 args = [parser.parse_expression()]
379
380 # Extract optional arguments.
381 kwarg_names = {'line_numbers': 0, 'use_classes': 0, 'class': 1,
382 'id': 1}
383 kwargs = {}
384 while not parser.stream.current.test('block_end'):
385 name = parser.stream.expect('name')
386 if name.value not in kwarg_names:
387 raise Exception("'%s' is not a valid argument for the code "
388 "highlighting tag." % name.value)
389 if kwarg_names[name.value] == 0:
390 kwargs[name.value] = Const(True)
391 elif parser.stream.skip_if('assign'):
392 kwargs[name.value] = parser.parse_expression()
393
394 # body of the block
395 body = parser.parse_statements(['name:endhighlight', 'name:endgeshi'],
396 drop_needle=True)
397
398 return CallBlock(self.call_method('_highlight', args, kwargs),
399 [], [], body).set_lineno(lineno)
400
401 def _highlight(self, lang, line_numbers=False, use_classes=False,
402 css_class=None, css_id=None, caller=None):
403 # Try to be mostly compatible with Jinja2-highlight's settings.
404 body = caller()
405
406 if lang is None:
407 lexer = guess_lexer(body)
408 else:
409 lexer = get_lexer_by_name(lang, stripall=False)
410
411 if css_class is None:
412 try:
413 css_class = self.environment.jinja2_highlight_cssclass
414 except AttributeError:
415 pass
416
417 if css_class is not None:
418 formatter = HtmlFormatter(cssclass=css_class,
419 linenos=line_numbers)
420 else:
421 formatter = HtmlFormatter(linenos=line_numbers)
422
423 code = highlight(Markup(body.rstrip()).unescape(), lexer, formatter)
424 return code
425
426
427 def get_highlight_css(style_name='default', class_name='.highlight'):
428 return HtmlFormatter(style=style_name).get_style_defs(class_name)
429
430
431 class PieCrustCacheExtension(Extension):
432 tags = set(['pccache', 'cache'])
433
434 def __init__(self, environment):
435 super(PieCrustCacheExtension, self).__init__(environment)
436 environment.extend(
437 piecrust_cache_prefix='',
438 piecrust_cache={}
439 )
440
441 def parse(self, parser):
442 # the first token is the token that started the tag. In our case
443 # we only listen to ``'pccache'`` so this will be a name token with
444 # `pccache` as value. We get the line number so that we can give
445 # that line number to the nodes we create by hand.
446 lineno = next(parser.stream).lineno
447
448 # now we parse a single expression that is used as cache key.
449 args = [parser.parse_expression()]
450
451 # now we parse the body of the cache block up to `endpccache` and
452 # drop the needle (which would always be `endpccache` in that case)
453 body = parser.parse_statements(['name:endpccache', 'name:endcache'],
454 drop_needle=True)
455
456 # now return a `CallBlock` node that calls our _cache_support
457 # helper method on this extension.
458 return CallBlock(self.call_method('_cache_support', args),
459 [], [], body).set_lineno(lineno)
460
461 def _cache_support(self, name, caller):
462 key = self.environment.piecrust_cache_prefix + name
463
464 exc_stack = self.environment.app.env.exec_info_stack
465 render_ctx = exc_stack.current_page_info.render_ctx
466 rdr_pass = render_ctx.current_pass_info
467
468 # try to load the block from the cache
469 # if there is no fragment in the cache, render it and store
470 # it in the cache.
471 pair = self.environment.piecrust_cache.get(key)
472 if pair is not None:
473 rdr_pass.used_source_names.update(pair[1])
474 return pair[0]
475
476 pair = self.environment.piecrust_cache.get(key)
477 if pair is not None:
478 rdr_pass.used_source_names.update(pair[1])
479 return pair[0]
480
481 prev_used = rdr_pass.used_source_names.copy()
482 rv = caller()
483 after_used = rdr_pass.used_source_names.copy()
484 used_delta = after_used.difference(prev_used)
485 self.environment.piecrust_cache[key] = (rv, used_delta)
486 return rv
487
488
489 class PieCrustSpacelessExtension(HtmlCompressor):
490 """ A re-implementation of `SelectiveHtmlCompressor` so that we can
491 both use `strip` or `spaceless` in templates.
492 """
493 def filter_stream(self, stream):
494 ctx = StreamProcessContext(stream)
495 strip_depth = 0
496 while 1:
497 if stream.current.type == 'block_begin':
498 for tk in ['strip', 'spaceless']:
499 change = self._processToken(ctx, stream, tk)
500 if change != 0:
501 strip_depth += change
502 if strip_depth < 0:
503 ctx.fail('Unexpected tag end%s' % tk)
504 break
505 if strip_depth > 0 and stream.current.type == 'data':
506 ctx.token = stream.current
507 value = self.normalize(ctx)
508 yield Token(stream.current.lineno, 'data', value)
509 else:
510 yield stream.current
511 next(stream)
512
513 def _processToken(self, ctx, stream, test_token):
514 change = 0
515 if (stream.look().test('name:%s' % test_token) or
516 stream.look().test('name:end%s' % test_token)):
517 stream.skip()
518 if stream.current.value == test_token:
519 change = 1
520 else:
521 change = -1
522 stream.skip()
523 if stream.current.type != 'block_end':
524 ctx.fail('expected end of block, got %s' %
525 describe_token(stream.current))
526 stream.skip()
527 return change
528
529
530 def php_format_to_strftime_format(fmt):
531 replacements = {
532 'd': '%d',
533 'D': '%a',
534 'j': '%d',
535 'l': '%A',
536 'w': '%w',
537 'z': '%j',
538 'W': '%W',
539 'F': '%B',
540 'm': '%m',
541 'M': '%b',
542 'n': '%m',
543 'y': '%Y',
544 'Y': '%y',
545 'g': '%I',
546 'G': '%H',
547 'h': '%I',
548 'H': '%H',
549 'i': '%M',
550 's': '%S',
551 'e': '%Z',
552 'O': '%z',
553 'c': '%Y-%m-%dT%H:%M:%SZ'}
554 return multi_replace(fmt, replacements)
555