python-liquid 2.0.2__tar.gz → 2.2.0__tar.gz

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 (98) hide show
  1. {python_liquid-2.0.2 → python_liquid-2.2.0}/PKG-INFO +4 -2
  2. {python_liquid-2.0.2 → python_liquid-2.2.0}/README.md +1 -0
  3. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/__init__.py +1 -1
  4. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/ast.py +17 -4
  5. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/builtin/__init__.py +4 -0
  6. python_liquid-2.2.0/liquid/builtin/filters/extra.py +83 -0
  7. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/builtin/filters/string.py +45 -4
  8. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/builtin/loaders/caching_file_system_loader.py +4 -0
  9. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/builtin/loaders/file_system_loader.py +21 -4
  10. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/builtin/tags/case_tag.py +3 -3
  11. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/builtin/tags/comment_tag.py +2 -2
  12. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/builtin/tags/doc_tag.py +1 -1
  13. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/builtin/tags/if_tag.py +1 -1
  14. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/builtin/tags/render_tag.py +85 -28
  15. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/exceptions.py +3 -2
  16. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/extra/__init__.py +2 -1
  17. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/extra/filters/babel.py +1 -1
  18. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/extra/tags/__init__.py +2 -0
  19. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/extra/tags/extends_tag.py +1 -2
  20. python_liquid-2.2.0/liquid/extra/tags/snippet_tag.py +107 -0
  21. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/filter.py +4 -1
  22. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/static_analysis.py +105 -32
  23. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/template.py +61 -6
  24. {python_liquid-2.0.2 → python_liquid-2.2.0}/pyproject.toml +5 -4
  25. python_liquid-2.0.2/liquid/builtin/filters/extra.py +0 -21
  26. {python_liquid-2.0.2 → python_liquid-2.2.0}/.gitignore +0 -0
  27. {python_liquid-2.0.2 → python_liquid-2.2.0}/LICENSE +0 -0
  28. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/analyze_tags.py +0 -0
  29. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/builtin/content.py +0 -0
  30. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/builtin/drops/__init__.py +0 -0
  31. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/builtin/drops/drops.py +0 -0
  32. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/builtin/expressions/__init__.py +0 -0
  33. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/builtin/expressions/_tokenize.py +0 -0
  34. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/builtin/expressions/arguments.py +0 -0
  35. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/builtin/expressions/filtered.py +0 -0
  36. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/builtin/expressions/logical.py +0 -0
  37. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/builtin/expressions/loop.py +0 -0
  38. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/builtin/expressions/path.py +0 -0
  39. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/builtin/expressions/primitive.py +0 -0
  40. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/builtin/filters/__init__.py +0 -0
  41. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/builtin/filters/array.py +0 -0
  42. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/builtin/filters/math.py +0 -0
  43. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/builtin/filters/misc.py +0 -0
  44. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/builtin/illegal.py +0 -0
  45. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/builtin/loaders/__init__.py +0 -0
  46. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/builtin/loaders/choice_loader.py +0 -0
  47. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/builtin/loaders/dict_loader.py +0 -0
  48. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/builtin/loaders/mixins.py +0 -0
  49. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/builtin/loaders/package_loader.py +0 -0
  50. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/builtin/output.py +0 -0
  51. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/builtin/tags/__init__.py +0 -0
  52. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/builtin/tags/assign_tag.py +0 -0
  53. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/builtin/tags/capture_tag.py +0 -0
  54. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/builtin/tags/cycle_tag.py +0 -0
  55. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/builtin/tags/decrement_tag.py +0 -0
  56. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/builtin/tags/echo_tag.py +0 -0
  57. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/builtin/tags/for_tag.py +0 -0
  58. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/builtin/tags/ifchanged_tag.py +0 -0
  59. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/builtin/tags/include_tag.py +0 -0
  60. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/builtin/tags/increment_tag.py +0 -0
  61. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/builtin/tags/inline_comment_tag.py +0 -0
  62. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/builtin/tags/liquid_tag.py +0 -0
  63. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/builtin/tags/tablerow_tag.py +0 -0
  64. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/builtin/tags/unless_tag.py +0 -0
  65. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/context.py +0 -0
  66. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/environment.py +0 -0
  67. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/expression.py +0 -0
  68. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/extra/filters/__init__.py +0 -0
  69. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/extra/filters/_json.py +0 -0
  70. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/extra/filters/array.py +0 -0
  71. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/extra/filters/html.py +0 -0
  72. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/extra/filters/translate.py +0 -0
  73. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/extra/tags/_with.py +0 -0
  74. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/extra/tags/macro_tag.py +0 -0
  75. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/extra/tags/translate_tag.py +0 -0
  76. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/future/__init__.py +0 -0
  77. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/future/environment.py +0 -0
  78. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/future/filters/__init__.py +0 -0
  79. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/future/tags/__init__.py +0 -0
  80. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/lex.py +0 -0
  81. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/limits.py +0 -0
  82. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/loader.py +0 -0
  83. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/messages.py +0 -0
  84. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/mode.py +0 -0
  85. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/output.py +0 -0
  86. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/parser.py +0 -0
  87. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/py.typed +0 -0
  88. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/span.py +0 -0
  89. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/stream.py +0 -0
  90. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/stringify.py +0 -0
  91. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/tag.py +0 -0
  92. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/token.py +0 -0
  93. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/undefined.py +0 -0
  94. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/utils/__init__.py +0 -0
  95. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/utils/chain_map.py +0 -0
  96. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/utils/html.py +0 -0
  97. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/utils/lru_cache.py +0 -0
  98. {python_liquid-2.0.2 → python_liquid-2.2.0}/liquid/utils/text.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-liquid
