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.
- {djinitx-0.3.7 → djinitx-0.3.9}/PKG-INFO +3 -3
- {djinitx-0.3.7 → djinitx-0.3.9}/pyproject.toml +3 -3
- djinitx-0.3.9/requirements.txt +3 -0
- djinitx-0.3.9/src/djinit/core/parser.py +590 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/creators/files.py +56 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/creators/setup.py +4 -0
- djinitx-0.3.9/src/djinit/templates/components/base.py-tpl +11 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/config/settings/base.py-tpl +3 -6
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/config/settings/development.py-tpl +1 -1
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/config/urls.py-tpl +0 -18
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/project/pyproject.toml-tpl +4 -5
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/project/readme.md-tpl +10 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/project/requirements-tpl +0 -1
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/ui/input.py +3 -0
- djinitx-0.3.9/uv.lock +364 -0
- djinitx-0.3.7/requirements.txt +0 -5
- djinitx-0.3.7/setup.py +0 -63
- djinitx-0.3.7/src/djinit/core/parser.py +0 -215
- djinitx-0.3.7/uv.lock +0 -337
- {djinitx-0.3.7 → djinitx-0.3.9}/.github/dependabot.yml +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/.github/workflows/ci.yml +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/.github/workflows/codeql.yml +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/.github/workflows/dependabot-auto-merge.yml +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/.github/workflows/pre-commit.yml +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/.github/workflows/publish.yml +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/.gitignore +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/.pre-commit-config.yaml +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/.python-version +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/LICENSE +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/MANIFEST.in +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/README.md +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/TODO.md +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/ruff.toml +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/__init__.py +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/cli.py +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/core/__init__.py +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/core/base.py +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/core/config.py +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/core/types.py +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/creators/__init__.py +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/creators/app.py +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/creators/project.py +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templater/__init__.py +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templater/base.py +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/components/admin.py-tpl +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/components/api_readme.md-tpl +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/components/apps.py-tpl +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/components/models.py-tpl +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/components/models_readme.md-tpl +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/components/serializers.py-tpl +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/components/tests.py-tpl +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/components/urls.py-tpl +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/components/views.py-tpl +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/config/asgi.py-tpl +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/config/settings/production.py-tpl +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/config/wsgi.py-tpl +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/project/Dockerfile-tpl +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/project/ci/github_actions-tpl +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/project/ci/gitlab_ci-tpl +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/project/dockerignore-tpl +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/project/env_sample-tpl +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/project/gitignore-tpl +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/project/init.py-tpl +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/project/justfile-tpl +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/project/manage.py-tpl +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/project/procfile-tpl +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/project/runtime_txt-tpl +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/templates/project/vite.config.ts-tpl +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/ui/__init__.py +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/ui/console.py +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/utils/__init__.py +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/utils/common.py +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/utils/django.py +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/utils/exceptions.py +0 -0
- {djinitx-0.3.7 → djinitx-0.3.9}/src/djinit/utils/secretkey.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
10
|
+
version = "0.3.9"
|
|
11
11
|
description = "A CLI tool to setup Django project"
|
|
12
12
|
readme = "README.md"
|
|
13
|
-
requires-python = ">=3.
|
|
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,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)
|