python-liquid 2.3.6__py3-none-any.whl → 3.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) hide show
  1. liquid/__about__.py +1 -0
  2. liquid/__init__.py +161 -187
  3. liquid/_base_tag.py +66 -0
  4. liquid/{utils/chain_map.py → _chain_map.py} +18 -19
  5. liquid/_context.py +465 -0
  6. liquid/_drop.py +108 -0
  7. liquid/_filter.py +174 -0
  8. liquid/_io.py +18 -0
  9. liquid/_lax_parser_mixin.py +100 -0
  10. liquid/_lexer.py +93 -0
  11. liquid/_lexer_extended.py +623 -0
  12. liquid/_lexer_legacy.py +536 -0
  13. liquid/_loader.py +84 -0
  14. liquid/{utils/lru_cache.py → _lru_cache.py} +28 -36
  15. liquid/_markup.py +253 -0
  16. liquid/_messages.py +292 -0
  17. liquid/_missing.py +3 -0
  18. liquid/_nothing.py +28 -0
  19. liquid/_parser.py +460 -0
  20. liquid/_parser_extended.py +293 -0
  21. liquid/_parser_legacy.py +819 -0
  22. liquid/_resource_limits.py +50 -0
  23. liquid/_slice.py +41 -0
  24. liquid/_static_analysis.py +503 -0
  25. liquid/_strftime.py +186 -0
  26. liquid/_template.py +536 -0
  27. liquid/drops/__init__.py +20 -0
  28. liquid/drops/forloop.py +78 -0
  29. liquid/drops/iterable.py +25 -0
  30. liquid/drops/range.py +76 -0
  31. liquid/drops/self_.py +45 -0
  32. liquid/drops/tablerowloop.py +142 -0
  33. liquid/drops/undefined.py +228 -0
  34. liquid/environment.py +717 -551
  35. liquid/exceptions.py +67 -214
  36. liquid/expressions.py +1184 -0
  37. liquid/filters/__init__.py +134 -0
  38. liquid/filters/abs.py +7 -0
  39. liquid/filters/append.py +5 -0
  40. liquid/filters/at_least.py +9 -0
  41. liquid/filters/at_most.py +9 -0
  42. liquid/filters/babel.py +560 -0
  43. liquid/filters/base64.py +26 -0
  44. liquid/filters/capitalize.py +5 -0
  45. liquid/filters/ceil.py +8 -0
  46. liquid/filters/compact.py +25 -0
  47. liquid/filters/concat.py +11 -0
  48. liquid/filters/date.py +27 -0
  49. liquid/filters/default.py +25 -0
  50. liquid/filters/divided_by.py +21 -0
  51. liquid/filters/downcase.py +5 -0
  52. liquid/filters/escape.py +56 -0
  53. liquid/filters/find.py +56 -0
  54. liquid/filters/first.py +19 -0
  55. liquid/filters/floor.py +8 -0
  56. liquid/filters/html.py +23 -0
  57. liquid/filters/index.py +12 -0
  58. liquid/filters/join.py +13 -0
  59. liquid/filters/json_.py +30 -0
  60. liquid/filters/last.py +17 -0
  61. liquid/filters/map.py +5 -0
  62. liquid/filters/minus.py +13 -0
  63. liquid/filters/modulo.py +18 -0
  64. liquid/filters/newline_to_br.py +16 -0
  65. liquid/filters/plus.py +13 -0
  66. liquid/filters/prepend.py +5 -0
  67. liquid/filters/reject.py +19 -0
  68. liquid/filters/remove.py +36 -0
  69. liquid/filters/replace.py +32 -0
  70. liquid/filters/reverse.py +5 -0
  71. liquid/filters/round.py +35 -0
  72. liquid/filters/size.py +25 -0
  73. liquid/filters/slice.py +39 -0
  74. liquid/filters/sort.py +148 -0
  75. liquid/filters/split.py +34 -0
  76. liquid/filters/squish.py +9 -0
  77. liquid/filters/strip.py +13 -0
  78. liquid/filters/strip_html.py +19 -0
  79. liquid/filters/strip_newlines.py +17 -0
  80. liquid/filters/sum.py +21 -0
  81. liquid/filters/times.py +13 -0
  82. liquid/filters/translate.py +380 -0
  83. liquid/filters/truncate.py +39 -0
  84. liquid/filters/uniq.py +11 -0
  85. liquid/filters/upcase.py +5 -0
  86. liquid/filters/url.py +18 -0
  87. liquid/filters/where.py +18 -0
  88. liquid/loaders/__init__.py +17 -0
  89. liquid/loaders/caching_file_system_loader.py +64 -0
  90. liquid/loaders/choice_loader.py +93 -0
  91. liquid/loaders/dict_loader.py +56 -0
  92. liquid/{builtin/loaders → loaders}/file_system_loader.py +51 -55
  93. liquid/{builtin/loaders → loaders}/mixins.py +39 -51
  94. liquid/loaders/package_loader.py +110 -0
  95. liquid/tags/__init__.py +61 -0
  96. liquid/tags/assign.py +68 -0
  97. liquid/tags/capture.py +83 -0
  98. liquid/tags/case.py +199 -0
  99. liquid/tags/comment.py +56 -0
  100. liquid/tags/cycle.py +131 -0
  101. liquid/tags/decrement.py +55 -0
  102. liquid/tags/doc.py +56 -0
  103. liquid/tags/echo.py +65 -0
  104. liquid/tags/else_.py +53 -0
  105. liquid/tags/error.py +51 -0
  106. liquid/tags/extends.py +448 -0
  107. liquid/tags/for_.py +421 -0
  108. liquid/tags/if_.py +156 -0
  109. liquid/tags/ifchanged.py +69 -0
  110. liquid/tags/include.py +223 -0
  111. liquid/tags/increment.py +55 -0
  112. liquid/tags/inline_comment.py +70 -0
  113. liquid/tags/liquid.py +56 -0
  114. liquid/tags/macro.py +281 -0
  115. liquid/tags/output.py +58 -0
  116. liquid/tags/raw.py +60 -0
  117. liquid/tags/render.py +245 -0
  118. liquid/tags/tablerow.py +254 -0
  119. liquid/tags/template_comment.py +56 -0
  120. liquid/tags/translate.py +316 -0
  121. liquid/tags/unless.py +121 -0
  122. liquid/tags/with_.py +83 -0
  123. liquid/tokens.py +370 -0
  124. {python_liquid-2.3.6.dist-info → python_liquid-3.0.0.dist-info}/METADATA +32 -39
  125. python_liquid-3.0.0.dist-info/RECORD +128 -0
  126. {python_liquid-2.3.6.dist-info → python_liquid-3.0.0.dist-info}/WHEEL +1 -1
  127. {python_liquid-2.3.6.dist-info → python_liquid-3.0.0.dist-info}/licenses/LICENSE +1 -1
  128. liquid/analyze_tags.py +0 -214
  129. liquid/ast.py +0 -252
  130. liquid/builtin/__init__.py +0 -203
  131. liquid/builtin/content.py +0 -41
  132. liquid/builtin/drops/__init__.py +0 -3
  133. liquid/builtin/drops/drops.py +0 -13
  134. liquid/builtin/expressions/__init__.py +0 -47
  135. liquid/builtin/expressions/_tokenize.py +0 -186
  136. liquid/builtin/expressions/arguments.py +0 -207
  137. liquid/builtin/expressions/filtered.py +0 -387
  138. liquid/builtin/expressions/logical.py +0 -672
  139. liquid/builtin/expressions/loop.py +0 -275
  140. liquid/builtin/expressions/path.py +0 -163
  141. liquid/builtin/expressions/primitive.py +0 -417
  142. liquid/builtin/filters/__init__.py +0 -0
  143. liquid/builtin/filters/array.py +0 -329
  144. liquid/builtin/filters/extra.py +0 -83
  145. liquid/builtin/filters/math.py +0 -129
  146. liquid/builtin/filters/misc.py +0 -137
  147. liquid/builtin/filters/string.py +0 -416
  148. liquid/builtin/illegal.py +0 -28
  149. liquid/builtin/loaders/__init__.py +0 -0
  150. liquid/builtin/loaders/caching_file_system_loader.py +0 -61
  151. liquid/builtin/loaders/choice_loader.py +0 -94
  152. liquid/builtin/loaders/dict_loader.py +0 -64
  153. liquid/builtin/loaders/package_loader.py +0 -132
  154. liquid/builtin/output.py +0 -74
  155. liquid/builtin/tags/__init__.py +0 -0
  156. liquid/builtin/tags/assign_tag.py +0 -75
  157. liquid/builtin/tags/capture_tag.py +0 -98
  158. liquid/builtin/tags/case_tag.py +0 -287
  159. liquid/builtin/tags/comment_tag.py +0 -77
  160. liquid/builtin/tags/cycle_tag.py +0 -132
  161. liquid/builtin/tags/decrement_tag.py +0 -59
  162. liquid/builtin/tags/doc_tag.py +0 -68
  163. liquid/builtin/tags/echo_tag.py +0 -45
  164. liquid/builtin/tags/for_tag.py +0 -337
  165. liquid/builtin/tags/if_tag.py +0 -209
  166. liquid/builtin/tags/ifchanged_tag.py +0 -94
  167. liquid/builtin/tags/include_tag.py +0 -251
  168. liquid/builtin/tags/increment_tag.py +0 -60
  169. liquid/builtin/tags/inline_comment_tag.py +0 -47
  170. liquid/builtin/tags/liquid_tag.py +0 -183
  171. liquid/builtin/tags/render_tag.py +0 -371
  172. liquid/builtin/tags/tablerow_tag.py +0 -320
  173. liquid/builtin/tags/unless_tag.py +0 -218
  174. liquid/context.py +0 -615
  175. liquid/expression.py +0 -41
  176. liquid/extra/__init__.py +0 -98
  177. liquid/extra/filters/__init__.py +0 -33
  178. liquid/extra/filters/_json.py +0 -33
  179. liquid/extra/filters/array.py +0 -60
  180. liquid/extra/filters/babel.py +0 -552
  181. liquid/extra/filters/html.py +0 -32
  182. liquid/extra/filters/translate.py +0 -448
  183. liquid/extra/tags/__init__.py +0 -17
  184. liquid/extra/tags/_with.py +0 -80
  185. liquid/extra/tags/extends_tag.py +0 -576
  186. liquid/extra/tags/macro_tag.py +0 -270
  187. liquid/extra/tags/snippet_tag.py +0 -107
  188. liquid/extra/tags/translate_tag.py +0 -388
  189. liquid/filter.py +0 -230
  190. liquid/future/__init__.py +0 -3
  191. liquid/future/environment.py +0 -30
  192. liquid/future/filters/__init__.py +0 -1
  193. liquid/future/tags/__init__.py +0 -1
  194. liquid/lex.py +0 -260
  195. liquid/limits.py +0 -66
  196. liquid/loader.py +0 -141
  197. liquid/messages.py +0 -357
  198. liquid/mode.py +0 -12
  199. liquid/output.py +0 -35
  200. liquid/parser.py +0 -119
  201. liquid/span.py +0 -35
  202. liquid/static_analysis.py +0 -443
  203. liquid/stream.py +0 -148
  204. liquid/stringify.py +0 -31
  205. liquid/tag.py +0 -47
  206. liquid/template.py +0 -658
  207. liquid/token.py +0 -145
  208. liquid/undefined.py +0 -218
  209. liquid/utils/__init__.py +0 -15
  210. liquid/utils/html.py +0 -57
  211. liquid/utils/text.py +0 -28
  212. python_liquid-2.3.6.dist-info/RECORD +0 -96
