djinitx 0.3.7__tar.gz → 0.3.9__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 (76) hide show
  1. {djinitx-0.3.7 → djinitx-0.3.9}/PKG-INFO +3 -3
  2. {djinitx-0.3.7 → djinitx-0.3.9}/pyproject.toml +3 -3
  3. djinitx-0.3.9/requirements.txt +3 -0
  4. djinitx-0.3.9/src/djinit/core/parser.py +590 -0
  5. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/creators/files.py +56 -0
  6. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/creators/setup.py +4 -0
  7. djinitx-0.3.9/src/djinit/templates/components/base.py-tpl +11 -0
  8. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/config/settings/base.py-tpl +3 -6
  9. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/config/settings/development.py-tpl +1 -1
  10. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/config/urls.py-tpl +0 -18
  11. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/project/pyproject.toml-tpl +4 -5
  12. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/project/readme.md-tpl +10 -0
  13. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/project/requirements-tpl +0 -1
  14. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/ui/input.py +3 -0
  15. djinitx-0.3.9/uv.lock +364 -0
  16. djinitx-0.3.7/requirements.txt +0 -5
  17. djinitx-0.3.7/setup.py +0 -63
  18. djinitx-0.3.7/src/djinit/core/parser.py +0 -215
  19. djinitx-0.3.7/uv.lock +0 -337
  20. {djinitx-0.3.7 → djinitx-0.3.9}/.github/dependabot.yml +0 -0
  21. {djinitx-0.3.7 → djinitx-0.3.9}/.github/workflows/ci.yml +0 -0
  22. {djinitx-0.3.7 → djinitx-0.3.9}/.github/workflows/codeql.yml +0 -0
  23. {djinitx-0.3.7 → djinitx-0.3.9}/.github/workflows/dependabot-auto-merge.yml +0 -0
  24. {djinitx-0.3.7 → djinitx-0.3.9}/.github/workflows/pre-commit.yml +0 -0
  25. {djinitx-0.3.7 → djinitx-0.3.9}/.github/workflows/publish.yml +0 -0
  26. {djinitx-0.3.7 → djinitx-0.3.9}/.gitignore +0 -0
  27. {djinitx-0.3.7 → djinitx-0.3.9}/.pre-commit-config.yaml +0 -0
  28. {djinitx-0.3.7 → djinitx-0.3.9}/.python-version +0 -0
  29. {djinitx-0.3.7 → djinitx-0.3.9}/LICENSE +0 -0
  30. {djinitx-0.3.7 → djinitx-0.3.9}/MANIFEST.in +0 -0
  31. {djinitx-0.3.7 → djinitx-0.3.9}/README.md +0 -0
  32. {djinitx-0.3.7 → djinitx-0.3.9}/TODO.md +0 -0
  33. {djinitx-0.3.7 → djinitx-0.3.9}/ruff.toml +0 -0
  34. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/__init__.py +0 -0
  35. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/cli.py +0 -0
  36. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/core/__init__.py +0 -0
  37. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/core/base.py +0 -0
  38. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/core/config.py +0 -0
  39. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/core/types.py +0 -0
  40. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/creators/__init__.py +0 -0
  41. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/creators/app.py +0 -0
  42. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/creators/project.py +0 -0
  43. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templater/__init__.py +0 -0
  44. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templater/base.py +0 -0
  45. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/components/admin.py-tpl +0 -0
  46. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/components/api_readme.md-tpl +0 -0
  47. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/components/apps.py-tpl +0 -0
  48. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/components/models.py-tpl +0 -0
  49. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/components/models_readme.md-tpl +0 -0
  50. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/components/serializers.py-tpl +0 -0
  51. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/components/tests.py-tpl +0 -0
  52. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/components/urls.py-tpl +0 -0
  53. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/components/views.py-tpl +0 -0
  54. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/config/asgi.py-tpl +0 -0
  55. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/config/settings/production.py-tpl +0 -0
  56. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/config/wsgi.py-tpl +0 -0
  57. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/project/Dockerfile-tpl +0 -0
  58. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/project/ci/github_actions-tpl +0 -0
  59. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/project/ci/gitlab_ci-tpl +0 -0
  60. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/project/dockerignore-tpl +0 -0
  61. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/project/env_sample-tpl +0 -0
  62. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/project/gitignore-tpl +0 -0
  63. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/project/init.py-tpl +0 -0
  64. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/project/justfile-tpl +0 -0
  65. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/project/manage.py-tpl +0 -0
  66. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/project/procfile-tpl +0 -0
  67. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/project/runtime_txt-tpl +0 -0
  68. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/project/vite.config.ts-tpl +0 -0
  69. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/ui/__init__.py +0 -0
  70. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/ui/console.py +0 -0
  71. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/utils/__init__.py +0 -0
  72. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/utils/common.py +0 -0
  73. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/utils/django.py +0 -0
  74. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/utils/exceptions.py +0 -0
  75. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/utils/secretkey.py +0 -0
  76. {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/utils/validators.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: djinitx
3
- Version: 0.3.7
3
+ Version: 0.3.9
4
4
  Summary: A CLI tool to setup Django project
5
5
  Project-URL: Homepage, https://github.com/S4NKALP/djinit
6
6
  Author-email: Sankalp Tharu <sankalptharu50028@gmail.com>
@@ -12,12 +12,12 @@ Classifier: Framework :: Django
12
12
  Classifier: Framework :: Django :: 4.2
13
13
  Classifier: Framework :: Django :: 5.1
14
14
  Classifier: Framework :: Django :: 5.2
15
+ Classifier: Framework :: Django :: 6.0
15
16
  Classifier: Intended Audience :: Developers
16
17
  Classifier: License :: OSI Approved :: MIT License
17
18
  Classifier: Operating System :: OS Independent
18
19
  Classifier: Programming Language :: Python
19
20
  Classifier: Programming Language :: Python :: 3
20
- Classifier: Programming Language :: Python :: 3.10
21
21
  Classifier: Programming Language :: Python :: 3.11
22
22
  Classifier: Programming Language :: Python :: 3.12
23
23
  Classifier: Programming Language :: Python :: 3.13
@@ -25,7 +25,7 @@ Classifier: Topic :: Internet :: WWW/HTTP
25
25
  Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
26
26
  Classifier: Topic :: Software Development :: Code Generators
27
27
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
28
- Requires-Python: >=3.10
28
+ Requires-Python: >=3.11
29
29
  Requires-Dist: questionary>=2.0.1
30
30
  Requires-Dist: rich>=14.2.0
31
31
  Requires-Dist: typer>=0.21.1
@@ -7,10 +7,10 @@ packages = ["src/djinit"]
7
7
 
8
8
  [project]
9
9
  name = "djinitx"
10
- version = "0.3.7"
10
+ version = "0.3.9"
11
11
  description = "A CLI tool to setup Django project"
12
12
  readme = "README.md"
13
- requires-python = ">=3.10"
13
+ requires-python = ">=3.11"
14
14
  authors = [
15
15
  { name = "Sankalp Tharu", email = "sankalptharu50028@gmail.com" }
16
16
  ]
@@ -29,7 +29,6 @@ keywords = [
29
29
  classifiers=[
30
30
  "Programming Language :: Python",
31
31
  "Programming Language :: Python :: 3",
32
- "Programming Language :: Python :: 3.10",
33
32
  "Programming Language :: Python :: 3.11",
34
33
  "Programming Language :: Python :: 3.12",
35
34
  "Programming Language :: Python :: 3.13",
@@ -38,6 +37,7 @@ classifiers=[
38
37
  "Framework :: Django :: 4.2",
39
38
  "Framework :: Django :: 5.1",
40
39
  "Framework :: Django :: 5.2",
40
+ "Framework :: Django :: 6.0",
41
41
  "Intended Audience :: Developers",
42
42
  "License :: OSI Approved :: MIT License",
43
43
  "Operating System :: OS Independent",
@@ -0,0 +1,3 @@
1
+ rich>=14.2.0
2
+ questionary>=2.0.1
3
+ typer>=0.21.1
@@ -0,0 +1,590 @@
1
+ """
2
+ Safe template expression evaluation for djinit.
3
+
4
+ Templates use a tiny, comment-style logic language:
5
+
6
+ # @IF <expression> ... # @ELSEIF <expression> ... # @ELSE ... # @ENDIF
7
+ # @LOOP <var> in <expression> ... # @ENDLOOP
8
+ [[ variable ]] variable / attribute / index substitution
9
+
10
+ Expressions are evaluated by walking the parsed syntax tree with an explicit
11
+ allow-list. ``eval()`` is never used: restricting ``__builtins__`` is *not* a
12
+ sandbox, because object graphs reachable from ordinary literals (for example
13
+ ``().__class__.__base__.__subclasses__()``) still hand out ``os.system`` and
14
+ friends. An explicit interpreter has no such escape hatch.
15
+ """
16
+
17
+ import ast
18
+ import operator
19
+ import re
20
+ from collections.abc import Mapping
21
+ from typing import Any, Dict, List, Optional
22
+
23
+ __all__ = ["InFileLogicParser", "SafeExpressionError"]
24
+
25
+
26
+ class SafeExpressionError(ValueError):
27
+ """Raised when a template expression is malformed or uses a disallowed feature."""
28
+
29
+
30
+ # The only names an expression may reference besides the render context. These are
31
+ # side-effect free helpers; nothing that can import, open, execute or mutate.
32
+ _SAFE_BUILTINS: Mapping[str, Any] = {
33
+ "True": True,
34
+ "False": False,
35
+ "None": None,
36
+ "abs": abs,
37
+ "all": all,
38
+ "any": any,
39
+ "bool": bool,
40
+ "dict": dict,
41
+ "enumerate": enumerate,
42
+ "float": float,
43
+ "int": int,
44
+ "len": len,
45
+ "list": list,
46
+ "max": max,
47
+ "min": min,
48
+ "reversed": reversed,
49
+ "sorted": sorted,
50
+ "str": str,
51
+ "zip": zip,
52
+ }
53
+
54
+ # Attribute names that must never be reachable, even on an otherwise harmless
55
+ # object. Together with rejecting every ``_``-prefixed name this closes the usual
56
+ # attribute-hop escape routes (``__class__``, ``__globals__``, ``__subclasses__``…).
57
+ # ``format``/``format_map`` are included because the *format string* they receive is
58
+ # resolved with ``getattr`` inside CPython, which would sidestep the checks below.
59
+ _FORBIDDEN_ATTRIBUTES = frozenset(
60
+ {
61
+ "__import__",
62
+ "breakpoint",
63
+ "compile",
64
+ "delattr",
65
+ "eval",
66
+ "exec",
67
+ "exit",
68
+ "format",
69
+ "format_map",
70
+ "fork",
71
+ "getattr",
72
+ "globals",
73
+ "input",
74
+ "locals",
75
+ "open",
76
+ "popen",
77
+ "quit",
78
+ "setattr",
79
+ "spawn",
80
+ "system",
81
+ "vars",
82
+ }
83
+ )
84
+
85
+ # Calling a method is only allowed on plain data values. Without this, a context
86
+ # value that happens to be a module, class or function would expose its whole
87
+ # (dangerous) surface through dotted access.
88
+ _PLAIN_DATA_TYPES = (str, bytes, bytearray, bool, int, float, complex, list, tuple, dict, set, frozenset, Mapping)
89
+
90
+ # Guards against cheap resource exhaustion such as ``2 ** 99999999``.
91
+ _MAX_EXPONENT = 1000
92
+
93
+
94
+ class _SafeExpressionEvaluator(ast.NodeVisitor):
95
+ """Interprets a validated expression tree against the render context."""
96
+
97
+ _BINARY_OPERATORS = {
98
+ ast.Add: operator.add,
99
+ ast.Sub: operator.sub,
100
+ ast.Mult: operator.mul,
101
+ ast.Div: operator.truediv,
102
+ ast.FloorDiv: operator.floordiv,
103
+ ast.Mod: operator.mod,
104
+ ast.Pow: operator.pow,
105
+ }
106
+
107
+ _UNARY_OPERATORS = {
108
+ ast.UAdd: operator.pos,
109
+ ast.USub: operator.neg,
110
+ ast.Not: operator.not_,
111
+ ast.Invert: operator.invert,
112
+ }
113
+
114
+ _COMPARISON_OPERATORS = {
115
+ ast.Eq: operator.eq,
116
+ ast.NotEq: operator.ne,
117
+ ast.Lt: operator.lt,
118
+ ast.LtE: operator.le,
119
+ ast.Gt: operator.gt,
120
+ ast.GtE: operator.ge,
121
+ ast.Is: operator.is_,
122
+ ast.IsNot: operator.is_not,
123
+ ast.In: lambda a, b: operator.contains(b, a),
124
+ ast.NotIn: lambda a, b: not operator.contains(b, a),
125
+ }
126
+
127
+ def __init__(self, context: Mapping[str, Any]) -> None:
128
+ # Outermost scope is the render context; comprehension bodies push their own
129
+ # dicts so loop variables never leak into the surrounding scope.
130
+ self._scopes: List[Any] = [context]
131
+
132
+ # -- entry point ---------------------------------------------------------
133
+
134
+ def evaluate(self, tree: ast.Expression) -> Any:
135
+ return self.visit(tree.body)
136
+
137
+ def generic_visit(self, node: ast.AST) -> Any:
138
+ raise SafeExpressionError(f"{type(node).__name__} is not allowed in template expressions")
139
+
140
+ # -- literals ------------------------------------------------------------
141
+
142
+ def visit_Constant(self, node: ast.Constant) -> Any:
143
+ return node.value
144
+
145
+ def visit_List(self, node: ast.List) -> list:
146
+ return [self.visit(element) for element in node.elts]
147
+
148
+ def visit_Tuple(self, node: ast.Tuple) -> tuple:
149
+ return tuple(self.visit(element) for element in node.elts)
150
+
151
+ def visit_Set(self, node: ast.Set) -> set:
152
+ return {self.visit(element) for element in node.elts}
153
+
154
+ def visit_Dict(self, node: ast.Dict) -> dict:
155
+ result: dict = {}
156
+ for key_node, value_node in zip(node.keys, node.values):
157
+ if key_node is None:
158
+ raise SafeExpressionError("Dictionary unpacking is not allowed")
159
+ result[self.visit(key_node)] = self.visit(value_node)
160
+ return result
161
+
162
+ def visit_JoinedStr(self, node: ast.JoinedStr) -> str:
163
+ return "".join(str(self.visit(value)) for value in node.values)
164
+
165
+ def visit_FormattedValue(self, node: ast.FormattedValue) -> str:
166
+ value = self.visit(node.value)
167
+ if node.conversion == 115: # !s
168
+ return f"{value:s}"
169
+ if node.conversion == 114: # !r
170
+ return repr(value)
171
+ if node.conversion == 97: # !a
172
+ return ascii(value)
173
+ if node.format_spec is not None:
174
+ return format(value, self.visit(node.format_spec))
175
+ return str(value)
176
+
177
+ # -- names and attributes ------------------------------------------------
178
+
179
+ def visit_Name(self, node: ast.Name) -> Any:
180
+ return self._lookup(node.id)
181
+
182
+ def _lookup(self, name: str) -> Any:
183
+ for scope in reversed(self._scopes):
184
+ if name in scope:
185
+ return scope[name]
186
+ if name in _SAFE_BUILTINS:
187
+ return _SAFE_BUILTINS[name]
188
+ raise SafeExpressionError(f"Unknown name: {name!r}")
189
+
190
+ def visit_Attribute(self, node: ast.Attribute) -> Any:
191
+ return self._resolve_attribute(self.visit(node.value), node.attr)
192
+
193
+ @staticmethod
194
+ def _resolve_attribute(value: Any, name: str) -> Any:
195
+ if name.startswith("_") or name in _FORBIDDEN_ATTRIBUTES:
196
+ raise SafeExpressionError(f"Access to attribute {name!r} is not allowed")
197
+ if isinstance(value, Mapping):
198
+ # Templates routinely index plain dicts with dotted syntax.
199
+ try:
200
+ return value[name]
201
+ except KeyError:
202
+ raise AttributeError(name) from None
203
+ resolved = getattr(value, name)
204
+ if callable(resolved) and not isinstance(value, _PLAIN_DATA_TYPES):
205
+ raise SafeExpressionError(f"Access to method {name!r} is not allowed here")
206
+ return resolved
207
+
208
+ # -- operators -----------------------------------------------------------
209
+
210
+ def visit_BoolOp(self, node: ast.BoolOp) -> Any:
211
+ if isinstance(node.op, ast.And):
212
+ result: Any = True
213
+ for value in node.values:
214
+ result = self.visit(value)
215
+ if not result:
216
+ return result
217
+ return result
218
+ result = False
219
+ for value in node.values:
220
+ result = self.visit(value)
221
+ if result:
222
+ return result
223
+ return result
224
+
225
+ def visit_UnaryOp(self, node: ast.UnaryOp) -> Any:
226
+ handler = self._UNARY_OPERATORS.get(type(node.op))
227
+ if handler is None:
228
+ raise SafeExpressionError(f"Unary operator {type(node.op).__name__} is not allowed")
229
+ return handler(self.visit(node.operand))
230
+
231
+ def visit_BinOp(self, node: ast.BinOp) -> Any:
232
+ handler = self._BINARY_OPERATORS.get(type(node.op))
233
+ if handler is None:
234
+ raise SafeExpressionError(f"Binary operator {type(node.op).__name__} is not allowed")
235
+ left = self.visit(node.left)
236
+ right = self.visit(node.right)
237
+ if isinstance(node.op, ast.Pow) and isinstance(left, int) and isinstance(right, int):
238
+ if abs(right) > _MAX_EXPONENT:
239
+ raise SafeExpressionError("Exponent is too large")
240
+ return handler(left, right)
241
+
242
+ def visit_Compare(self, node: ast.Compare) -> bool:
243
+ left = self.visit(node.left)
244
+ for operator_node, comparator_node in zip(node.ops, node.comparators):
245
+ handler = self._COMPARISON_OPERATORS.get(type(operator_node))
246
+ if handler is None:
247
+ raise SafeExpressionError(f"Comparison {type(operator_node).__name__} is not allowed")
248
+ right = self.visit(comparator_node)
249
+ if not handler(left, right):
250
+ return False
251
+ left = right
252
+ return True
253
+
254
+ def visit_IfExp(self, node: ast.IfExp) -> Any:
255
+ return self.visit(node.body) if self.visit(node.test) else self.visit(node.orelse)
256
+
257
+ # -- subscripts ----------------------------------------------------------
258
+
259
+ def visit_Subscript(self, node: ast.Subscript) -> Any:
260
+ value = self.visit(node.value)
261
+ key = self._resolve_subscript_key(node.slice, value)
262
+ try:
263
+ return value[key]
264
+ except TypeError as exc:
265
+ raise SafeExpressionError(f"Cannot subscript {type(value).__name__}") from exc
266
+
267
+ def _resolve_subscript_key(self, node: ast.AST, value: Any) -> Any:
268
+ if isinstance(node, ast.Slice):
269
+ return slice(
270
+ self._resolve_subscript_bound(node.lower),
271
+ self._resolve_subscript_bound(node.upper),
272
+ self._resolve_subscript_bound(node.step),
273
+ )
274
+ if isinstance(node, ast.Tuple):
275
+ return tuple(self.visit(element) for element in node.elts)
276
+ return self.visit(node)
277
+
278
+ def _resolve_subscript_bound(self, node: Optional[ast.AST]) -> Optional[Any]:
279
+ return None if node is None else self.visit(node)
280
+
281
+ # -- calls ---------------------------------------------------------------
282
+
283
+ def visit_Call(self, node: ast.Call) -> Any:
284
+ if any(keyword.arg is None for keyword in node.keywords):
285
+ raise SafeExpressionError("** unpacking is not allowed in calls")
286
+ args = [self.visit(argument) for argument in node.args]
287
+ kwargs = {keyword.arg: self.visit(keyword.value) for keyword in node.keywords}
288
+ callee = self.visit(node.func)
289
+ if not callable(callee):
290
+ raise SafeExpressionError(f"{callee!r} is not callable")
291
+ return callee(*args, **kwargs)
292
+
293
+ # -- comprehensions ------------------------------------------------------
294
+
295
+ def visit_ListComp(self, node: ast.ListComp) -> list:
296
+ result: list = []
297
+ self._run_comprehension(node.generators, 0, lambda: result.append(self.visit(node.elt)))
298
+ return result
299
+
300
+ def visit_SetComp(self, node: ast.SetComp) -> set:
301
+ result: set = set()
302
+ self._run_comprehension(node.generators, 0, lambda: result.add(self.visit(node.elt)))
303
+ return result
304
+
305
+ def visit_GeneratorExp(self, node: ast.GeneratorExp) -> tuple:
306
+ result: list = []
307
+ self._run_comprehension(node.generators, 0, lambda: result.append(self.visit(node.elt)))
308
+ return tuple(result)
309
+
310
+ def visit_DictComp(self, node: ast.DictComp) -> dict:
311
+ result: dict = {}
312
+ self._run_comprehension(
313
+ node.generators,
314
+ 0,
315
+ lambda: result.__setitem__(self.visit(node.key), self.visit(node.value)),
316
+ )
317
+ return result
318
+
319
+ def _run_comprehension(self, generators: List[ast.comprehension], index: int, emit) -> None:
320
+ if index >= len(generators):
321
+ emit()
322
+ return
323
+
324
+ generator = generators[index]
325
+ for value in self._as_iterable(self.visit(generator.iter)):
326
+ self._scopes.append({})
327
+ try:
328
+ self._assign(generator.target, value)
329
+ if all(self.visit(condition) for condition in generator.ifs):
330
+ self._run_comprehension(generators, index + 1, emit)
331
+ finally:
332
+ self._scopes.pop()
333
+
334
+ @staticmethod
335
+ def _as_iterable(value: Any) -> Any:
336
+ if isinstance(value, (str, bytes)) or not hasattr(value, "__iter__"):
337
+ raise SafeExpressionError(f"{type(value).__name__} is not iterable")
338
+ return value
339
+
340
+ def _assign(self, target: ast.AST, value: Any) -> None:
341
+ if isinstance(target, ast.Name):
342
+ self._scopes[-1][target.id] = value
343
+ return
344
+ if isinstance(target, (ast.Tuple, ast.List)) and isinstance(value, (tuple, list)):
345
+ if len(target.elts) != len(value):
346
+ raise SafeExpressionError("Cannot unpack mismatched number of loop variables")
347
+ for element, item in zip(target.elts, value):
348
+ self._assign(element, item)
349
+ return
350
+ raise SafeExpressionError("Unsupported loop target")
351
+
352
+
353
+ def safe_eval(expression: str, context: Optional[Mapping[str, Any]] = None) -> Any:
354
+ """Evaluate a template expression without ``eval()``.
355
+
356
+ Args:
357
+ expression: The expression source, e.g. ``"use_tailwind and len(app_names) > 0"``.
358
+ context: Names available to the expression.
359
+
360
+ Raises:
361
+ SafeExpressionError: If the expression is empty, malformed or disallowed.
362
+ """
363
+ source = expression.strip()
364
+ if not source:
365
+ raise SafeExpressionError("Empty expression")
366
+
367
+ try:
368
+ tree = ast.parse(source, mode="eval")
369
+ except SyntaxError as exc:
370
+ raise SafeExpressionError(f"Invalid expression syntax: {source!r}") from exc
371
+
372
+ return _SafeExpressionEvaluator(context or {}).evaluate(tree)
373
+
374
+
375
+ class InFileLogicParser:
376
+ """
377
+ A lightweight template parser that uses comment-style markers for logic
378
+ and [[ variable ]] for substitutions.
379
+ """
380
+
381
+ def __init__(self, context: Dict[str, Any] = None):
382
+ self.context = context or {}
383
+
384
+ def _evaluate(self, expression: str) -> Any:
385
+ """Evaluate ``expression`` against the current context, safely."""
386
+ return safe_eval(expression, self.context)
387
+
388
+ def _get_value(self, key: str) -> str:
389
+ """
390
+ Supports nested/dotted access like [[ user.name ]] or [[ settings["DEBUG"] ]].
391
+ Dotted names are resolved against the context, and arbitrary expressions are
392
+ evaluated by the safe interpreter. Anything that cannot be resolved is
393
+ returned verbatim so a missing context key stays visible in the output.
394
+ """
395
+ key = key.strip()
396
+ try:
397
+ return str(self._evaluate(key))
398
+ except (AttributeError, IndexError, KeyError, TypeError, ValueError):
399
+ return f"[[ {key} ]]"
400
+
401
+ def render(self, template_text: str, context: Dict[str, Any] = None) -> str:
402
+ if context is not None:
403
+ self.context = context
404
+
405
+ final_lines = []
406
+ # Stack stores boolean results of nested IF blocks
407
+ # Each element is (current_block_result, has_any_true_branch_executed)
408
+ stack: List[List[bool]] = []
409
+
410
+ lines = template_text.splitlines()
411
+ i = 0
412
+ while i < len(lines):
413
+ line = lines[i]
414
+ stripped = line.strip()
415
+
416
+ # Handle @IF
417
+ if stripped.startswith("# @IF "):
418
+ expr = stripped[6:].strip()
419
+ try:
420
+ result = bool(self._evaluate(expr))
421
+ except (AttributeError, IndexError, KeyError, TypeError, ValueError):
422
+ result = False
423
+ stack.append([result, result])
424
+ i += 1
425
+ continue
426
+
427
+ # Handle @ELSEIF
428
+ elif stripped.startswith("# @ELSEIF "):
429
+ if not stack:
430
+ final_lines.append(line)
431
+ i += 1
432
+ continue
433
+
434
+ expr = stripped[9:].strip()
435
+ current_stack = stack[-1]
436
+
437
+ if current_stack[1]:
438
+ current_stack[0] = False
439
+ else:
440
+ try:
441
+ result = bool(self._evaluate(expr))
442
+ except (AttributeError, IndexError, KeyError, TypeError, ValueError):
443
+ result = False
444
+ current_stack[0] = result
445
+ if result:
446
+ current_stack[1] = True
447
+ i += 1
448
+ continue
449
+
450
+ # Handle @ELSE
451
+ elif stripped.startswith("# @ELSE"):
452
+ if not stack:
453
+ final_lines.append(line)
454
+ i += 1
455
+ continue
456
+
457
+ current_stack = stack[-1]
458
+ if current_stack[1]:
459
+ current_stack[0] = False
460
+ else:
461
+ current_stack[0] = True
462
+ current_stack[1] = True
463
+ i += 1
464
+ continue
465
+
466
+ # Handle @ENDIF
467
+ elif stripped.startswith("# @ENDIF"):
468
+ if stack:
469
+ stack.pop()
470
+ else:
471
+ final_lines.append(line)
472
+ i += 1
473
+ continue
474
+
475
+ # Handle @LOOP
476
+ elif stripped.startswith("# @LOOP "):
477
+ if stack and not all(s[0] for s in stack):
478
+ # Skip the entire loop block if inside a false conditional
479
+ loop_depth = 1
480
+ i += 1
481
+ while i < len(lines) and loop_depth > 0:
482
+ if lines[i].strip().startswith("# @LOOP "):
483
+ loop_depth += 1
484
+ elif lines[i].strip().startswith("# @ENDLOOP"):
485
+ loop_depth -= 1
486
+ i += 1
487
+ continue
488
+
489
+ parts = stripped[7:].strip().split(" in ", 1)
490
+ if len(parts) == 2 and parts[0].strip() and parts[1].strip():
491
+ var_name = parts[0].strip()
492
+ iterable_name = parts[1].strip()
493
+ try:
494
+ iterable = self._evaluate(iterable_name)
495
+ if not hasattr(iterable, "__iter__"):
496
+ raise TypeError(f"{iterable_name!r} is not iterable")
497
+ except (AttributeError, IndexError, KeyError, TypeError, ValueError):
498
+ iterable = []
499
+
500
+ # Capture loop body
501
+ loop_body = []
502
+ i += 1
503
+ loop_depth = 1
504
+ while i < len(lines) and loop_depth > 0:
505
+ if lines[i].strip().startswith("# @LOOP "):
506
+ loop_depth += 1
507
+ elif lines[i].strip().startswith("# @ENDLOOP"):
508
+ loop_depth -= 1
509
+
510
+ if loop_depth > 0:
511
+ loop_body.append(lines[i])
512
+ i += 1
513
+
514
+ if i < len(lines): # Skip the @ENDLOOP line itself
515
+ i += 1
516
+
517
+ # Execute loop
518
+ if hasattr(iterable, "__iter__"):
519
+ old_val = self.context.get(var_name)
520
+ for val in iterable:
521
+ self.context[var_name] = val
522
+ rendered_body = self.render("\n".join(loop_body), self.context)
523
+ final_lines.extend(rendered_body.splitlines())
524
+ if old_val is not None:
525
+ self.context[var_name] = old_val
526
+ else:
527
+ self.context.pop(var_name, None)
528
+ continue
529
+ else:
530
+ final_lines.append(line)
531
+ i += 1
532
+ continue
533
+
534
+ # Handle @ENDLOOP (should only be hit if out of sync or error)
535
+ elif stripped.startswith("# @ENDLOOP"):
536
+ i += 1
537
+ continue
538
+
539
+ # Check if we should skip this line based on conditional stack
540
+ if stack and not all(s[0] for s in stack):
541
+ i += 1
542
+ continue
543
+
544
+ # Variable substitution
545
+ rendered_line = line
546
+ matches = re.findall(r"\[\[\s*(.*?)\s*\]\]", rendered_line)
547
+ for match in matches:
548
+ value = self._get_value(match)
549
+ # Replace all variations of the variable syntax
550
+ rendered_line = re.sub(r"\[\[\s*" + re.escape(match) + r"\s*\]\]", value, rendered_line)
551
+
552
+ # In-line @IF support
553
+ # Case 1: content # @IF cond
554
+ # Case 2: # @IF cond content
555
+ if "# @IF " in rendered_line:
556
+ parts = rendered_line.split("# @IF ", 1)
557
+ pre_content = parts[0].rstrip()
558
+ rest = parts[1].strip()
559
+
560
+ # Split rest into expression and post-content (if any)
561
+ # This is tricky because the expression might have spaces.
562
+ # If there's an # @ENDIF on the same line, use it.
563
+ post_content = ""
564
+ expr = rest
565
+ if " # @ENDIF" in rest:
566
+ expr_parts = rest.split(" # @ENDIF", 1)
567
+ expr = expr_parts[0].strip()
568
+ post_content = expr_parts[1].lstrip()
569
+ elif "# @ENDIF" in rest:
570
+ expr_parts = rest.split("# @ENDIF", 1)
571
+ expr = expr_parts[0].strip()
572
+ post_content = expr_parts[1].lstrip()
573
+
574
+ try:
575
+ if bool(self._evaluate(expr)):
576
+ # If Case 1, we want pre_content. If Case 2, we want post_content.
577
+ # Usually, if pre_content is empty (or just whitespace), it's Case 2.
578
+ if pre_content:
579
+ final_lines.append(pre_content + post_content)
580
+ else:
581
+ final_lines.append(post_content)
582
+ except (AttributeError, IndexError, KeyError, TypeError, ValueError):
583
+ pass
584
+ i += 1
585
+ continue
586
+
587
+ final_lines.append(rendered_line)
588
+ i += 1
589
+
590
+ return "\n".join(final_lines)