python-liquid 2.1.0__py3-none-any.whl → 2.2.1__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.
liquid/__init__.py CHANGED
@@ -56,7 +56,7 @@ from .tag import Tag
56
56
 
57
57
  from . import future
58
58
 
59
- __version__ = "2.1.0"
59
+ __version__ = "2.2.1"
60
60
 
61
61
  __all__ = (
62
62
  "AwareBoundTemplate",
liquid/ast.py CHANGED
@@ -9,7 +9,9 @@ from enum import auto
9
9
  from typing import TYPE_CHECKING
10
10
  from typing import Collection
11
11
  from typing import Iterable
12
+ from typing import Optional
12
13
  from typing import TextIO
14
+ from typing import Union
13
15
 
14
16
  from .exceptions import DisabledTagError
15
17
  from .output import NullIO
@@ -104,7 +106,7 @@ class Node(ABC):
104
106
  """Return variables this node adds to the node's block scope."""
105
107
  return []
106
108
 
107
- def partial_scope(self) -> Partial | None:
109
+ def partial_scope(self) -> Optional[Partial]:
108
110
  """Return information about a partial template loaded by this node."""
109
111
  return None
110
112
 
@@ -121,19 +123,30 @@ class Partial:
121
123
  """Partial template meta data.
122
124
 
123
125
  Args:
124
- name: An expression resolving to the name associated with the partial template.
126
+ name: The name of the partial or an expression resolving to the name
127
+ associated with the partial template.
125
128
  scope: The kind of scope the partial template should have when loaded.
126
129
  in_scope: Names that will be added to the partial template scope.
130
+ key: A hash of the partial template name and any arguments the partial
131
+ template will be rendered with that might affect its scope. If a
132
+ key is provided, static analysis helpers will visit a partial
133
+ template once for each distinct key.
127
134
  """
128
135
 
129
- __slots__ = ("name", "scope", "in_scope")
136
+ __slots__ = ("name", "scope", "in_scope", "key")
130
137
 
131
138
  def __init__(
132
- self, name: Expression, scope: PartialScope, in_scope: Iterable[Identifier]
139
+ self,
140
+ name: Union[Expression, str],
141
+ scope: PartialScope,
142
+ in_scope: Iterable[Identifier],
143
+ *,
144
+ key: Optional[int] = None,
133
145
  ) -> None:
134
146
  self.name = name
135
147
  self.scope = scope
136
148
  self.in_scope = in_scope
149
+ self.key = key
137
150
 
138
151
 
139
152
  class IllegalNode(Node):
@@ -58,6 +58,7 @@ from .filters.string import replace_last
58
58
  from .filters.string import rstrip
59
59
  from .filters.string import slice_
60
60
  from .filters.string import split
61
+ from .filters.string import squish
61
62
  from .filters.string import strip
62
63
  from .filters.string import strip_html
63
64
  from .filters.string import strip_newlines
@@ -175,6 +176,7 @@ def register(env: Environment) -> None: # noqa: PLR0915
175
176
  env.add_filter("base64_decode", base64_decode)
176
177
  env.add_filter("base64_url_safe_encode", base64_url_safe_encode)
177
178
  env.add_filter("base64_url_safe_decode", base64_url_safe_decode)
179
+ env.add_filter("squish", squish)
178
180
 
179
181
  env.add_filter("find", find)
180
182
  env.add_filter("find_index", find_index)
@@ -405,3 +405,12 @@ def base64_url_safe_decode(val: str) -> str:
405
405
  return base64.urlsafe_b64decode(val).decode()
406
406
  except binascii.Error as err:
407
407
  raise FilterError("invalid base64-encoded string", token=None) from err
408
+
409
+
410
+ RE_WHITESPACE = re.compile(r"\s+")
411
+
412
+
413
+ @string_filter
414
+ def squish(val: str) -> str:
415
+ """Strip whitespace and replace whitespace with a single space."""
416
+ return RE_WHITESPACE.sub(" ", val.strip())
@@ -21,6 +21,8 @@ class CachingFileSystemLoader(CachingLoaderMixin, FileSystemLoader):
21
21
  search_path: One or more paths to search.
22
22
  encoding: Open template files with the given encoding.
23
23
  ext: A default file extension. Should include a leading period.
24
+ reject_symlinks: When `True`, reject paths to symlinks that resolve to files
25
+ outside the search path. Defaults to `False`.
24
26
  auto_reload: If `True`, automatically reload a cached template if it has been
25
27
  updated.
26
28
  namespace_key: The name of a global render context variable or loader keyword
@@ -39,6 +41,7 @@ class CachingFileSystemLoader(CachingLoaderMixin, FileSystemLoader):
39
41
  encoding: str = "utf-8",
40
42
  ext: Optional[str] = None,
41
43
  *,
44
+ reject_symlinks: bool = False,
42
45
  auto_reload: bool = True,
43
46
  namespace_key: str = "",
44
47
  capacity: int = 300,
@@ -54,4 +57,5 @@ class CachingFileSystemLoader(CachingLoaderMixin, FileSystemLoader):
54
57
  search_path=search_path,
55
58
  encoding=encoding,
56
59
  ext=ext,
60
+ reject_symlinks=reject_symlinks,
57
61
  )
@@ -27,6 +27,8 @@ class FileSystemLoader(BaseLoader):
27
27
  search_path: One or more paths to search.
28
28
  encoding: Encoding to use when opening files.
29
29
  ext: A default file extension. Should include a leading period.
30
+ reject_symlinks: When `True`, reject paths to symlinks that resolve to files
31
+ outside the search path. Defaults to `False`.
30
32
  """
31
33
 
32
34
  def __init__(
@@ -35,6 +37,7 @@ class FileSystemLoader(BaseLoader):
35
37
  *,
36
38
  encoding: str = "utf-8",
37
39
  ext: Optional[str] = None,
40
+ reject_symlinks: bool = False,
38
41
  ):
39
42
  super().__init__()
40
43
  if not isinstance(search_path, Iterable) or isinstance(search_path, str):
@@ -43,6 +46,7 @@ class FileSystemLoader(BaseLoader):
43
46
  self.search_path = [Path(path) for path in search_path]
44
47
  self.encoding = encoding
45
48
  self.ext = ext
49
+ self.reject_symlinks = reject_symlinks
46
50
 
47
51
  def resolve_path(self, template_name: str) -> Path:
48
52
  """Return a path to the template identified by _template_name_.
@@ -56,14 +60,27 @@ class FileSystemLoader(BaseLoader):
56
60
  if self.ext and not template_path.suffix:
57
61
  template_path = template_path.with_suffix(self.ext)
58
62
 
59
- if os.path.pardir in template_path.parts:
63
+ if os.path.pardir in template_path.parts or template_path.is_absolute():
60
64
  raise TemplateNotFoundError(template_name)
61
65
 
62
- for path in self.search_path:
63
- source_path = path.joinpath(template_path)
64
- if not source_path.exists():
66
+ for base in self.search_path:
67
+ source_path = base.joinpath(template_path)
68
+
69
+ if not source_path.exists() or not source_path.is_file():
65
70
  continue
71
+
72
+ if self.reject_symlinks:
73
+ try:
74
+ resolved = source_path.resolve(strict=False)
75
+ base_resolved = base.resolve(strict=False)
76
+ except OSError:
77
+ continue
78
+
79
+ if not resolved.is_relative_to(base_resolved):
80
+ continue
81
+
66
82
  return source_path
83
+
67
84
  raise TemplateNotFoundError(template_name)
68
85
 
