Mercurial > piecrust2
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 |