@@ -0,0 +1,50 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass(slots=True, kw_only=True)
5
+ class ResourceLimits:
6
+ """
7
+ Parameters
8
+ ----------
9
+ max_assign_score
10
+ The maximum assign score allowed per template before a
11
+ :class:`liquid.ResourceLimitError` is raised.
12
+ max_assign_score_cumulative
13
+ The maximum combined assign score allowed for a template and all its descendant
14
+ partial templates before a :class:`liquid.ResourceLimitError` is raised.
15
+ max_render_score
16
+ The maximum render score allowed per template before a
17
+ :class:`liquid.ResourceLimitError` is raised.
18
+ max_render_score_cumulative
19
+ The maximum combined render score allowed for a template and all its descendant
20
+ partial templates before a :class:`liquid.ResourceLimitError` is raised.
21
+ max_render_size
22
+ The maximum allowed length (in code points) of the output buffer per template
23
+ before a :class:`liquid.ResourceLimitError` is raised.
24
+ max_context_depth
25
+ The maximum number of times a render context can be extended or copied before
26
+ a :class:`liquid.ResourceLimitError` is raised. This helps us guard against
27
+ circular ``{% render %}`` and ``{% include %}`` tags.
28
+ max_loop_iterations
29
+ The maximum number of loop iterations allowed before a
30
+ :class:`liquid.ResourceLimitError` is raised, taking into account nested loops
31
+ that could span multiple partial templates.
32
+ max_block_depth
33
+ The maximum block nesting depth allowed before a :class:`liquid.ResourceLimitError`
34
+ is raised. This guards against specially crafted templates that could hit
35
+ Python's recursion limit during the parsing phase.
36
+ max_expression_depth
37
+ The maximum expression nesting depth allowed before a
38
+ :class:`liquid.ResourceLimitError` is raised. This guards against specially crafted
39
+ expressions that could cause our recursive parser to hit Python's recursion limit.
40
+ """
41
+
42
+ max_assign_score: int | None = None
43
+ max_assign_score_cumulative: int | None = None
44
+ max_render_score: int | None = None
45
+ max_render_score_cumulative: int | None = None
46
+ max_render_size: int | None = None
47
+ max_context_depth: int = 30
48
+ max_loop_iterations: int | None = None
49
+ max_block_depth: int = 30
50
+ max_expression_depth: int = 100
liquid/_slice.py ADDED
@@ -0,0 +1,41 @@
1
+ from collections.abc import Iterator, Sequence
2
+
3
+ from .exceptions import LiquidValueError
4
+
5
+
6
+ def slice_sequence[T](
7
+ seq: Sequence[T], offset: int, limit: int | None, reversed_: bool = False
8
+ ) -> tuple[Iterator[T], int]:
9
+ if limit is not None and limit <= 0:
10
+ return iter(()), 0
11
+
12
+ start = max(0, offset)
13
+ end = len(seq) if limit is None else max(0, offset + limit)
14
+ indices = range(start, min(len(seq), end))
15
+ length = len(indices)
16
+
17
+ def it_() -> Iterator[T]:
18
+ for i in reversed(indices) if reversed_ else indices:
19
+ yield seq[i]
20
+
21
+ return it_(), length
22
+
23
+
24
+ def slice_range(
25
+ it: range, offset: int, limit: int | None, reversed_: bool = False
26
+ ) -> range:
27
+ if limit is not None and limit <= 0:
28
+ return range(0)
29
+
30
+ stop_inclusive = it.stop + (1 if it.step > 0 else -1)
31
+ full_range = range(it.start, stop_inclusive, it.step)
32
+
33
+ start = max(0, offset)
34
+
35
+ try:
36
+ end = len(full_range) if limit is None else max(0, offset + limit)
37
+ except OverflowError:
38
+ raise LiquidValueError("range literal length limit reached")
39
+
40
+ sliced = full_range[start:end]
41
+ return sliced[::-1] if reversed_ else sliced
@@ -0,0 +1,503 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections import defaultdict
5
+ from dataclasses import dataclass
6
+ from itertools import chain
7
+ from typing import TYPE_CHECKING, TypedDict
8
+
9
+ from . import expressions
10
+ from ._context import RenderContext
11
+ from ._markup import SCOPE_ISOLATED
12
+ from .exceptions import LiquidError
13
+ from .tokens import span, token_value
14
+
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Iterable
17
+
18
+ from ._markup import Expression, Markup
19
+ from ._template import Template
20
+ from .tokens import Token
21
+
22
+
23
+ @dataclass(slots=True, kw_only=True)
24
+ class _Location:
25
+ template: Template
26
+ token: Token
27
+
28
+ def line_col(self, index: int) -> tuple[int, int]:
29
+ cumulative_length = 0
30
+ target_line_index = -1
31
+
32
+ for i, line in enumerate(self.template.lines):
33
+ cumulative_length += len(line)
34
+ if index < cumulative_length:
35
+ target_line_index = i
36
+ break
37
+
38
+ if target_line_index == -1:
39
+ raise LiquidError("index out of bounds")
40
+
41
+ line_number = target_line_index + 1
42
+ line = self.template.lines[target_line_index]
43
+ column_number = index - (cumulative_length - len(line))
44
+ return line_number, column_number
45
+
46
+ def span(self) -> tuple[tuple[int, int], tuple[int, int]]:
47
+ return self.line_col(self.token[1]), self.line_col(self.token[2])
48
+
49
+ def value(self) -> str:
50
+ return token_value(self.token, self.template.source)
51
+
52
+
53
+ class Loc(TypedDict):
54
+ start_index: int
55
+ end_index: int
56
+ start_line: int
57
+ start_column: int
58
+ end_line: int
59
+ end_column: int
60
+ value: str
61
+ template_name: str
62
+ template_path: str | None
63
+
64
+
65
+ type Segments = tuple[int | str | Segments, ...]
66
+ """Names and indexes that make up a path to a variable."""
67
+
68
+
69
+ class Var(Loc):
70
+ segments: Segments
71
+ path: str
72
+
73
+
74
+ type Vars = dict[str, list[Var]]
75
+
76
+ type Locations = dict[str, list[Loc]]
77
+
78
+ RE_PROPERTY = re.compile(r"[a-zA-Z_][a-zA-Z0-9_-]*\??")
79
+
80
+
81
+ @dataclass(slots=True, kw_only=True)
82
+ class _StaticVariable:
83
+ segments: Segments
84
+ location: _Location
85
+
86
+ def __eq__(self, other: object) -> bool:
87
+ return isinstance(other, _StaticVariable) and self.segments == other.segments
88
+
89
+ def root(self) -> str:
90
+ segment = self.segments[0] if self.segments else ""
91
+ if isinstance(segment, tuple):
92
+ return self._to_str(segment)
93
+ return str(segment)
94
+
95
+ def __str__(self) -> str:
96
+ return self._to_str(self.segments)
97
+
98
+ def _to_str(self, segments: Segments) -> str:
99
+ def segment_str(segment: int | str | Segments) -> str:
100
+ if isinstance(segment, tuple):
101
+ return f"[{self._to_str(segment)}]"
102
+ if isinstance(segment, str) and RE_PROPERTY.match(segment):
103
+ return f".{segment}"
104
+ return f"[{segment}]"
105
+
106
+ it = iter(segments)
107
+ head = next(it)
108
+
109
+ return "".join(chain(str(head), (segment_str(s) for s in it)))
110
+
111
+
112
+ class _StaticScope:
113
+ def __init__(self, globals: set[str]) -> None:
114
+ self.stack: list[set[str]] = [globals]
115
+
116
+ def add(self, name: str) -> None:
117
+ self.stack[0].add(name)
118
+
119
+ def __contains__(self, key: str) -> bool:
120
+ return any(key in scope for scope in self.stack)
121
+
122
+ def pop(self) -> set[str] | None:
123
+ return self.stack.pop() if self.stack else None
124
+
125
+ def push(self, scope: set[str]) -> _StaticScope:
126
+ self.stack.append(scope)
127
+ return self
128
+
129
+
130
+ class _VariableMap:
131
+ def __init__(self) -> None:
132
+ self.data: dict[str, list[_StaticVariable]] = {}
133
+
134
+ def add(self, key: _StaticVariable) -> None:
135
+ v = self.get(key)
136
+ if v is not None:
137
+ v.append(key)
138
+
139
+ def get(self, key: _StaticVariable) -> list[_StaticVariable] | None:
140
+ k = key.root()
141
+ if k not in self.data:
142
+ self.data[k] = []
143
+ return self.data[k]
144
+
145
+ def asdict(self) -> Vars:
146
+ obj: Vars = {}
147
+
148
+ for k, v in self.data.items():
149
+ a: list[Var] = []
150
+
151
+ for sv in v:
152
+ ((start_line, start_column), (end_line, end_column)) = (
153
+ sv.location.span()
154
+ )
155
+
156
+ a.append(
157
+ {
158
+ "segments": sv.segments,
159
+ "path": str(sv),
160
+ "start_index": sv.location.token[1],
161
+ "end_index": sv.location.token[2],
162
+ "start_line": start_line,
163
+ "start_column": start_column,
164
+ "end_line": end_line,
165
+ "end_column": end_column,
166
+ "value": sv.location.value(),
167
+ "template_name": sv.location.template.name,
168
+ "template_path": sv.location.template.path,
169
+ }
170
+ )
171
+
172
+ obj[k] = a
173
+
174
+ return obj
175
+
176
+
177
+ @dataclass(slots=True, kw_only=True)
178
+ class TemplateAnalysis:
179
+ variables: Vars
180
+ locals: Vars
181
+ globals: Vars
182
+ filters: Locations
183
+ tags: Locations
184
+
185
+
186
+ def analyze(template: Template, *, include_partials: bool) -> TemplateAnalysis:
187
+ variables = _VariableMap()
188
+ globals = _VariableMap()
189
+ locals = _VariableMap()
190
+
191
+ filters: defaultdict[str, list[_Location]] = defaultdict(list)
192
+ tags: defaultdict[str, list[_Location]] = defaultdict(list)
193
+
194
+ template_scope: set[str] = set()
195
+ root_scope = _StaticScope(template_scope)
196
+ static_context = RenderContext(template)
197
+
198
+ # Names of partial templates that have already been analyzed.
199
+ # Keys are hashes of partial template name and its arguments. If we've
200
+ # visited a template before but with different arguments, later visits
201
+ # only record global variables so as not to double count locals, filters
202
+ # and tags.
203
+ seen: defaultdict[str, set[int | None]] = defaultdict(set)
204
+
205
+ def visit(
206
+ node: Markup,
207
+ template: Template,
208
+ scope: _StaticScope,
209
+ *,
210
+ just_globals: bool = False,
211
+ ) -> None:
212
+ if template.name and not just_globals:
213
+ seen[template.name].add(None)
214
+
215
+ # Update tags
216
+ # Markup with empty `node.tag` is silenced.
217
+ if not just_globals and node.tag:
218
+ tags[node.tag].append(_Location(template=template, token=node.token))
219
+
220
+ # Update variables from `node.expressions()`
221
+ for expr in node.expressions():
222
+ analyze_variables(
223
+ expr,
224
+ template,
225
+ scope,
226
+ globals,
227
+ variables if not just_globals else _VariableMap(),
228
+ static_context,
229
+ )
230
+
231
+ if not just_globals:
232
+ # Update filters from expr
233
+ for filter_name, span in extract_filters(
234
+ expr, template, static_context
235
+ ):
236
+ filters[filter_name].append(span)
237
+
238
+ # Update template scope from `node.template_scope()`
239
+ for name in node.template_scope():
240
+ scope.add(name.value)
241
+ locals.add(
242
+ _StaticVariable(
243
+ segments=(name.value,),
244
+ location=_Location(template=template, token=name.span),
245
+ )
246
+ )
247
+
248
+ # Set block scope before descending into child nodes.
249
+ block_scope = node.block_scope()
250
+ if block_scope:
251
+ scope.push({n.value for n in block_scope})
252
+
253
+ for child in node.children():
254
+ visit(child, template, scope, just_globals=just_globals)
255
+
256
+ if block_scope:
257
+ scope.pop()
258
+
259
+ # Descend into partial templates
260
+ if include_partials:
261
+ for partial in node.partials(static_context):
262
+ template_name = partial.template.name
263
+
264
+ # If we've seen this partial before but with different arguments,
265
+ # we might want to visit it again but only capture globals.
266
+ just_globals_ = template_name in seen
267
+
268
+ if partial.key not in seen[template_name]:
269
+ seen[template_name].add(partial.key)
270
+ partial_globals = {n.value for n in partial.in_scope}
271
+
272
+ partial_scope = (
273
+ _StaticScope(partial_globals)
274
+ if partial.scope == SCOPE_ISOLATED
275
+ else root_scope.push(partial_globals)
276
+ )
277
+
278
+ for partial_node in partial.template.nodes:
279
+ if not isinstance(partial_node, str):
280
+ visit(
281
+ partial_node,
282
+ partial.template,
283
+ partial_scope,
284
+ just_globals=just_globals_,
285
+ )
286
+
287
+ if partial.scope != SCOPE_ISOLATED:
288
+ partial_scope.pop()
289
+
290
+ for node in template.nodes:
291
+ if not isinstance(node, str):
292
+ visit(node, template, root_scope)
293
+
294
+ return TemplateAnalysis(
295
+ variables=variables.asdict(),
296
+ locals=locals.asdict(),
297
+ globals=globals.asdict(),
298
+ filters=to_locations(filters),
299
+ tags=to_locations(tags),
300
+ )
301
+
302
+
303
+ async def analyze_async(
304
+ template: Template, *, include_partials: bool
305
+ ) -> TemplateAnalysis:
306
+ variables = _VariableMap()
307
+ globals = _VariableMap()
308
+ locals = _VariableMap()
309
+
310
+ filters: defaultdict[str, list[_Location]] = defaultdict(list)
311
+ tags: defaultdict[str, list[_Location]] = defaultdict(list)
312
+
313
+ template_scope: set[str] = set()
314
+ root_scope = _StaticScope(template_scope)
315
+ static_context = RenderContext(template)
316
+
317
+ # Names of partial templates that have already been analyzed.
318
+ # Keys are hashes of partial template name and its arguments. If we've
319
+ # visited a template before but with different arguments, later visits
320
+ # only record global variables so as not to double count locals, filters
321
+ # and tags.
322
+ seen: defaultdict[str, set[int | None]] = defaultdict(set)
323
+
324
+ async def visit(
325
+ node: Markup,
326
+ template: Template,
327
+ scope: _StaticScope,
328
+ *,
329
+ just_globals: bool = False,
330
+ ) -> None:
331
+ if template.name and not just_globals:
332
+ seen[template.name].add(None)
333
+
334
+ # Update tags
335
+ # Markup with empty `node.tag` is silenced.
336
+ if not just_globals and node.tag:
337
+ tags[node.tag].append(_Location(template=template, token=node.token))
338
+
339
+ # Update variables from `node.expressions()`
340
+ for expr in node.expressions():
341
+ analyze_variables(
342
+ expr,
343
+ template,
344
+ scope,
345
+ globals,
346
+ variables if not just_globals else _VariableMap(),
347
+ static_context,
348
+ )
349
+
350
+ if not just_globals:
351
+ # Update filters from expr
352
+ for filter_name, span in extract_filters(
353
+ expr, template, static_context
354
+ ):
355
+ filters[filter_name].append(span)
356
+
357
+ # Update template scope from `node.template_scope()`
358
+ for name in node.template_scope():
359
+ scope.add(name.value)
360
+ locals.add(
361
+ _StaticVariable(
362
+ segments=(name.value,),
363
+ location=_Location(template=template, token=name.span),
364
+ )
365
+ )
366
+
367
+ # Set block scope before descending into child nodes.
368
+ block_scope = node.block_scope()
369
+ if block_scope:
370
+ scope.push({n.value for n in block_scope})
371
+
372
+ for child in node.children():
373
+ await visit(child, template, scope, just_globals=just_globals)
374
+
375
+ if block_scope:
376
+ scope.pop()
377
+
378
+ # Descend into partial templates
379
+ if include_partials:
380
+ for partial in await node.partials_async(static_context):
381
+ temlate_name = partial.template.name
382
+
383
+ # If we've seen this partial before but with different arguments,
384
+ # we might want to visit it again but only capture globals.
385
+ just_globals_ = temlate_name in seen
386
+
387
+ if partial.key not in seen[temlate_name]:
388
+ seen[temlate_name].add(partial.key)
389
+ partial_globals = {n.value for n in partial.in_scope}
390
+
391
+ partial_scope = (
392
+ _StaticScope(partial_globals)
393
+ if partial.scope == SCOPE_ISOLATED
394
+ else root_scope.push(partial_globals)
395
+ )
396
+
397
+ for partial_node in partial.template.nodes:
398
+ if not isinstance(partial_node, str):
399
+ await visit(
400
+ partial_node,
401
+ partial.template,
402
+ partial_scope,
403
+ just_globals=just_globals_,
404
+ )
405
+
406
+ if partial.scope != SCOPE_ISOLATED:
407
+ partial_scope.pop()
408
+
409
+ for node in template.nodes:
410
+ if not isinstance(node, str):
411
+ await visit(node, template, root_scope)
412
+
413
+ return TemplateAnalysis(
414
+ variables=variables.asdict(),
415
+ locals=locals.asdict(),
416
+ globals=globals.asdict(),
417
+ filters=to_locations(filters),
418
+ tags=to_locations(tags),
419
+ )
420
+
421
+
422
+ def analyze_variables(
423
+ expression: Expression,
424
+ template: Template,
425
+ scope: _StaticScope,
426
+ globals: _VariableMap,
427
+ variables: _VariableMap,
428
+ static_context: RenderContext,
429
+ ) -> None:
430
+ if isinstance(expression, expressions.Variable):
431
+ v = _StaticVariable(
432
+ segments=segments(expression, template),
433
+ location=_Location(template=template, token=expression.span),
434
+ )
435
+
436
+ variables.add(v)
437
+
438
+ if str(expression.root) not in scope:
439
+ globals.add(v)
440
+
441
+ for expr in expression.children():
442
+ analyze_variables(expr, template, scope, globals, variables, static_context)
443
+
444
+
445
+ def segments(variable: expressions.Variable, template: Template) -> Segments:
446
+
447
+ def generate_segments() -> Iterable[int | str | Segments]:
448
+ if isinstance(variable.root, expressions.VariableSegment):
449
+ yield segments(variable.root.var, template)
450
+ else:
451
+ yield variable.root.value
452
+
453
+ if variable.selectors:
454
+ for s in variable.selectors:
455
+ if isinstance(s, expressions.VariableSegment):
456
+ yield segments(s.var, template)
457
+ else:
458
+ yield s.value
459
+
460
+ return tuple(generate_segments())
461
+
462
+
463
+ def extract_filters(
464
+ expression: Expression, template: Template, static_context: RenderContext
465
+ ) -> Iterable[tuple[str, _Location]]:
466
+ if isinstance(expression, expressions.Filtered):
467
+ yield (
468
+ expression.name.value,
469
+ _Location(
470
+ template=template, token=span(expression.name.token, expression.span)
471
+ ),
472
+ )
473
+
474
+ for expr in expression.children():
475
+ yield from extract_filters(expr, template, static_context)
476
+
477
+
478
+ def to_locations(d: defaultdict[str, list[_Location]]) -> Locations:
479
+ result: Locations = {}
480
+
481
+ for k, v in d.items():
482
+ a: list[Loc] = []
483
+
484
+ for location in v:
485
+ ((start_line, start_column), (end_line, end_column)) = location.span()
486
+
487
+ a.append(
488
+ {
489
+ "start_index": location.token[1],
490
+ "end_index": location.token[2],
491
+ "start_line": start_line,
492
+ "start_column": start_column,
493
+ "end_line": end_line,
494
+ "end_column": end_column,
495
+ "value": location.value(),
496
+ "template_name": location.template.name,
497
+ "template_path": location.template.path,
498
+ }
499
+ )
500
+
501
+ result[k] = a
502
+
503
+ return result