69
86
  def _read(self, source_path: Path) -> tuple[str, float]:
@@ -11,7 +11,7 @@ from typing import Union
11
11
  from liquid.ast import BlockNode
12
12
  from liquid.ast import Node
13
13
  from liquid.builtin.expressions import parse_primitive
14
- from liquid.builtin.expressions.logical import _eq
14
+ from liquid.builtin.expressions.logical import _eq # type: ignore
15
15
  from liquid.exceptions import LiquidSyntaxError
16
16
  from liquid.expression import Expression
17
17
  from liquid.parser import get_parser
@@ -85,7 +85,7 @@ class CaseNode(Node):
85
85
  count += count_
86
86
  # Only render `else` blocks if all preceding `when` blocks are falsy.
87
87
  # Multiple `else` blocks are OK.
88
- elif isinstance(block, BlockNode) and default:
88
+ elif default:
89
89
  count += block.render(context, buffer)
90
90
 
91
91
  return count
@@ -106,7 +106,7 @@ class CaseNode(Node):
106
106
  count += count_
107
107
  # Only render `else` blocks if all preceding `when` blocks are falsy.
108
108
  # Multiple `else` blocks are OK.
109
- elif isinstance(block, BlockNode) and default:
109
+ elif default:
110
110
  count += await block.render_async(context, buffer)
111
111
 
112
112
  return count
@@ -145,7 +145,7 @@ class CaseTag(Tag):
145
145
 
146
146
  # Eat whitespace or junk between `case` and when/else/endcase
147
147
  while (
148
- stream.current.kind != TOKEN_TAG
148
+ stream.current.kind not in (TOKEN_TAG, TOKEN_EOF)
149
149
  and stream.current.value not in ENDWHENBLOCK
150
150
  ):
151
151
  next(stream)
@@ -37,7 +37,7 @@ class CommentNode(ast.Node):
37
37
  def __str__(self) -> str:
38
38
  return f"{{% comment %}}{self.text}{{% endcomment %}}"
39
39
 
40
- def render_to_output(self, _: RenderContext, __: TextIO) -> int:
40
+ def render_to_output(self, context: RenderContext, buffer: TextIO) -> int: # noqa: ARG002
41
41
  """Render the node to the output buffer."""
42
42
  return 0
43
43
 
@@ -61,7 +61,7 @@ class CommentTag(Tag):
61
61
 
62
62
  # A block `{% comment %}`
63
63
  token = stream.eat(TOKEN_TAG)
64
- text = []
64
+ text: list[str] = []
65
65
 
66
66
  while True:
67
67
  if stream.current.kind == TOKEN_EOF:
@@ -50,7 +50,7 @@ class DocTag(Tag):
50
50
 
51
51
  # This only happens if the doc tag is malformed
52
52
  token = stream.eat(TOKEN_TAG)
53
- text = []
53
+ text: list[str] = []
54
54
 
55
55
  if stream.current.kind == TOKEN_EXPRESSION:
56
56
  raise LiquidSyntaxError("unexpected expression", token=stream.current)
@@ -152,7 +152,7 @@ class IfTag(Tag):
152
152
  condition = BooleanExpression.parse(self.env, tokens)
153
153
  parse_block = get_parser(self.env).parse_block
154
154
  consequence = parse_block(stream, ENDIFBLOCK)
155
- alternatives = []
155
+ alternatives: list[ConditionalBlockNode] = []
156
156
 
157
157
  while stream.current.is_tag(TAG_ELSIF):
158
158
  # If the expression can't be parsed, eat the "elsif" block and
@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING
7
7
  from typing import Iterable
8
8
  from typing import Optional
9
9
  from typing import TextIO
10
+ from typing import Union
10
11
 
11
12
  from liquid.ast import Node
12
13
  from liquid.ast import Partial
@@ -21,11 +22,12 @@ from liquid.builtin.expressions import parse_identifier
21
22
  from liquid.builtin.expressions import parse_primitive
22
23
  from liquid.builtin.tags.for_tag import ForLoop
23
24
  from liquid.builtin.tags.include_tag import TAG_INCLUDE
25
+ from liquid.exceptions import LiquidSyntaxError
24
26
  from liquid.exceptions import TemplateNotFoundError
25
27
  from liquid.tag import Tag
28
+ from liquid.template import BoundTemplate
26
29
  from liquid.token import TOKEN_AS
27
30
  from liquid.token import TOKEN_FOR
28
- from liquid.token import TOKEN_STRING
29
31
  from liquid.token import TOKEN_TAG
30
32
  from liquid.token import TOKEN_WITH
31
33
  from liquid.token import TOKEN_WORD
