29
29
import os
30
30
import re
31
31
32
+ try :
33
+ from sys import implementation
34
+
35
+ if implementation .name == "circuitpython" and implementation .version < (9 , 0 , 0 ):
36
+ print (
37
+ "Warning: adafruit_templateengine requires CircuitPython 9.0.0, as previous versions"
38
+ " will have limited functionality when using block comments and non-ASCII characters."
39
+ )
40
+ finally :
41
+ # Unimport sys to prevent accidental use
42
+ del implementation
43
+
32
44
33
45
class Language : # pylint: disable=too-few-public-methods
34
46
"""
@@ -59,12 +71,12 @@ def safe_html(value: Any) -> str:
59
71
# 1e−10
60
72
"""
61
73
62
- def replace_amp_or_semi (match : re .Match ):
74
+ def _replace_amp_or_semi (match : re .Match ):
63
75
return "&" if match .group (0 ) == "&" else ";"
64
76
65
77
return (
66
78
# Replace initial & and ; together
67
- re .sub (r"&|;" , replace_amp_or_semi , str (value ))
79
+ re .sub (r"&|;" , _replace_amp_or_semi , str (value ))
68
80
# Replace other characters
69
81
.replace ('"' , """ )
70
82
.replace ("_" , "_" )
@@ -152,47 +164,48 @@ def safe_markdown(value: Any) -> str:
152
164
)
153
165
154
166
155
- _PRECOMPILED_EXTENDS_PATTERN = re .compile (r"{% extends '.+?' %}|{% extends \".+?\" %}" )
156
- _PRECOMPILED_BLOCK_PATTERN = re .compile (r"{% block \w+? %}" )
157
- _PRECOMPILED_INCLUDE_PATTERN = re .compile (r"{% include '.+?' %}|{% include \".+?\" %}" )
158
- _PRECOMPILED_HASH_COMMENT_PATTERN = re .compile (r"{# .+? #}" )
159
- _PRECOMPILED_BLOCK_COMMENT_PATTERN = re .compile (
167
+ _EXTENDS_PATTERN = re .compile (r"{% extends '.+?' %}|{% extends \".+?\" %}" )
168
+ _BLOCK_PATTERN = re .compile (r"{% block \w+? %}" )
169
+ _INCLUDE_PATTERN = re .compile (r"{% include '.+?' %}|{% include \".+?\" %}" )
170
+ _HASH_COMMENT_PATTERN = re .compile (r"{# .+? #}" )
171
+ _BLOCK_COMMENT_PATTERN = re .compile (
160
172
r"{% comment ('.*?' |\".*?\" )?%}[\s\S]*?{% endcomment %}"
161
173
)
162
- _PRECOMPILED_TOKEN_PATTERN = re .compile (r"{{ .+? }}|{% .+? %}" )
174
+ _TOKEN_PATTERN = re .compile (r"{{ .+? }}|{% .+? %}" )
175
+ _LSTRIP_BLOCK_PATTERN = re .compile (r"\n( )+$" )
163
176
164
177
165
- def _find_next_extends (template : str ):
166
- return _PRECOMPILED_EXTENDS_PATTERN .search (template )
178
+ def _find_extends (template : str ):
179
+ return _EXTENDS_PATTERN .search (template )
167
180
168
181
169
- def _find_next_block (template : str ):
170
- return _PRECOMPILED_BLOCK_PATTERN .search (template )
182
+ def _find_block (template : str ):
183
+ return _BLOCK_PATTERN .search (template )
171
184
172
185
173
- def _find_next_include (template : str ):
174
- return _PRECOMPILED_INCLUDE_PATTERN .search (template )
186
+ def _find_include (template : str ):
187
+ return _INCLUDE_PATTERN .search (template )
175
188
176
189
177
190
def _find_named_endblock (template : str , name : str ):
178
191
return re .search (r"{% endblock " + name + r" %}" , template )
179
192
180
193
181
- def _exists_and_is_file (path : str ):
194
+ def _exists_and_is_file (path : str ) -> bool :
182
195
try :
183
196
return (os .stat (path )[0 ] & 0b_11110000_00000000 ) == 0b_10000000_00000000
184
197
except OSError :
185
198
return False
186
199
187
200
188
201
def _resolve_includes (template : str ):
189
- while (include_match := _find_next_include (template )) is not None :
202
+ while (include_match := _find_include (template )) is not None :
190
203
template_path = include_match .group (0 )[12 :- 4 ]
191
204
192
205
# TODO: Restrict include to specific directory
193
206
194
207
if not _exists_and_is_file (template_path ):
195
- raise FileNotFoundError (f"Include template not found: { template_path } " )
208
+ raise OSError (f"Include template not found: { template_path } " )
196
209
197
210
# Replace the include with the template content
198
211
with open (template_path , "rt" , encoding = "utf-8" ) as template_file :
@@ -205,15 +218,15 @@ def _resolve_includes(template: str):
205
218
206
219
207
220
def _check_for_unsupported_nested_blocks (template : str ):
208
- if _find_next_block (template ) is not None :
221
+ if _find_block (template ) is not None :
209
222
raise ValueError ("Nested blocks are not supported" )
210
223
211
224
212
225
def _resolve_includes_blocks_and_extends (template : str ):
213
226
block_replacements : "dict[str, str]" = {}
214
227
215
228
# Processing nested child templates
216
- while (extends_match := _find_next_extends (template )) is not None :
229
+ while (extends_match := _find_extends (template )) is not None :
217
230
extended_template_name = extends_match .group (0 )[12 :- 4 ]
218
231
219
232
# Load extended template
@@ -229,20 +242,15 @@ def _resolve_includes_blocks_and_extends(template: str):
229
242
template = _resolve_includes (template )
230
243
231
244
# Save block replacements
232
- while (block_match := _find_next_block (template )) is not None :
245
+ while (block_match := _find_block (template )) is not None :
233
246
block_name = block_match .group (0 )[9 :- 3 ]
234
247
235
248
endblock_match = _find_named_endblock (template , block_name )
236
249
237
250
if endblock_match is None :
238
251
raise ValueError (r"Missing {% endblock %} for block: " + block_name )
239
252
240
- # Workaround for bug in re module https://github.com/adafruit/circuitpython/issues/6860
241
- block_content = template .encode ("utf-8" )[
242
- block_match .end () : endblock_match .start ()
243
- ].decode ("utf-8" )
244
- # TODO: Uncomment when bug is fixed
245
- # block_content = template[block_match.end() : endblock_match.start()]
253
+ block_content = template [block_match .end () : endblock_match .start ()]
246
254
247
255
_check_for_unsupported_nested_blocks (block_content )
248
256
@@ -267,7 +275,7 @@ def _resolve_includes_blocks_and_extends(template: str):
267
275
268
276
def _replace_blocks_with_replacements (template : str , replacements : "dict[str, str]" ):
269
277
# Replace blocks in top-level template
270
- while (block_match := _find_next_block (template )) is not None :
278
+ while (block_match := _find_block (template )) is not None :
271
279
block_name = block_match .group (0 )[9 :- 3 ]
272
280
273
281
# Self-closing block tag without default content
@@ -309,34 +317,61 @@ def _replace_blocks_with_replacements(template: str, replacements: "dict[str, st
309
317
return template
310
318
311
319
312
- def _find_next_hash_comment (template : str ):
313
- return _PRECOMPILED_HASH_COMMENT_PATTERN .search (template )
320
+ def _find_hash_comment (template : str ):
321
+ return _HASH_COMMENT_PATTERN .search (template )
322
+
323
+
324
+ def _find_block_comment (template : str ):
325
+ return _BLOCK_COMMENT_PATTERN .search (template )
326
+
327
+
328
+ def _remove_comments (
329
+ template : str ,
330
+ * ,
331
+ trim_blocks : bool = True ,
332
+ lstrip_blocks : bool = True ,
333
+ ):
334
+ def _remove_matched_comment (template : str , comment_match : re .Match ):
335
+ text_before_comment = template [: comment_match .start ()]
336
+ text_after_comment = template [comment_match .end () :]
314
337
338
+ if text_before_comment :
339
+ if lstrip_blocks :
340
+ if _token_is_on_own_line (text_before_comment ):
341
+ text_before_comment = text_before_comment .rstrip (" " )
315
342
316
- def _find_next_block_comment (template : str ):
317
- return _PRECOMPILED_BLOCK_COMMENT_PATTERN .search (template )
343
+ if text_after_comment :
344
+ if trim_blocks :
345
+ if text_after_comment .startswith ("\n " ):
346
+ text_after_comment = text_after_comment [1 :]
318
347
348
+ return text_before_comment + text_after_comment
319
349
320
- def _remove_comments (template : str ):
321
350
# Remove hash comments: {# ... #}
322
- while (comment_match := _find_next_hash_comment (template )) is not None :
323
- template = template [: comment_match . start ()] + template [ comment_match . end () :]
351
+ while (comment_match := _find_hash_comment (template )) is not None :
352
+ template = _remove_matched_comment ( template , comment_match )
324
353
325
354
# Remove block comments: {% comment %} ... {% endcomment %}
326
- while (comment_match := _find_next_block_comment (template )) is not None :
327
- template = template [: comment_match . start ()] + template [ comment_match . end () :]
355
+ while (comment_match := _find_block_comment (template )) is not None :
356
+ template = _remove_matched_comment ( template , comment_match )
328
357
329
358
return template
330
359
331
360
332
- def _find_next_token (template : str ):
333
- return _PRECOMPILED_TOKEN_PATTERN .search (template )
361
+ def _find_token (template : str ):
362
+ return _TOKEN_PATTERN .search (template )
363
+
364
+
365
+ def _token_is_on_own_line (text_before_token : str ) -> bool :
366
+ return _LSTRIP_BLOCK_PATTERN .search (text_before_token ) is not None
334
367
335
368
336
369
def _create_template_function ( # pylint: disable=,too-many-locals,too-many-branches,too-many-statements
337
370
template : str ,
338
371
language : str = Language .HTML ,
339
372
* ,
373
+ trim_blocks : bool = True ,
374
+ lstrip_blocks : bool = True ,
340
375
function_name : str = "_" ,
341
376
context_name : str = "context" ,
342
377
dry_run : bool = False ,
@@ -351,22 +386,34 @@ def _create_template_function( # pylint: disable=,too-many-locals,too-many-bran
351
386
function_string = f"def { function_name } ({ context_name } ):\n "
352
387
indent , indentation_level = " " , 1
353
388
354
- # Keep track of the tempalte state
389
+ # Keep track of the template state
355
390
forloop_iterables : "list[str]" = []
356
391
autoescape_modes : "list[bool]" = ["default_on" ]
392
+ last_token_was_block = False
357
393
358
394
# Resolve tokens
359
- while (token_match := _find_next_token (template )) is not None :
395
+ while (token_match := _find_token (template )) is not None :
360
396
token = token_match .group (0 )
361
397
362
398
# Add the text before the token
363
399
if text_before_token := template [: token_match .start ()]:
364
- function_string += (
365
- indent * indentation_level + f"yield { repr (text_before_token )} \n "
366
- )
400
+ if lstrip_blocks and token .startswith (r"{% " ):
401
+ if _token_is_on_own_line (text_before_token ):
402
+ text_before_token = text_before_token .rstrip (" " )
403
+
404
+ if trim_blocks :
405
+ if last_token_was_block and text_before_token .startswith ("\n " ):
406
+ text_before_token = text_before_token [1 :]
407
+
408
+ if text_before_token :
409
+ function_string += (
410
+ indent * indentation_level + f"yield { repr (text_before_token )} \n "
411
+ )
367
412
368
413
# Token is an expression
369
414
if token .startswith (r"{{ " ):
415
+ last_token_was_block = False
416
+
370
417
autoescape = autoescape_modes [- 1 ] in ("on" , "default_on" )
371
418
372
419
# Expression should be escaped with language-specific function
@@ -383,6 +430,8 @@ def _create_template_function( # pylint: disable=,too-many-locals,too-many-bran
383
430
384
431
# Token is a statement
385
432
elif token .startswith (r"{% " ):
433
+ last_token_was_block = True
434
+
386
435
# Token is a some sort of if statement
387
436
if token .startswith (r"{% if " ):
388
437
function_string += indent * indentation_level + f"{ token [3 :- 3 ]} :\n "
@@ -449,9 +498,16 @@ def _create_template_function( # pylint: disable=,too-many-locals,too-many-bran
449
498
# Continue with the rest of the template
450
499
template = template [token_match .end () :]
451
500
452
- # Add the text after the last token (if any) and return
453
- if template :
454
- function_string += indent * indentation_level + f"yield { repr (template )} \n "
501
+ # Add the text after the last token (if any)
502
+ text_after_last_token = template
503
+
504
+ if text_after_last_token :
505
+ if trim_blocks and text_after_last_token .startswith ("\n " ):
506
+ text_after_last_token = text_after_last_token [1 :]
507
+
508
+ function_string += (
509
+ indent * indentation_level + f"yield { repr (text_after_last_token )} \n "
510
+ )
455
511
456
512
# If dry run, return the template function string
457
513
if dry_run :
0 commit comments