3
- Version: 2.0.2
3
+ Version: 2.2.0
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
@@ -105,6 +106,7 @@ $ conda install -c conda-forge python-liquid
105
106
 
106
107
  - [Python Liquid2](https://github.com/jg-rp/python-liquid2): A new Python engine for Liquid with [extra features](https://jg-rp.github.io/python-liquid2/migration/#new-features).
107
108
  - [Ruby Liquid2](https://github.com/jg-rp/ruby-liquid2): Liquid2 templates for Ruby.
109
+ - [Micro Liquid](https://github.com/jg-rp/micro-liquid): A stripped-down Liquid-like template engine for Python. You can think of it as a non-evaluating alternative to Python's [f-strings](https://docs.python.org/3/tutorial/inputoutput.html#formatted-string-literals) or [t-strings](https://docs.python.org/3.14/whatsnew/3.14.html#whatsnew314-pep750).
108
110
  - [LiquidScript](https://github.com/jg-rp/liquidscript): A JavaScript engine for Liquid with a similar high-level API to Python Liquid.
109
111
 
110
112
  ## Quick Start
@@ -75,6 +75,7 @@ $ conda install -c conda-forge python-liquid
75
75
 
76
76
  - [Python Liquid2](https://github.com/jg-rp/python-liquid2): A new Python engine for Liquid with [extra features](https://jg-rp.github.io/python-liquid2/migration/#new-features).
77
77
  - [Ruby Liquid2](https://github.com/jg-rp/ruby-liquid2): Liquid2 templates for Ruby.
78
+ - [Micro Liquid](https://github.com/jg-rp/micro-liquid): A stripped-down Liquid-like template engine for Python. You can think of it as a non-evaluating alternative to Python's [f-strings](https://docs.python.org/3/tutorial/inputoutput.html#formatted-string-literals) or [t-strings](https://docs.python.org/3.14/whatsnew/3.14.html#whatsnew314-pep750).
78
79
  - [LiquidScript](https://github.com/jg-rp/liquidscript): A JavaScript engine for Liquid with a similar high-level API to Python Liquid.
79
80
 
80
81
  ## Quick Start
@@ -56,7 +56,7 @@ from .tag import Tag
56
56
 
57
57
  from . import future
58
58
 
59
- __version__ = "2.0.2"
59
+ __version__ = "2.2.0"
60
60
 
61
61
  __all__ = (
62
62
  "AwareBoundTemplate",
@@ -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):
@@ -21,6 +21,7 @@ from .filters.array import sort_natural
21
21
  from .filters.array import sum_
22
22
  from .filters.array import uniq
23
23
  from .filters.array import where
24
+ from .filters.extra import escapejs
24
25
  from .filters.extra import safe
25
26
  from .filters.math import abs_
26
27
  from .filters.math import at_least
@@ -57,6 +58,7 @@ from .filters.string import replace_last
57
58
  from .filters.string import rstrip
58
59
  from .filters.string import slice_
59
60
  from .filters.string import split
61
+ from .filters.string import squish
60
62
  from .filters.string import strip
61
63
  from .filters.string import strip_html
62
64
  from .filters.string import strip_newlines
@@ -174,6 +176,7 @@ def register(env: Environment) -> None: # noqa: PLR0915
174
176
  env.add_filter("base64_decode", base64_decode)
175
177
  env.add_filter("base64_url_safe_encode", base64_url_safe_encode)
176
178
  env.add_filter("base64_url_safe_decode", base64_url_safe_decode)
179
+ env.add_filter("squish", squish)
177
180
 
178
181
  env.add_filter("find", find)
179
182
  env.add_filter("find_index", find_index)
@@ -197,3 +200,4 @@ def register(env: Environment) -> None: # noqa: PLR0915
197
200
  env.add_filter("date", date)
198
201
 
199
202
  env.add_filter("safe", safe)
203
+ env.add_filter("escapejs", escapejs)
@@ -0,0 +1,83 @@
1
+ """Filters that don't exist in the reference implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import TYPE_CHECKING
7
+
8
+ from liquid import Markup
9
+ from liquid.filter import string_filter
10
+ from liquid.filter import with_environment
11
+
12
+ if TYPE_CHECKING:
13
+ from liquid import Environment
14
+
15
+
16
+ @with_environment
17
+ @string_filter
18
+ def safe(val: str, *, environment: Environment) -> str:
19
+ """Return a copy of _val_ that will not be automatically HTML escaped on output."""
20
+ if environment.autoescape:
21
+ return Markup(val)
22
+ return val
23
+
24
+
25
+ # `escapejs` is inspired by https://github.com/salesforce/secure-filters and Django's
26
+ # escapejs filter, https://github.com/django/django/blob/485f483d49144a2ea5401442bc3b937a370b3ca6/django/utils/html.py#L63
27
+
28
+ _ESCAPE_MAP = {
29
+ "\\": "\\u005C",
30
+ "'": "\\u0027",
31
+ '"': "\\u0022",
32
+ ">": "\\u003E",
33
+ "<": "\\u003C",
34
+ "&": "\\u0026",
35
+ "=": "\\u003D",
36
+ "-": "\\u002D",
37
+ ";": "\\u003B",
38
+ "`": "\\u0060",
39
+ "\u2028": "\\u2028",
40
+ "\u2029": "\\u2029",
41
+ }
42
+
43
+ _ESCAPE_MAP.update({chr(c): f"\\u{c:04X}" for c in range(32)})
44
+ _ESCAPE_RE = re.compile("[" + re.escape("".join(_ESCAPE_MAP.keys())) + "]")
45
+
46
+
47
+ @with_environment
48
+ @string_filter
49
+ def escapejs(val: str, *, environment: Environment) -> str:
50
+ """Escape characters for safe use in JavaScript string literals.
51
+
52
+ This filter escapes a string for embedding inside **JavaScript string
53
+ literals**, using either single or double quotes (e.g. `'...'` or `"..."`).
54
+ It replaces control characters and potentially dangerous symbols with
55
+ their corresponding Unicode escape sequences.
56
+
57
+ **Important:** This filter does **not** make strings safe for use in
58
+ JavaScript template literals (backtick strings), or in raw JavaScript
59
+ expressions. Use it only when placing data inside quoted JS strings
60
+ within inline `<script>` blocks or event handlers.
61
+
62
+ **Recommended alternatives:**
63
+ - Pass data using HTML `data-*` attributes and read them in JS via
64
+ `element.dataset`.
65
+ - For structured data, prefer a JSON-serialization approach using the
66
+ JSON filter.
67
+
68
+ Escaped characters include:
69
+ - ASCII control characters (U+0000 to U+001F)
70
+ - Characters like quotes, angle brackets, ampersands, equals signs
71
+ - Line/paragraph separators (U+2028, U+2029)
72
+
73
+ Args:
74
+ val: The input string to escape.
75
+ environment: The active Liquid environment
76
+
77
+ Returns:
78
+ A JavaScript-safe string, with problematic characters escaped as Unicode.
79
+ """
80
+ escaped = _ESCAPE_RE.sub(lambda m: _ESCAPE_MAP[m.group()], val)
81
+ if environment.autoescape:
82
+ return Markup(escaped)
83
+ return escaped
@@ -56,7 +56,35 @@ def downcase(val: str) -> str:
56
56
  @with_environment
57
57
  @string_filter
58
58
  def escape(val: str, *, environment: Environment) -> str:
59
- """Return _val_ with the characters &, < and > converted to HTML-safe sequences."""
59
+ """Escape special characters in a string for safe use in HTML.
60
+
61
+ This filter replaces the characters `&`, `<`, `>`, `'`, and `"` with their
62
+ corresponding HTML-safe sequences:
63
+
64
+ - `&` -> `&amp;`
65
+ - `<` -> `&lt;`
66
+ - `>` -> `&gt;`
67
+ - `'` -> `&#39;`
68
+ - `"` -> `&#34;`
69
+
70
+ This helps prevent HTML injection (XSS) when rendering untrusted content in
71
+ HTML element bodies or attributes.
72
+
73
+ Important: This filter does **not** make strings safe for use in JavaScript,
74
+ including in `<script>` blocks, inline event handler attributes (e.g. `onerror`),
75
+ or other JavaScript contexts. For those cases, use the `escapejs` filter instead.
76
+
77
+ When `autoescape` is enabled in the environment, this filter uses the same
78
+ escaping logic as the environment (via `markupsafe.escape()`). Otherwise, it
79
+ falls back to Python's standard `html.escape()`.
80
+
81
+ Args:
82
+ val: The input string to escape.
83
+ environment: The current rendering environment.
84
+
85
+ Returns:
86
+ A string with HTML-special characters replaced by safe escape sequences.
87
+ """
60
88
  if environment.autoescape:
61
89
  return markupsafe_escape(str(val))
62
90
  return html.escape(val)
@@ -65,10 +93,14 @@ def escape(val: str, *, environment: Environment) -> str:
65
93
  @with_environment
66
94
  @string_filter
67
95
  def escape_once(val: str, *, environment: Environment) -> str:
68
- """Return _val_ with the characters &, < and > converted to HTML-safe sequences.
96
+ """Escape a string for HTML, but avoid double-escaping existing entities.
69
97
 
70
- It is safe to use `escape_one` on string values that already contain HTML escape
71
- sequences.
98
+ Converts characters like `&`, `<`, and `>` to their HTML-safe sequences,
99
+ but leaves existing HTML entities untouched (e.g., `&amp;` stays `&amp;`).
100
+
101
+ This is useful when escaping content that may already be partially escaped.
102
+
103
+ See the `escape` filter for details and limitations.
72
104
  """
73
105
  if environment.autoescape:
74
106
  return Markup(val).unescape()
@@ -373,3 +405,12 @@ def base64_url_safe_decode(val: str) -> str:
373
405
  return base64.urlsafe_b64decode(val).decode()
374
406
  except binascii.Error as err:
375
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
@@ -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)
@@ -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:
@@ -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",