@@ -50,7 +52,8 @@ class RenderNode(Node):
50
52
  def __init__(
51
53
  self,
52
54
  token: Token,
53
- name: StringLiteral,
55
+ name: Union[StringLiteral, Identifier],
56
+ *,
54
57
  var: Optional[Expression] = None,
55
58
  loop: bool = False,
56
59
  alias: Optional[Identifier] = None,
@@ -77,14 +80,26 @@ class RenderNode(Node):
77
80
 
78
81
  def render_to_output(self, context: RenderContext, buffer: TextIO) -> int:
79
82
  """Render the node to the output buffer."""
80
- try:
81
- template = context.env.get_template(
82
- self.name.value, context=context, tag=self.tag
83
+ if isinstance(self.name, Identifier):
84
+ # We're expecting an inline snippet.
85
+ template: Optional[BoundTemplate] = context.resolve(
86
+ self.name, token=self.token, default=None
83
87
  )
84
- except TemplateNotFoundError as err:
85
- err.token = self.name.token
86
- err.template_name = context.template.full_name()
87
- raise
88
+ if not isinstance(template, BoundTemplate):
89
+ raise TemplateNotFoundError(
90
+ self.name,
91
+ filename=context.template.full_name(),
92
+ token=self.name.token,
93
+ )
94
+ else:
95
+ try:
96
+ template = context.env.get_template(
97
+ self.name.value, context=context, tag=self.tag
98
+ )
99
+ except TemplateNotFoundError as err:
100
+ err.token = self.name.token
101
+ err.template_name = context.template.full_name()
102
+ raise
88
103
 
89
104
  # Evaluate keyword arguments once. Unlike 'include', 'render' can not
90
105
  # mutate variables in the outer scope, so there's no need to re-evaluate
@@ -145,14 +160,26 @@ class RenderNode(Node):
145
160
  self, context: RenderContext, buffer: TextIO
146
161
  ) -> int:
147
162
  """Render the node to the output buffer."""
148
- try:
149
- template = await context.env.get_template_async(
150
- self.name.value, context=context, tag=self.tag
163
+ if isinstance(self.name, Identifier):
164
+ # We're expecting an inline snippet.
165
+ template: Optional[BoundTemplate] = context.resolve(
166
+ self.name, token=self.token, default=None
151
167
  )
152
- except TemplateNotFoundError as err:
153
- err.token = self.name.token
154
- err.template_name = context.template.full_name()
155
- raise
168
+ if not isinstance(template, BoundTemplate):
169
+ raise TemplateNotFoundError(
170
+ self.name,
171
+ filename=context.template.full_name(),
172
+ token=self.name.token,
173
+ )
174
+ else:
175
+ try:
176
+ template = await context.env.get_template_async(
177
+ self.name.value, context=context, tag=self.tag
178
+ )
179
+ except TemplateNotFoundError as err:
180
+ err.token = self.name.token
181
+ err.template_name = context.template.full_name()
182
+ raise
156
183
 
157
184
  # Evaluate keyword arguments once. Unlike 'include', 'render' can not
158
185
  # mutate variables in the outer scope, so there's no need to re-evaluate
@@ -215,7 +242,15 @@ class RenderNode(Node):
215
242
  self, static_context: RenderContext, *, include_partials: bool = True
216
243
  ) -> Iterable[Node]:
217
244
  """Return this node's children."""
218
- if include_partials:
245
+ if isinstance(self.name, Identifier):
246
+ # We're expecting an inline snippet.
247
+ # Always visit inline snippets, even if include_partials is False.
248
+ template: Optional[BoundTemplate] = static_context.resolve(
249
+ self.name, token=self.token, default=None
250
+ )
251
+ if template:
252
+ yield from template.nodes
253
+ elif include_partials:
219
254
  name = self.name.evaluate(static_context)
220
255
  try:
221
256
  template = static_context.env.get_template(
@@ -231,7 +266,14 @@ class RenderNode(Node):
231
266
  self, static_context: RenderContext, *, include_partials: bool = True
232
267
  ) -> Iterable[Node]:
233
268
  """Return this node's children."""
234
- if include_partials:
269
+ if isinstance(self.name, Identifier):
270
+ # We're expecting an inline snippet.
271
+ template: Optional[BoundTemplate] = static_context.resolve(
272
+ self.name, token=self.token, default=None
273
+ )
274
+ if template:
275
+ return template.nodes
276
+ elif include_partials:
235
277
  name = await self.name.evaluate_async(static_context)
236
278
  try:
237
279
  template = await static_context.env.get_template_async(
@@ -246,12 +288,11 @@ class RenderNode(Node):
246
288
 
247
289
  def expressions(self) -> Iterable[Expression]:
248
290
  """Return this node's expressions."""
249
- yield self.name
250
291
  if self.var:
251
292
  yield self.var
252
293
  yield from (arg.value for arg in self.args)
253
294
 
254
- def partial_scope(self) -> Partial | None:
295
+ def partial_scope(self) -> Optional[Partial]:
255
296
  """Return information about a partial template loaded by this node."""
256
297
  scope: list[Identifier] = [
257
298
  Identifier(arg.name, token=arg.token) for arg in self.args
@@ -267,7 +308,17 @@ class RenderNode(Node):
267
308
  )
268
309
  )
269
310
 
270
- return Partial(name=self.name, scope=PartialScope.ISOLATED, in_scope=scope)
311
+ partial_name = self.name.value if isinstance(self.name, StringLiteral) else ""
312
+ partial_key = hash((partial_name, *[arg.name for arg in self.args]))
313
+
314
+ # Static analysis will use the parent template name if Partial.name is
315
+ # empty. Which is what we want for inline snippets.
316
+ return Partial(
317
+ name=partial_name,
318
+ scope=PartialScope.ISOLATED,
319
+ in_scope=scope,
320
+ key=partial_key,
321
+ )
271
322
 
272
323
 
273
324
  BIND_TAGS = frozenset((TOKEN_WITH, TOKEN_FOR))
@@ -284,12 +335,18 @@ class RenderTag(Tag):
284
335
  """Parse tokens from _stream_ into an AST node."""
285
336
  token = stream.eat(TOKEN_TAG)
286
337
  tokens = stream.into_inner(tag=token, eat=False)
287
-
288
- # Need a string. 'render' does not accept identifiers that resolve to a string.
289
- # This is the name of the template to be included.
290
- tokens.expect(TOKEN_STRING)
291
- name = parse_primitive(self.env, tokens)
292
- assert isinstance(name, StringLiteral)
338
+ name: Union[Expression, Identifier] = parse_primitive(self.env, tokens)
339
+
340
+ if isinstance(name, Path):
341
+ head = name.head()
342
+ if len(name.path) != 1 or not isinstance(head, str):
343
+ raise LiquidSyntaxError(
344
+ "expected an identifier, found a path",
345
+ token=name.token,
346
+ )
347
+ name = Identifier(head, token=name.token)
348
+ elif not isinstance(name, StringLiteral):
349
+ raise LiquidSyntaxError("expected a string or identifier", token=name.token)
293
350
 
294
351
  alias: Optional[Identifier] = None
295
352
  var: Optional[Path] = None
@@ -311,4 +368,4 @@ class RenderTag(Tag):
311
368
 
312
369
  # Zero or more keyword arguments
313
370
  args = KeywordArgument.parse(self.env, tokens)
314
- return self.node_class(token, name, var, loop, alias, args)
371
+ return self.node_class(token, name, var=var, loop=loop, alias=alias, args=args)
liquid/exceptions.py CHANGED
@@ -169,9 +169,10 @@ class TemplateNotFoundError(LiquidError):
169
169
  def __init__(
170
170
  self,
171
171
  *args: object,
172
- filename: Union[str, None] = None,
172
+ filename: Optional[str] = None,
173
+ token: Optional[Token] = None,
173
174
  ):
174
- super().__init__(*args, token=None, template_name=filename)
175
+ super().__init__(*args, token=token, template_name=filename)
175
176
 
176
177
  def __str__(self) -> str:
177
178
  if not self.token:
liquid/extra/__init__.py CHANGED
@@ -21,6 +21,7 @@ from .tags import BlockTag
21
21
  from .tags import CallTag
22
22
  from .tags import ExtendsTag
23
23
  from .tags import MacroTag
24
+ from .tags import SnippetTag
24
25
  from .tags import TranslateTag
25
26
  from .tags import WithTag
26
27
 
@@ -38,7 +39,6 @@ __all__ = (
38
39
  "DateTime",
39
40
  "ExtendsTag",
40
41
  "GetText",
41
- "IfNotTag",
42
42
  "index",
43
43
  "JSON",
44
44
  "MacroTag",
@@ -49,6 +49,7 @@ __all__ = (
49
49
  "script_tag",
50
50
  "sort_numeric",
51
51
  "stylesheet_tag",
52
+ "SnippetTag",
52
53
  "Translate",
53
54
  "Unit",
54
55
  "WithTag",
@@ -66,7 +66,7 @@ class Currency:
66
66
  can not be resolved. Defaults to `"USD"`.
67
67
  locale_var: The name of a render context variable that resolves to the
68
68
  current locale. Defaults to `"locale"`.
69
- default_locale : A fallback locale to use if `locale_var` can not be
69
+ default_locale: A fallback locale to use if `locale_var` can not be
70
70
  resolved. Defaults to `"en_US"`.
71
71
  format_var: The name of a render context variable that resolves to the
72
72
  current currency format string. Defaults to `"currency_format"`.
@@ -3,6 +3,7 @@ from .extends_tag import BlockTag
3
3
  from .extends_tag import ExtendsTag
4
4
  from .macro_tag import CallTag
5
5
  from .macro_tag import MacroTag
6
+ from .snippet_tag import SnippetTag
6
7
  from .translate_tag import TranslateTag
7
8
 
8
9
  __all__ = (
@@ -11,5 +12,6 @@ __all__ = (
11
12
  "ExtendsTag",
12
13
  "CallTag",
13
14
  "MacroTag",
15
+ "SnippetTag",
14
16
  "TranslateTag",
15
17
  )
@@ -19,7 +19,6 @@ from liquid.ast import Node
19
19
  from liquid.ast import Partial
20
20
  from liquid.ast import PartialScope
21
21
  from liquid.builtin.expressions import Identifier
22
- from liquid.builtin.expressions import StringLiteral
23
22
  from liquid.builtin.expressions import parse_name
24
23
  from liquid.exceptions import RequiredBlockError
25
24
  from liquid.exceptions import StopRender
@@ -107,7 +106,7 @@ class ExtendsNode(Node):
107
106
  def partial_scope(self) -> Optional[Partial]:
108
107
  """Return information about a partial template loaded by this node."""
109
108
  return Partial(
110
- name=StringLiteral(self.name.token, self.name),
109
+ name=self.name,
111
110
  scope=PartialScope.INHERITED,
112
111
  in_scope=[],
113
112
  )
@@ -0,0 +1,107 @@
1
+ """The built-in _snippet_ tag.
2
+
3
+ New in Python Liquid version 2.2.0.
4
+ New in Shopify/liquid version 5.10.0.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING
10
+ from typing import TextIO
11
+
12
+ from liquid.ast import BlockNode
13
+ from liquid.ast import Node
14
+ from liquid.builtin.expressions import parse_identifier
15
+ from liquid.parser import get_parser
16
+ from liquid.tag import Tag
17
+ from liquid.template import BoundTemplate
18
+ from liquid.token import TOKEN_EOF
19
+ from liquid.token import TOKEN_TAG
20
+ from liquid.token import Token
21
+
22
+ if TYPE_CHECKING:
23
+ from collections.abc import Iterable
24
+
25
+ from liquid.builtin.expressions import Identifier
26
+ from liquid.context import RenderContext
27
+ from liquid.stream import TokenStream
28
+
29
+
30
+ class SnippetNode(Node):
31
+ """The built-in _snippet_ tag."""
32
+
33
+ __slots__ = ("name", "block")
34
+
35
+ def __init__(self, token: Token, name: Identifier, block: BlockNode):
36
+ super().__init__(token)
37
+ self.name = name
38
+ self.block = block
39
+
40
+ def __str__(self) -> str:
41
+ return f"{{% snippet {self.name} %}}{self.block}{{% endsnippet %}}"
42
+
43
+ def render_to_output(self, context: RenderContext, buffer: TextIO) -> int: # noqa: ARG002
44
+ """Render the node to the output buffer."""
45
+ # Don't render anything, just bind the block to its name.
46
+ context.assign(
47
+ self.name,
48
+ SnippetDrop(
49
+ context.env,
50
+ self.block.nodes,
51
+ context.template.name,
52
+ context.template.path,
53
+ ),
54
+ )
55
+ return 0
56
+
57
+ def children(
58
+ self,
59
+ static_context: RenderContext, # noqa: ARG002
60
+ *,
61
+ include_partials: bool = True, # noqa: ARG002
62
+ ) -> Iterable[Node]:
63
+ """Return this node's children."""
64
+ # Snippets are only visited when rendered so we get accurate variable
65
+ # scope analysis.
66
+ static_context.assign(
67
+ self.name,
68
+ SnippetDrop(
69
+ static_context.env,
70
+ self.block.nodes,
71
+ static_context.template.name,
72
+ static_context.template.path,
73
+ ),
74
+ )
75
+ return []
76
+
77
+ def template_scope(self) -> Iterable[Identifier]:
78
+ """Return variables this node adds to the template local scope."""
79
+ yield self.name
80
+
81
+
82
+ class SnippetTag(Tag):
83
+ """The built-in _snippet_ tag."""
84
+
85
+ block = True
86
+ name = "snippet"
87
+ end = "endsnippet"
88
+ node_class = SnippetNode
89
+
90
+ ENDSNIPPETBLOCK = frozenset((end, TOKEN_EOF))
91
+
92
+ def parse(self, stream: TokenStream) -> SnippetNode:
93
+ """Parse tokens from _stream_ into an AST node."""
94
+ token = stream.eat(TOKEN_TAG)
95
+ tokens = stream.into_inner(tag=token)
96
+ name = parse_identifier(self.env, tokens)
97
+ tokens.expect_eos()
98
+ block = get_parser(self.env).parse_block(stream, self.ENDSNIPPETBLOCK)
99
+ stream.expect(TOKEN_TAG, value=self.end)
100
+ return self.node_class(token, name, block)
101
+
102
+
103
+ class SnippetDrop(BoundTemplate):
104
+ """An template suitable for storing in a render context."""
105
+
106
+ def __str__(self) -> str:
107
+ return "SnippetDrop"
liquid/filter.py CHANGED
@@ -54,8 +54,11 @@ def string_filter(_filter: FilterT) -> FilterT:
54
54
 
55
55
  @wraps(_filter)
56
56
  def wrapper(val: object, *args: Any, **kwargs: Any) -> Any:
57
- if not isinstance(val, str):
57
+ if val is None:
58
+ val = ""
59
+ elif not isinstance(val, str):
58
60
  val = str(val)
61
+
59
62
  try:
60
63
  return _filter(val, *args, **kwargs)
61
64
  except TypeError as err:
liquid/static_analysis.py CHANGED
@@ -8,6 +8,7 @@ from dataclasses import dataclass
8
8
  from dataclasses import field
9
9
  from typing import TYPE_CHECKING
10
10
  from typing import Iterable
11
+ from typing import Optional
11
12
  from typing import Union
12
13
 
13
14
  from .ast import BlockNode
@@ -126,7 +127,11 @@ class TemplateAnalysis:
126
127
  tags: dict[str, list[Span]]
127
128
 
128
129
 
129
- def _analyze(template: BoundTemplate, *, include_partials: bool) -> TemplateAnalysis:
130
+ def analyze(template: BoundTemplate, *, include_partials: bool) -> TemplateAnalysis:
131
+ """Report variable, filters and tags used in the given template.
132
+
133
+ This is used by `BoundTemplate.analyze`.
134
+ """
130
135
  variables = _VariableMap()
131
136
  globals = _VariableMap() # noqa: A001
132
137
  locals = _VariableMap() # noqa: A001
@@ -139,15 +144,26 @@ def _analyze(template: BoundTemplate, *, include_partials: bool) -> TemplateAnal
139
144
  static_context = RenderContext(template)
140
145
 
141
146
  # Names of partial templates that have already been analyzed.
142
- seen: set[str] = set()
143
-
144
- def _visit(node: Node, template_name: str, scope: _StaticScope) -> None:
145
- if template_name:
146
- seen.add(template_name)
147
+ # Keys are hashes of partial template name and its arguments. If we've
148
+ # visited a template before but ith different arguments, later visits
149
+ # only record global variables so as not to double count locals, filters
150
+ # and tags.
151
+ seen: defaultdict[str, set[Optional[int]]] = defaultdict(set)
152
+
153
+ def _visit(
154
+ node: Node,
155
+ template_name: str,
156
+ scope: _StaticScope,
157
+ *,
158
+ just_globals: bool = False,
159
+ ) -> None:
160
+ if template_name and not just_globals:
161
+ seen[template_name].add(None)
147
162
 
148
163
  # Update tags from node.token
149
164
  if (
150
- not isinstance(
165
+ not just_globals
166
+ and not isinstance(
151
167
  node, (BlockNode, ConditionalBlockNode, MultiExpressionBlockNode)
152
168
  )
153
169
  and node.token.kind == TOKEN_TAG
@@ -156,11 +172,18 @@ def _analyze(template: BoundTemplate, *, include_partials: bool) -> TemplateAnal
156
172
 
157
173
  # Update variables from node.expressions()
158
174
  for expr in node.expressions():
159
- _analyze_variables(expr, template_name, scope, globals, variables)
175
+ _analyze_variables(
176
+ expr,
177
+ template_name,
178
+ scope,
179
+ globals,
180
+ _VariableMap() if just_globals else variables,
181
+ )
160
182
 
161
- # Update filters from expr
162
- for name, span in _extract_filters(expr, template_name):
163
- filters[name].append(span)
183
+ if not just_globals:
184
+ # Update filters from expr
185
+ for name, span in _extract_filters(expr, template_name):
186
+ filters[name].append(span)
164
187
 
165
188
  # Update the template scope from node.template_scope()
166
189
  for ident in node.template_scope():
@@ -173,11 +196,23 @@ def _analyze(template: BoundTemplate, *, include_partials: bool) -> TemplateAnal
173
196
  )
174
197
 
175
198
  if partial := node.partial_scope():
176
- partial_name = str(partial.name.evaluate(static_context))
199
+ partial_name = (
200
+ partial.name
201
+ if isinstance(partial.name, str)
202
+ else str(partial.name.evaluate(static_context))
203
+ )
177
204
 
178
- if partial_name in seen:
205
+ # If we've seen this partial before but with different arguments,
206
+ # we might want to visit it again but only capture globals.
207
+ _just_globals = partial_name in seen
208
+ if partial.key in seen[partial_name]:
209
+ # We've visited this partial template before with the same
210
+ # arguments.
179
211
  return
180
212
 
213
+ seen[partial_name].add(partial.key)
214
+ partial_name = partial_name or template_name
215
+
181
216
  partial_scope = (
182
217
  _StaticScope(set(partial.in_scope))
183
218
  if partial.scope == PartialScope.ISOLATED
@@ -187,8 +222,12 @@ def _analyze(template: BoundTemplate, *, include_partials: bool) -> TemplateAnal
187
222
  for child in node.children(
188
223
  static_context, include_partials=include_partials
189
224
  ):
190
- seen.add(partial_name)
191
- _visit(child, partial_name, partial_scope)
225
+ _visit(
226
+ child,
227
+ partial_name,
228
+ partial_scope,
229
+ just_globals=just_globals or _just_globals,
230
+ )
192
231
 
193
232
  partial_scope.pop()
194
233
  else:
@@ -196,7 +235,7 @@ def _analyze(template: BoundTemplate, *, include_partials: bool) -> TemplateAnal
196
235
  for child in node.children(
197
236
  static_context, include_partials=include_partials
198
237
  ):
199
- _visit(child, template_name, scope)
238
+ _visit(child, template_name, scope, just_globals=just_globals)
200
239
  scope.pop()
201
240
 
202
241
  for node in template.nodes:
@@ -211,9 +250,13 @@ def _analyze(template: BoundTemplate, *, include_partials: bool) -> TemplateAnal
211
250
  )
212
251
 
213
252
 
214
- async def _analyze_async(
253
+ async def analyze_async(
215
254
  template: BoundTemplate, *, include_partials: bool
216
255
  ) -> TemplateAnalysis:
256
+ """Report variable, filters and tags used in the given template.
257
+
258
+ This is used by `BoundTemplate.analyze_async`.
259
+ """
217
260
  variables = _VariableMap()
218
261
  globals = _VariableMap() # noqa: A001
219
262
  locals = _VariableMap() # noqa: A001
@@ -226,15 +269,22 @@ async def _analyze_async(
226
269
  static_context = RenderContext(template)
227
270
 
228
271
  # Names of partial templates that have already been analyzed.
229
- seen: set[str] = set()
230
-
231
- async def _visit(node: Node, template_name: str, scope: _StaticScope) -> None:
232
- if template_name:
233
- seen.add(template_name)
272
+ seen: defaultdict[str, set[Optional[int]]] = defaultdict(set)
273
+
274
+ async def _visit(
275
+ node: Node,
276
+ template_name: str,
277
+ scope: _StaticScope,
278
+ *,
279
+ just_globals: bool = False,
280
+ ) -> None:
281
+ if template_name and not just_globals:
282
+ seen[template_name].add(None)
234
283
 
235
284
  # Update tags from node.token
236
285
  if (
237
- not isinstance(
286
+ not just_globals
287
+ and not isinstance(
238
288
  node, (BlockNode, ConditionalBlockNode, MultiExpressionBlockNode)
239
289
  )
240
290
  and node.token.kind == TOKEN_TAG
@@ -243,11 +293,18 @@ async def _analyze_async(
243
293
 
244
294
  # Update variables from node.expressions()
245
295
  for expr in node.expressions():
246
- _analyze_variables(expr, template_name, scope, globals, variables)
296
+ _analyze_variables(
297
+ expr,
298
+ template_name,
299
+ scope,
300
+ globals,
301
+ _VariableMap() if just_globals else variables,
302
+ )
247
303
 
248
- # Update filters from expr
249
- for name, span in _extract_filters(expr, template_name):
250
- filters[name].append(span)
304
+ if not just_globals:
305
+ # Update filters from expr
306
+ for name, span in _extract_filters(expr, template_name):
307
+ filters[name].append(span)
251
308
 
252
309
  # Update the template scope from node.template_scope()
253
310
  for ident in node.template_scope():
@@ -260,11 +317,23 @@ async def _analyze_async(
260
317
  )
261
318
 
262
319
  if partial := node.partial_scope():
263
- partial_name = str(partial.name.evaluate(static_context))
320
+ partial_name = (
321
+ partial.name
322
+ if isinstance(partial.name, str)
323
+ else str(partial.name.evaluate(static_context))
324
+ )
264
325
 
265
- if partial_name in seen:
326
+ # If we've seen this partial before but with different arguments,
327
+ # we might want to visit it again but only capture globals.
328
+ _just_globals = partial_name in seen
329
+ if partial.key in seen[partial_name]:
330
+ # We've visited this partial template before with the same
331
+ # arguments.
266
332
  return
267
333
 
334
+ seen[partial_name].add(partial.key)
335
+ partial_name = partial_name or template_name
336
+
268
337
  partial_scope = (
269
338
  _StaticScope(set(partial.in_scope))
270
339
  if partial.scope == PartialScope.ISOLATED
@@ -274,8 +343,12 @@ async def _analyze_async(
274
343
  for child in await node.children_async(
275
344
  static_context, include_partials=include_partials
276
345
  ):
277
- seen.add(partial_name)
278
- await _visit(child, partial_name, partial_scope)
346
+ await _visit(
347
+ child,
348
+ partial_name,
349
+ partial_scope,
350
+ just_globals=just_globals or _just_globals,
351
+ )
279
352
 
280
353
  partial_scope.pop()
281
354
  else:
@@ -283,7 +356,7 @@ async def _analyze_async(
283
356
  for child in await node.children_async(
284
357
  static_context, include_partials=include_partials
285
358
  ):
286
- await _visit(child, template_name, scope)
359
+ await _visit(child, template_name, scope, just_globals=just_globals)
287
360
  scope.pop()
288
361
 
289
362
  for node in template.nodes:
liquid/stream.py CHANGED
@@ -16,7 +16,7 @@ from .token import reverse_operators
16
16
  class TokenStream:
17
17
  """Step through a sequence of tokens."""
18
18
 
19
- eof = Token(TOKEN_EOF, "", -1, "")
19
+ eof = Token(TOKEN_EOF, TOKEN_EOF, -1, "")
20
20
 
21
21
  def __init__(
22
22
  self,
liquid/template.py CHANGED
@@ -13,8 +13,11 @@ from typing import Mapping
13
13
  from typing import Optional
14
14
  from typing import TextIO
15
15
  from typing import Type
16
+ from typing import TypedDict
16
17
  from typing import Union
17
18
 
19
+ from .builtin.tags.comment_tag import CommentNode
20
+ from .builtin.tags.doc_tag import DocNode
18
21
  from .context import FutureContext
19
22
  from .context import RenderContext
20
23
  from .exceptions import LiquidError
@@ -22,8 +25,8 @@ from .exceptions import LiquidInterrupt
22
25
  from .exceptions import LiquidSyntaxError
23
26
  from .exceptions import StopRender
24
27
  from .output import LimitedStringIO
25
- from .static_analysis import _analyze
26
- from .static_analysis import _analyze_async
28
+ from .static_analysis import analyze
29
+ from .static_analysis import analyze_async
27
30
  from .utils import ReadOnlyChainMap
28
31
 
29
32
  if TYPE_CHECKING:
@@ -262,11 +265,11 @@ class BoundTemplate:
262
265
  include_partials: If `True`, we will try to load partial templates and
263
266
  analyze those templates too.
264
267
  """
265
- return _analyze(self, include_partials=include_partials)
268
+ return analyze(self, include_partials=include_partials)
266
269
 
267
270
  async def analyze_async(self, *, include_partials: bool = True) -> TemplateAnalysis:
268
271
  """An async version of `analyze`."""
269
- return await _analyze_async(self, include_partials=include_partials)
272
+ return await analyze_async(self, include_partials=include_partials)
270
273
 
271
274
  def variables(self, *, include_partials: bool = True) -> list[str]:
272
275
  """Return a list of variables used in this template without path segments.
@@ -520,6 +523,52 @@ class BoundTemplate:
520
523
  """Return a list of tag names used in this template."""
521
524
  return list((await self.analyze_async(include_partials=include_partials)).tags)
522
525
 
526
+ def comments(self) -> list[CommentNode]:
527
+ """Return a list of comment tag nodes found in this template.
528
+
529
+ Instances of `CommentNode` and `InlineCommentNode` have `token` and
530
+ `text` properties. Use `[node.text for node in template.comments()]` to
531
+ get a list of comment strings.
532
+
533
+ Note that this method does not try to load included or rendered
534
+ templates.
535
+ """
536
+ context = RenderContext(self)
537
+ nodes: list[CommentNode] = []
538
+
539
+ def visit(node: Node) -> None:
540
+ if isinstance(node, CommentNode):
541
+ nodes.append(node)
542
+
543
+ for child in node.children(context, include_partials=False):
544
+ visit(child)
545
+
546
+ for child in self.nodes:
547
+ visit(child)
548
+
549
+ return nodes
550
+
551
+ def docs(self) -> list[DocNode]:
552
+ """Return a list of doc tag nodes found in this template.
553
+
554
+ Instances of `DocNode` have `token` and `text` properties. Use
555
+ `[node.text for node in template.docs()]` to get a list of doc strings.
556
+ """
557
+ context = RenderContext(self)
558
+ nodes: list[DocNode] = []
559
+
560
+ def visit(node: Node) -> None:
561
+ if isinstance(node, DocNode):
562
+ nodes.append(node)
563
+
564
+ for child in node.children(context, include_partials=False):
565
+ visit(child)
566
+
567
+ for child in self.nodes:
568
+ visit(child)
569
+
570
+ return nodes
571
+
523
572
 
524
573
  class AwareBoundTemplate(BoundTemplate):
525
574
  """A `BoundTemplate` subclass with a `TemplateDrop` in the global namespace."""
@@ -559,6 +608,12 @@ class FutureAwareBoundTemplate(AwareBoundTemplate):
559
608
  context_class = FutureContext
560
609
 
561
610
 
611
+ class _TemplateDropItems(TypedDict):
612
+ directory: str
613
+ name: str
614
+ suffix: str | None
615
+
616
+
562
617
  class TemplateDrop(Mapping[str, Optional[str]]):
563
618
  """Template meta data mapping."""
564
619
 
@@ -575,7 +630,7 @@ class TemplateDrop(Mapping[str, Optional[str]]):
575
630
  if "." in self.stem:
576
631
  self.suffix = self.stem.split(".")[-1]
577
632
 
578
- self._items = {
633
+ self._items: _TemplateDropItems = {
579
634
  "directory": self.path.parent.name,
580
635
  "name": self.path.name.split(".")[0],
581
636
  "suffix": self.suffix,
@@ -594,7 +649,7 @@ class TemplateDrop(Mapping[str, Optional[str]]):
594
649
  return item in self._items
595
650
 
596
651
  def __getitem__(self, key: object) -> Optional[str]:
597
- return self._items[str(key)]
652
+ return self._items[str(key)] # type: ignore
598
653
 
599
654
  def __len__(self) -> int:
600
655
  return len(self._items)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-liquid
3
- Version: 2.1.0
3
+ Version: 2.2.1
4
4
  Summary: A Python engine for the Liquid template language.
5
5
  Project-URL: Change Log, https://github.com/jg-rp/liquid/blob/main/CHANGES.md
6
6
  Project-URL: Documentation, https://jg-rp.github.io/liquid/
@@ -19,9 +19,10 @@ Classifier: Programming Language :: Python :: 3.10
19
19
  Classifier: Programming Language :: Python :: 3.11
20
20
  Classifier: Programming Language :: Python :: 3.12
21
21
  Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
22
23
  Classifier: Programming Language :: Python :: Implementation :: CPython
23
24
  Classifier: Programming Language :: Python :: Implementation :: PyPy
24
- Requires-Python: >=3.7
25
+ Requires-Python: >=3.9
25
26
  Requires-Dist: babel>=2
26
27
  Requires-Dist: markupsafe>=3
27
28
  Requires-Dist: python-dateutil>=2.8.1
@@ -1,11 +1,11 @@
1
- liquid/__init__.py,sha256=gQBAgPnQURpFuvzPsHbB0XVy2LtBlofIRTVfl4fyvjs,7696
1
+ liquid/__init__.py,sha256=YIN6_DEfW6bv9bhNED8Pnl0Ryt3_uCPt1dvMIuw_Yfk,7696
2
2
  liquid/analyze_tags.py,sha256=Uc1nueKLRiIejs2JyQIC7u2pxp9l-5HJAMWlg-Qj1m8,7615
3
- liquid/ast.py,sha256=ec-c8F7B2_yj2FmYiOFnnvu2JSd7c4mzTDGlYeztQ_8,7653
3
+ liquid/ast.py,sha256=4ZqSbuW4mv9IvoIMPk3OICUaOI1vPh8P8uFCrXI_3XA,8140
4
4
  liquid/context.py,sha256=gSJI1mj7mdcUfiQ09_XCEj5JxAosCGEX85Uuzl9apeI,21505
5
5
  liquid/environment.py,sha256=1X5WsOHcFvKAjljQcG3F-cQ9sfxzaiD-N3jGJOG5yHU,23940
6
- liquid/exceptions.py,sha256=hG4SklVAOg-FOy2Y3husZmQdAzvn4bpB7FaunaLHTnU,8299
6
+ liquid/exceptions.py,sha256=FOc4KAOijF4WHrBGepvi0ycAYT5hu0rhdpsVb2C-ZHs,8336
7
7
  liquid/expression.py,sha256=Ozgajah2R5eg1yWA4ITFGaMFoOLt8GIjkE1NKqbBEzk,1143
8
- liquid/filter.py,sha256=-Mbrtui9ELPMpprJJN97GrpWJF47pzYSWGNM__p_A_k,6687
8
+ liquid/filter.py,sha256=3NtqGk6nHNwe6ux3X6ZIzzcEMJNA_SaMrBmMQA8hf-I,6735
9
9
  liquid/lex.py,sha256=PrnnUWhXZO6novkO0KE-p4virh5cNJsg7m85sg2IUiw,8004
10
10
  liquid/limits.py,sha256=BxqiC4Ax5iadmh6jDRcUNjFfOvbMkvZ1a046R1Cb8UA,1700
11
11
  liquid/loader.py,sha256=R_RniSRszCXmRVZgXlu9WjV130cWvspVcC3vIVpf-qg,4381
@@ -15,14 +15,14 @@ liquid/output.py,sha256=1rgPmXGWUTut-aBBbHs-TCCZ2UGUhmbJYRmCsnqIOCk,1000
15
15
  liquid/parser.py,sha256=F8aC__UiUzz39lxhPr3sl67l-V-mrxkdn-4MAaCTWRE,3824
16
16
  liquid/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
17
  liquid/span.py,sha256=Z7Xb5V_h3WLvVmZUBebZeZkJFHKa33Bfcqq3S60SMSk,1122
18
- liquid/static_analysis.py,sha256=4p6-xwzqE0WKmtjiKtjkMPbHIFqYo9yue3bOksjUqF8,11975
19
- liquid/stream.py,sha256=nYBSjdgs5SkjAM7TTTkmZtc_DF1_KOvQrTGsjZXSrYE,4882
18
+ liquid/static_analysis.py,sha256=iPUqxiufXyK5EoPZQ9WNRKAzkqBYiX1HaSfX8oZhL9c,14537
19
+ liquid/stream.py,sha256=jbIATQRY1azN5BX91FfnhL3mgBcYDJ3ZlEjI9kfmfaA,4889
20
20
  liquid/stringify.py,sha256=TkRO0KbZVCY3usNV6EXfG6CbtWhuLoI7joxcrEArSNE,892
21
21
  liquid/tag.py,sha256=h5jexl6NjUQnM2py4s5db1ksh0s8vi31YHud5kHJt1E,1189
22
- liquid/template.py,sha256=hLylYHSf9_nkLUymfn8o1hGMrHRhiRfU_QRFMjLPw_A,22376
22
+ liquid/template.py,sha256=ne1abyNX8Tytl-d-vAROxtn-hHt_-O4nrn0eb2b_5fU,24062
23
23
  liquid/token.py,sha256=gxqMGcGk1fWihz5gQ2BQJzqSEcY7tCAZiIjcdC34P1w,3995
24
24
  liquid/undefined.py,sha256=Y57D69YR6yXSHV4x1rg1xtoTe-6M49h-mqlSO7pcRSE,5673
25
- liquid/builtin/__init__.py,sha256=oEzqtKMvqvfoqzcN1Sq_Qbq094hySWNTCnE5Ga-CkcU,7018
25
+ liquid/builtin/__init__.py,sha256=YNQlg5dzSBnU4asR_1yX111l6Cl1Rj_JqwEnOdT7l5U,7090
26
26
  liquid/builtin/content.py,sha256=HXo2ty2cQxsOkXJDhWmcAWC0ny73w9oABaer50zGp70,1122
27
27
  liquid/builtin/illegal.py,sha256=RRzzlqo2_scdtbMt_hZq0pr-7khgbxrdub9XH-_goPs,844
28
28
  liquid/builtin/output.py,sha256=4r_QJimFTbBdubhdIOG_nweROEOXiWHlp_xNmWXy564,2279
@@ -41,44 +41,45 @@ liquid/builtin/filters/array.py,sha256=dMuT15EASXU4ejmNBmawdi92u0f7y_JhqVaGoP48z
41
41
  liquid/builtin/filters/extra.py,sha256=m13_1nbZyRBl3CZV9WWGLlUZueRPt8cKPEHRAioqM_o,2710
42
42
  liquid/builtin/filters/math.py,sha256=A_K2wg5gxm5Ncj9zMGDX_Lt7jjx4bRr3CywbCiCtqxo,3925
43
43
  liquid/builtin/filters/misc.py,sha256=t2ZsaDLKRc1-8XxZaiEOh7oAMqFJ4N5-tzfbpLHbmIQ,3466
44
- liquid/builtin/filters/string.py,sha256=3Ub1z0VeCeAT07FaDVAazw2J9vFmpqtehtEtujNZxdg,11963
44
+ liquid/builtin/filters/string.py,sha256=mWG-14HttAqyPA1atLmwdXjbxwDpAowookbadk_jijI,12164
45
45
  liquid/builtin/loaders/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
46
- liquid/builtin/loaders/caching_file_system_loader.py,sha256=-9riAxL8933u_w1Ppweia-WkfFlxVU3aCspdiNyD5xQ,1931
46
+ liquid/builtin/loaders/caching_file_system_loader.py,sha256=V4OLa6UGT-RELGiV4eu02TeLL4P_JIE2kMYMrO3qy8U,2158
47
47
  liquid/builtin/loaders/choice_loader.py,sha256=FE23RLMOiWmsRGZv9t6QmlG7bgMEUSblnucAv3j0SBM,2816
48
48
  liquid/builtin/loaders/dict_loader.py,sha256=8TdMJqaYPLwllmIiEmuZNXqH6UOOsi4HuJ2t5sztOOw,1785
49
- liquid/builtin/loaders/file_system_loader.py,sha256=VAS6C-oez9lvOq81Q8bPBb3JjHC2ltC2DvgPf32pdgE,3895
49
+ liquid/builtin/loaders/file_system_loader.py,sha256=6CzmJnktLE9n6y8YYEtUZFa4nDsZ6vsAWIDwCg6_vE0,4528
50
50
  liquid/builtin/loaders/mixins.py,sha256=4Hj5wWGMOi3LqA9ZJYsUb4LFbG0a5DDb9H975G8nn58,5209
51
51
  liquid/builtin/loaders/package_loader.py,sha256=XP7LT3aMClgfBlI8OMyHOERlXTXEWmqFH7HskCnY3cs,3659
52
52
  liquid/builtin/tags/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
53
53
  liquid/builtin/tags/assign_tag.py,sha256=VTfkPbF-i0KLqMmaacBItFQHaBTP7ECWR-RvVh9Q6lY,2374
54
54
  liquid/builtin/tags/capture_tag.py,sha256=7bOG7qVwcqRx1eStb96AZKQ9tKLBjmzaYAe27LJTIm4,3037
55
- liquid/builtin/tags/case_tag.py,sha256=OUKu_UiUeJ8Q9C3FePNSSkRiiM6w44Ze5HIzuZj4Pxo,9073
56
- liquid/builtin/tags/comment_tag.py,sha256=KNM2heK7U9S7S9PiNjVilwS8kuABfILiVpgjAmLbb_8,2213
55
+ liquid/builtin/tags/case_tag.py,sha256=8ZN3pWP6axr-y7XAGIDjOkTDis0KSxpw7m1ITSElOVw,9040
56
+ liquid/builtin/tags/comment_tag.py,sha256=BMvee7nVBim_iDScKWbcUBCxNXzbBvguYfQUG3GM1y8,2250
57
57
  liquid/builtin/tags/cycle_tag.py,sha256=tbgnEiwENXIfRAKRsVg18KRsZwaNLOLZeuLy-0Xm5c0,3789
58
58
  liquid/builtin/tags/decrement_tag.py,sha256=WgkwWqjJgzjfEKffTKNw0ao6N0JxniadxGO_n1k8oZE,1660
59
- liquid/builtin/tags/doc_tag.py,sha256=2VjfL-todQK-ejjIdWIRP97jrKofb_eP8DVh6-q69NY,2044
59
+ liquid/builtin/tags/doc_tag.py,sha256=luc2QI-4N7KF49vWak6dNm5Hs9jwlXTAfUlEsr6ZCNM,2055
60
60
  liquid/builtin/tags/echo_tag.py,sha256=VvYth9VuA-A9bsfGUOLlM4QxL_frsgWena3yBrrLKPI,1174
61
61
  liquid/builtin/tags/for_tag.py,sha256=vVSc9WJ0ZeLW8zgDwNs_dVr8qe9lrg92Ny90GmpO_Jo,9440
62
- liquid/builtin/tags/if_tag.py,sha256=u8Jqk-HaT-bK5deaoDltdmrLpL_0m3UM8a1_r9OXsjc,6560
62
+ liquid/builtin/tags/if_tag.py,sha256=4hGDjQo33PXxsXui6pZ75Wx2BkEtt5SOiZoK3Yb2pL8,6588
63
63
  liquid/builtin/tags/ifchanged_tag.py,sha256=VpJNDhT7MFXcNmRrYiWcJ9whhaRXh81oe_phatS5_Kg,2734
64
64
  liquid/builtin/tags/include_tag.py,sha256=eAnjcbJ2-e0382TkrV2WJ9dACFWi_UNUeKG-QRuiOig,9220
65
65
  liquid/builtin/tags/increment_tag.py,sha256=SV2nrbE90l4iz36WCROqGE0RkkzztWUpU1_Pyp2DDQo,1662
66
66
  liquid/builtin/tags/inline_comment_tag.py,sha256=hLLtvaI-VpJxzFeoLTwDTj46g13A1ke6blqVo2MP0Cs,1357
67
67
  liquid/builtin/tags/liquid_tag.py,sha256=vQh2T8_3uBfTTgp6PNqi_bOhbRNv45eoo9yoOXcNyz8,5465
68
- liquid/builtin/tags/render_tag.py,sha256=h2np1g8gK43Kind2Mw5Sr9bR7cQg2k2dTAOsH-w2ZOY,11787
68
+ liquid/builtin/tags/render_tag.py,sha256=iYWjKYQBmGHQVQ9OZgPZvVhraygFdmPzITin7cKJI5E,14309
69
69
  liquid/builtin/tags/tablerow_tag.py,sha256=qE5hIrlJXdKiLJ0u1YLgjnqt6GedWujfcfIO0b9x0d8,8947
70
70
  liquid/builtin/tags/unless_tag.py,sha256=udlHvVxILJsYfDaWRkj6Ynmnz2-2FF5RfkP0eizXt7k,6932
71
- liquid/extra/__init__.py,sha256=DNS-k6uH7-IvYu_6InPk38CmjW1orggBaj0rkBkvy74,2855
71
+ liquid/extra/__init__.py,sha256=KrE9DAkwCAOLIaBHt73pnL6sb1tN5-UEKoQsX1b43Gg,2886
72
72
  liquid/extra/filters/__init__.py,sha256=tsEfSmaJvd6xN8DfKISPOm_KcHOmBVc4RSQqViD8NvY,733
73
73
  liquid/extra/filters/_json.py,sha256=Od7iJWKCWQzf1eZiIqOG1LGwx2JTiHCe95GO5DqgHYY,844
74
74
  liquid/extra/filters/array.py,sha256=QvuP2jPg0G4xgIGXOcCRvjpX0O5h7U-WpayAaYmYDdc,1590
75
- liquid/extra/filters/babel.py,sha256=ofFSAaUfOyRYC94_LEVLvEeyzrrh7YssVXRcwKBWQPg,19188
75
+ liquid/extra/filters/babel.py,sha256=K0kvw8r5Xy4NwvX4qv-lym14rZ25gB4fCnHqj-IVagI,19187
76
76
  liquid/extra/filters/html.py,sha256=j6ghLorvxlVwAtyfVJlmB1tnroGJb8hD-uxUVALVSEQ,929
77
77
  liquid/extra/filters/translate.py,sha256=PDEgvmdOvg45ChAKLsaqtamOqaBWOFh4XzQC8igXIyE,13019
78
- liquid/extra/tags/__init__.py,sha256=IGL2PPie0T73DQRNB-BY3hKOq710ZzMvlW31qulhtI0,329
78
+ liquid/extra/tags/__init__.py,sha256=iqB5oOPYhZey-dyljrgAjGeni4lK8Q9-rW2cGRHa4hw,383
79
79
  liquid/extra/tags/_with.py,sha256=QLlpY3oW8prCmutEXv1uUCMo_hloLd-sEbiMkMIbtXM,2627
80
- liquid/extra/tags/extends_tag.py,sha256=_X5wYKVtzSetuGlEUI9gl5nkUJmwEAS7sQIaZHBKexM,17978
80
+ liquid/extra/tags/extends_tag.py,sha256=3PL1y1FHaeh6L4rA_Xz6wP6l5oHdiOY_u42ivS6c3xE,17893
81
81
  liquid/extra/tags/macro_tag.py,sha256=ErjPxOVB50KKn2qi7-2Icmrbx5Uldv2vUSj49TCs4bY,8749
82
+ liquid/extra/tags/snippet_tag.py,sha256=8tFZWDvwhRId5IV0mNKhkFswJx3eTeAYs7-nAwiorNY,3155
82
83
  liquid/extra/tags/translate_tag.py,sha256=wv2uK9QtWDrArVeAWmH2QQ7fkM5EAM4gK2bWjlstcnE,12515
83
84
  liquid/future/__init__.py,sha256=5BUZi58WyWjPQDflh2C3sQJmuSKAZgNG_286ERyYKFE,79
84
85
  liquid/future/environment.py,sha256=Zx4RbgIOMmH5N_VH9poOGRLcSI9DAA5_EkUlP-L6ZlA,986
@@ -89,7 +90,7 @@ liquid/utils/chain_map.py,sha256=nxkw3wwF6ddlGarIuL7Ii2elm4dU80LySgdQx1oift0,151
89
90
  liquid/utils/html.py,sha256=TmqOOpRMsy7fqZLj7X5ybd_XQnWW2YDAVDwTP1Gwf40,1706
90
91
  liquid/utils/lru_cache.py,sha256=p7bXOGaUwJpLsREI2lGSzK6lLna5W_k_zNXKWnPJdos,4022
91
92
  liquid/utils/text.py,sha256=1SwDECNMaqnnZ05je_AZZgxqzZd6U-mvq5jNU3W1-Qk,841
92
- python_liquid-2.1.0.dist-info/METADATA,sha256=hWoZ7ptvnd2teO9UkGuQeDhpYCQxoTbhG42ndbWDwJ0,6694
93
- python_liquid-2.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
94
- python_liquid-2.1.0.dist-info/licenses/LICENSE,sha256=yAFURzud5ERNHt1rZIPnTLJ92ep7q8y5yG9g5DUMR_E,1075
95
- python_liquid-2.1.0.dist-info/RECORD,,
93
+ python_liquid-2.2.1.dist-info/METADATA,sha256=ttOrZWxcSER0sxi5fw52WvjFIqb07SMBFMj18TLp2gk,6745
94
+ python_liquid-2.2.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
95
+ python_liquid-2.2.1.dist-info/licenses/LICENSE,sha256=yAFURzud5ERNHt1rZIPnTLJ92ep7q8y5yG9g5DUMR_E,1075
96
+ python_liquid-2.2.1.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.27.0
2
+ Generator: hatchling 1.30.1
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any