fix: replace eval() with an AST parser for template expressions
Empty __builtins__ is not a sandbox, so a template expression could reach os.system. Walk the AST with an allow-list instead. Also fixes app names missing from api/v1/urls.py and a wrong settings path in the generated pyproject.toml.
This commit is contained in:
5 files changed
+428
-20
No files matched your search
+392
-17
@@ -1,5 +1,375 @@
|
|||||||
|
"""
|
||||||
|
Safe template expression evaluation for djinit.
|
||||||
|
|
||||||
|
Templates use a tiny, comment-style logic language:
|
||||||
|
|
||||||
|
# @IF <expression> ... # @ELSEIF <expression> ... # @ELSE ... # @ENDIF
|
||||||
|
# @LOOP <var> in <expression> ... # @ENDLOOP
|
||||||
|
[[ variable ]] variable / attribute / index substitution
|
||||||
|
|
||||||
|
Expressions are evaluated by walking the parsed syntax tree with an explicit
|
||||||
|
allow-list. ``eval()`` is never used: restricting ``__builtins__`` is *not* a
|
||||||
|
sandbox, because object graphs reachable from ordinary literals (for example
|
||||||
|
``().__class__.__base__.__subclasses__()``) still hand out ``os.system`` and
|
||||||
|
friends. An explicit interpreter has no such escape hatch.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import operator
|
||||||
import re
|
import re
|
||||||
from typing import Any, Dict, List
|
from collections.abc import Mapping
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
__all__ = ["InFileLogicParser", "SafeExpressionError"]
|
||||||
|
|
||||||
|
|
||||||
|
class SafeExpressionError(ValueError):
|
||||||
|
"""Raised when a template expression is malformed or uses a disallowed feature."""
|
||||||
|
|
||||||
|
|
||||||
|
# The only names an expression may reference besides the render context. These are
|
||||||
|
# side-effect free helpers; nothing that can import, open, execute or mutate.
|
||||||
|
_SAFE_BUILTINS: Mapping[str, Any] = {
|
||||||
|
"True": True,
|
||||||
|
"False": False,
|
||||||
|
"None": None,
|
||||||
|
"abs": abs,
|
||||||
|
"all": all,
|
||||||
|
"any": any,
|
||||||
|
"bool": bool,
|
||||||
|
"dict": dict,
|
||||||
|
"enumerate": enumerate,
|
||||||
|
"float": float,
|
||||||
|
"int": int,
|
||||||
|
"len": len,
|
||||||
|
"list": list,
|
||||||
|
"max": max,
|
||||||
|
"min": min,
|
||||||
|
"reversed": reversed,
|
||||||
|
"sorted": sorted,
|
||||||
|
"str": str,
|
||||||
|
"zip": zip,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Attribute names that must never be reachable, even on an otherwise harmless
|
||||||
|
# object. Together with rejecting every ``_``-prefixed name this closes the usual
|
||||||
|
# attribute-hop escape routes (``__class__``, ``__globals__``, ``__subclasses__``…).
|
||||||
|
# ``format``/``format_map`` are included because the *format string* they receive is
|
||||||
|
# resolved with ``getattr`` inside CPython, which would sidestep the checks below.
|
||||||
|
_FORBIDDEN_ATTRIBUTES = frozenset(
|
||||||
|
{
|
||||||
|
"__import__",
|
||||||
|
"breakpoint",
|
||||||
|
"compile",
|
||||||
|
"delattr",
|
||||||
|
"eval",
|
||||||
|
"exec",
|
||||||
|
"exit",
|
||||||
|
"format",
|
||||||
|
"format_map",
|
||||||
|
"fork",
|
||||||
|
"getattr",
|
||||||
|
"globals",
|
||||||
|
"input",
|
||||||
|
"locals",
|
||||||
|
"open",
|
||||||
|
"popen",
|
||||||
|
"quit",
|
||||||
|
"setattr",
|
||||||
|
"spawn",
|
||||||
|
"system",
|
||||||
|
"vars",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calling a method is only allowed on plain data values. Without this, a context
|
||||||
|
# value that happens to be a module, class or function would expose its whole
|
||||||
|
# (dangerous) surface through dotted access.
|
||||||
|
_PLAIN_DATA_TYPES = (str, bytes, bytearray, bool, int, float, complex, list, tuple, dict, set, frozenset, Mapping)
|
||||||
|
|
||||||
|
# Guards against cheap resource exhaustion such as ``2 ** 99999999``.
|
||||||
|
_MAX_EXPONENT = 1000
|
||||||
|
|
||||||
|
|
||||||
|
class _SafeExpressionEvaluator(ast.NodeVisitor):
|
||||||
|
"""Interprets a validated expression tree against the render context."""
|
||||||
|
|
||||||
|
_BINARY_OPERATORS = {
|
||||||
|
ast.Add: operator.add,
|
||||||
|
ast.Sub: operator.sub,
|
||||||
|
ast.Mult: operator.mul,
|
||||||
|
ast.Div: operator.truediv,
|
||||||
|
ast.FloorDiv: operator.floordiv,
|
||||||
|
ast.Mod: operator.mod,
|
||||||
|
ast.Pow: operator.pow,
|
||||||
|
}
|
||||||
|
|
||||||
|
_UNARY_OPERATORS = {
|
||||||
|
ast.UAdd: operator.pos,
|
||||||
|
ast.USub: operator.neg,
|
||||||
|
ast.Not: operator.not_,
|
||||||
|
ast.Invert: operator.invert,
|
||||||
|
}
|
||||||
|
|
||||||
|
_COMPARISON_OPERATORS = {
|
||||||
|
ast.Eq: operator.eq,
|
||||||
|
ast.NotEq: operator.ne,
|
||||||
|
ast.Lt: operator.lt,
|
||||||
|
ast.LtE: operator.le,
|
||||||
|
ast.Gt: operator.gt,
|
||||||
|
ast.GtE: operator.ge,
|
||||||
|
ast.Is: operator.is_,
|
||||||
|
ast.IsNot: operator.is_not,
|
||||||
|
ast.In: lambda a, b: operator.contains(b, a),
|
||||||
|
ast.NotIn: lambda a, b: not operator.contains(b, a),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, context: Mapping[str, Any]) -> None:
|
||||||
|
# Outermost scope is the render context; comprehension bodies push their own
|
||||||
|
# dicts so loop variables never leak into the surrounding scope.
|
||||||
|
self._scopes: List[Any] = [context]
|
||||||
|
|
||||||
|
# -- entry point ---------------------------------------------------------
|
||||||
|
|
||||||
|
def evaluate(self, tree: ast.Expression) -> Any:
|
||||||
|
return self.visit(tree.body)
|
||||||
|
|
||||||
|
def generic_visit(self, node: ast.AST) -> Any:
|
||||||
|
raise SafeExpressionError(f"{type(node).__name__} is not allowed in template expressions")
|
||||||
|
|
||||||
|
# -- literals ------------------------------------------------------------
|
||||||
|
|
||||||
|
def visit_Constant(self, node: ast.Constant) -> Any:
|
||||||
|
return node.value
|
||||||
|
|
||||||
|
def visit_List(self, node: ast.List) -> list:
|
||||||
|
return [self.visit(element) for element in node.elts]
|
||||||
|
|
||||||
|
def visit_Tuple(self, node: ast.Tuple) -> tuple:
|
||||||
|
return tuple(self.visit(element) for element in node.elts)
|
||||||
|
|
||||||
|
def visit_Set(self, node: ast.Set) -> set:
|
||||||
|
return {self.visit(element) for element in node.elts}
|
||||||
|
|
||||||
|
def visit_Dict(self, node: ast.Dict) -> dict:
|
||||||
|
result: dict = {}
|
||||||
|
for key_node, value_node in zip(node.keys, node.values):
|
||||||
|
if key_node is None:
|
||||||
|
raise SafeExpressionError("Dictionary unpacking is not allowed")
|
||||||
|
result[self.visit(key_node)] = self.visit(value_node)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def visit_JoinedStr(self, node: ast.JoinedStr) -> str:
|
||||||
|
return "".join(str(self.visit(value)) for value in node.values)
|
||||||
|
|
||||||
|
def visit_FormattedValue(self, node: ast.FormattedValue) -> str:
|
||||||
|
value = self.visit(node.value)
|
||||||
|
if node.conversion == 115: # !s
|
||||||
|
return f"{value:s}"
|
||||||
|
if node.conversion == 114: # !r
|
||||||
|
return repr(value)
|
||||||
|
if node.conversion == 97: # !a
|
||||||
|
return ascii(value)
|
||||||
|
if node.format_spec is not None:
|
||||||
|
return format(value, self.visit(node.format_spec))
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
# -- names and attributes ------------------------------------------------
|
||||||
|
|
||||||
|
def visit_Name(self, node: ast.Name) -> Any:
|
||||||
|
return self._lookup(node.id)
|
||||||
|
|
||||||
|
def _lookup(self, name: str) -> Any:
|
||||||
|
for scope in reversed(self._scopes):
|
||||||
|
if name in scope:
|
||||||
|
return scope[name]
|
||||||
|
if name in _SAFE_BUILTINS:
|
||||||
|
return _SAFE_BUILTINS[name]
|
||||||
|
raise SafeExpressionError(f"Unknown name: {name!r}")
|
||||||
|
|
||||||
|
def visit_Attribute(self, node: ast.Attribute) -> Any:
|
||||||
|
return self._resolve_attribute(self.visit(node.value), node.attr)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_attribute(value: Any, name: str) -> Any:
|
||||||
|
if name.startswith("_") or name in _FORBIDDEN_ATTRIBUTES:
|
||||||
|
raise SafeExpressionError(f"Access to attribute {name!r} is not allowed")
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
# Templates routinely index plain dicts with dotted syntax.
|
||||||
|
try:
|
||||||
|
return value[name]
|
||||||
|
except KeyError:
|
||||||
|
raise AttributeError(name) from None
|
||||||
|
resolved = getattr(value, name)
|
||||||
|
if callable(resolved) and not isinstance(value, _PLAIN_DATA_TYPES):
|
||||||
|
raise SafeExpressionError(f"Access to method {name!r} is not allowed here")
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
# -- operators -----------------------------------------------------------
|
||||||
|
|
||||||
|
def visit_BoolOp(self, node: ast.BoolOp) -> Any:
|
||||||
|
if isinstance(node.op, ast.And):
|
||||||
|
result: Any = True
|
||||||
|
for value in node.values:
|
||||||
|
result = self.visit(value)
|
||||||
|
if not result:
|
||||||
|
return result
|
||||||
|
return result
|
||||||
|
result = False
|
||||||
|
for value in node.values:
|
||||||
|
result = self.visit(value)
|
||||||
|
if result:
|
||||||
|
return result
|
||||||
|
return result
|
||||||
|
|
||||||
|
def visit_UnaryOp(self, node: ast.UnaryOp) -> Any:
|
||||||
|
handler = self._UNARY_OPERATORS.get(type(node.op))
|
||||||
|
if handler is None:
|
||||||
|
raise SafeExpressionError(f"Unary operator {type(node.op).__name__} is not allowed")
|
||||||
|
return handler(self.visit(node.operand))
|
||||||
|
|
||||||
|
def visit_BinOp(self, node: ast.BinOp) -> Any:
|
||||||
|
handler = self._BINARY_OPERATORS.get(type(node.op))
|
||||||
|
if handler is None:
|
||||||
|
raise SafeExpressionError(f"Binary operator {type(node.op).__name__} is not allowed")
|
||||||
|
left = self.visit(node.left)
|
||||||
|
right = self.visit(node.right)
|
||||||
|
if isinstance(node.op, ast.Pow) and isinstance(left, int) and isinstance(right, int):
|
||||||
|
if abs(right) > _MAX_EXPONENT:
|
||||||
|
raise SafeExpressionError("Exponent is too large")
|
||||||
|
return handler(left, right)
|
||||||
|
|
||||||
|
def visit_Compare(self, node: ast.Compare) -> bool:
|
||||||
|
left = self.visit(node.left)
|
||||||
|
for operator_node, comparator_node in zip(node.ops, node.comparators):
|
||||||
|
handler = self._COMPARISON_OPERATORS.get(type(operator_node))
|
||||||
|
if handler is None:
|
||||||
|
raise SafeExpressionError(f"Comparison {type(operator_node).__name__} is not allowed")
|
||||||
|
right = self.visit(comparator_node)
|
||||||
|
if not handler(left, right):
|
||||||
|
return False
|
||||||
|
left = right
|
||||||
|
return True
|
||||||
|
|
||||||
|
def visit_IfExp(self, node: ast.IfExp) -> Any:
|
||||||
|
return self.visit(node.body) if self.visit(node.test) else self.visit(node.orelse)
|
||||||
|
|
||||||
|
# -- subscripts ----------------------------------------------------------
|
||||||
|
|
||||||
|
def visit_Subscript(self, node: ast.Subscript) -> Any:
|
||||||
|
value = self.visit(node.value)
|
||||||
|
key = self._resolve_subscript_key(node.slice, value)
|
||||||
|
try:
|
||||||
|
return value[key]
|
||||||
|
except TypeError as exc:
|
||||||
|
raise SafeExpressionError(f"Cannot subscript {type(value).__name__}") from exc
|
||||||
|
|
||||||
|
def _resolve_subscript_key(self, node: ast.AST, value: Any) -> Any:
|
||||||
|
if isinstance(node, ast.Slice):
|
||||||
|
return slice(
|
||||||
|
self._resolve_subscript_bound(node.lower),
|
||||||
|
self._resolve_subscript_bound(node.upper),
|
||||||
|
self._resolve_subscript_bound(node.step),
|
||||||
|
)
|
||||||
|
if isinstance(node, ast.Tuple):
|
||||||
|
return tuple(self.visit(element) for element in node.elts)
|
||||||
|
return self.visit(node)
|
||||||
|
|
||||||
|
def _resolve_subscript_bound(self, node: Optional[ast.AST]) -> Optional[Any]:
|
||||||
|
return None if node is None else self.visit(node)
|
||||||
|
|
||||||
|
# -- calls ---------------------------------------------------------------
|
||||||
|
|
||||||
|
def visit_Call(self, node: ast.Call) -> Any:
|
||||||
|
if any(keyword.arg is None for keyword in node.keywords):
|
||||||
|
raise SafeExpressionError("** unpacking is not allowed in calls")
|
||||||
|
args = [self.visit(argument) for argument in node.args]
|
||||||
|
kwargs = {keyword.arg: self.visit(keyword.value) for keyword in node.keywords}
|
||||||
|
callee = self.visit(node.func)
|
||||||
|
if not callable(callee):
|
||||||
|
raise SafeExpressionError(f"{callee!r} is not callable")
|
||||||
|
return callee(*args, **kwargs)
|
||||||
|
|
||||||
|
# -- comprehensions ------------------------------------------------------
|
||||||
|
|
||||||
|
def visit_ListComp(self, node: ast.ListComp) -> list:
|
||||||
|
result: list = []
|
||||||
|
self._run_comprehension(node.generators, 0, lambda: result.append(self.visit(node.elt)))
|
||||||
|
return result
|
||||||
|
|
||||||
|
def visit_SetComp(self, node: ast.SetComp) -> set:
|
||||||
|
result: set = set()
|
||||||
|
self._run_comprehension(node.generators, 0, lambda: result.add(self.visit(node.elt)))
|
||||||
|
return result
|
||||||
|
|
||||||
|
def visit_GeneratorExp(self, node: ast.GeneratorExp) -> tuple:
|
||||||
|
result: list = []
|
||||||
|
self._run_comprehension(node.generators, 0, lambda: result.append(self.visit(node.elt)))
|
||||||
|
return tuple(result)
|
||||||
|
|
||||||
|
def visit_DictComp(self, node: ast.DictComp) -> dict:
|
||||||
|
result: dict = {}
|
||||||
|
self._run_comprehension(
|
||||||
|
node.generators,
|
||||||
|
0,
|
||||||
|
lambda: result.__setitem__(self.visit(node.key), self.visit(node.value)),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _run_comprehension(self, generators: List[ast.comprehension], index: int, emit) -> None:
|
||||||
|
if index >= len(generators):
|
||||||
|
emit()
|
||||||
|
return
|
||||||
|
|
||||||
|
generator = generators[index]
|
||||||
|
for value in self._as_iterable(self.visit(generator.iter)):
|
||||||
|
self._scopes.append({})
|
||||||
|
try:
|
||||||
|
self._assign(generator.target, value)
|
||||||
|
if all(self.visit(condition) for condition in generator.ifs):
|
||||||
|
self._run_comprehension(generators, index + 1, emit)
|
||||||
|
finally:
|
||||||
|
self._scopes.pop()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _as_iterable(value: Any) -> Any:
|
||||||
|
if isinstance(value, (str, bytes)) or not hasattr(value, "__iter__"):
|
||||||
|
raise SafeExpressionError(f"{type(value).__name__} is not iterable")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def _assign(self, target: ast.AST, value: Any) -> None:
|
||||||
|
if isinstance(target, ast.Name):
|
||||||
|
self._scopes[-1][target.id] = value
|
||||||
|
return
|
||||||
|
if isinstance(target, (ast.Tuple, ast.List)) and isinstance(value, (tuple, list)):
|
||||||
|
if len(target.elts) != len(value):
|
||||||
|
raise SafeExpressionError("Cannot unpack mismatched number of loop variables")
|
||||||
|
for element, item in zip(target.elts, value):
|
||||||
|
self._assign(element, item)
|
||||||
|
return
|
||||||
|
raise SafeExpressionError("Unsupported loop target")
|
||||||
|
|
||||||
|
|
||||||
|
def safe_eval(expression: str, context: Optional[Mapping[str, Any]] = None) -> Any:
|
||||||
|
"""Evaluate a template expression without ``eval()``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
expression: The expression source, e.g. ``"use_tailwind and len(app_names) > 0"``.
|
||||||
|
context: Names available to the expression.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SafeExpressionError: If the expression is empty, malformed or disallowed.
|
||||||
|
"""
|
||||||
|
source = expression.strip()
|
||||||
|
if not source:
|
||||||
|
raise SafeExpressionError("Empty expression")
|
||||||
|
|
||||||
|
try:
|
||||||
|
tree = ast.parse(source, mode="eval")
|
||||||
|
except SyntaxError as exc:
|
||||||
|
raise SafeExpressionError(f"Invalid expression syntax: {source!r}") from exc
|
||||||
|
|
||||||
|
return _SafeExpressionEvaluator(context or {}).evaluate(tree)
|
||||||
|
|
||||||
|
|
||||||
class InFileLogicParser:
|
class InFileLogicParser:
|
||||||
@@ -11,18 +381,21 @@ class InFileLogicParser:
|
|||||||
def __init__(self, context: Dict[str, Any] = None):
|
def __init__(self, context: Dict[str, Any] = None):
|
||||||
self.context = context or {}
|
self.context = context or {}
|
||||||
|
|
||||||
|
def _evaluate(self, expression: str) -> Any:
|
||||||
|
"""Evaluate ``expression`` against the current context, safely."""
|
||||||
|
return safe_eval(expression, self.context)
|
||||||
|
|
||||||
def _get_value(self, key: str) -> str:
|
def _get_value(self, key: str) -> str:
|
||||||
"""
|
"""
|
||||||
Supports nested/dotted access like [[ user.name ]] or [[ settings["DEBUG"] ]].
|
Supports nested/dotted access like [[ user.name ]] or [[ settings["DEBUG"] ]].
|
||||||
For simplicity, it uses eval in the provided context for complex expressions,
|
Dotted names are resolved against the context, and arbitrary expressions are
|
||||||
but falls back to safe dictionary lookup for simple keys.
|
evaluated by the safe interpreter. Anything that cannot be resolved is
|
||||||
|
returned verbatim so a missing context key stays visible in the output.
|
||||||
"""
|
"""
|
||||||
key = key.strip()
|
key = key.strip()
|
||||||
try:
|
try:
|
||||||
# Try evaluating the expression in the context
|
return str(self._evaluate(key))
|
||||||
return str(eval(key, {"__builtins__": {}}, self.context))
|
except (AttributeError, IndexError, KeyError, TypeError, ValueError):
|
||||||
except (NameError, SyntaxError, KeyError, TypeError, AttributeError):
|
|
||||||
# Fallback for common patterns or just return the key as is if it fails
|
|
||||||
return f"[[ {key} ]]"
|
return f"[[ {key} ]]"
|
||||||
|
|
||||||
def render(self, template_text: str, context: Dict[str, Any] = None) -> str:
|
def render(self, template_text: str, context: Dict[str, Any] = None) -> str:
|
||||||
@@ -44,8 +417,8 @@ class InFileLogicParser:
|
|||||||
if stripped.startswith("# @IF "):
|
if stripped.startswith("# @IF "):
|
||||||
expr = stripped[6:].strip()
|
expr = stripped[6:].strip()
|
||||||
try:
|
try:
|
||||||
result = bool(eval(expr, {"__builtins__": {}}, self.context))
|
result = bool(self._evaluate(expr))
|
||||||
except Exception:
|
except (AttributeError, IndexError, KeyError, TypeError, ValueError):
|
||||||
result = False
|
result = False
|
||||||
stack.append([result, result])
|
stack.append([result, result])
|
||||||
i += 1
|
i += 1
|
||||||
@@ -65,8 +438,8 @@ class InFileLogicParser:
|
|||||||
current_stack[0] = False
|
current_stack[0] = False
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
result = bool(eval(expr, {"__builtins__": {}}, self.context))
|
result = bool(self._evaluate(expr))
|
||||||
except Exception:
|
except (AttributeError, IndexError, KeyError, TypeError, ValueError):
|
||||||
result = False
|
result = False
|
||||||
current_stack[0] = result
|
current_stack[0] = result
|
||||||
if result:
|
if result:
|
||||||
@@ -113,13 +486,15 @@ class InFileLogicParser:
|
|||||||
i += 1
|
i += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
parts = stripped[7:].strip().split(" in ")
|
parts = stripped[7:].strip().split(" in ", 1)
|
||||||
if len(parts) == 2:
|
if len(parts) == 2 and parts[0].strip() and parts[1].strip():
|
||||||
var_name = parts[0].strip()
|
var_name = parts[0].strip()
|
||||||
iterable_name = parts[1].strip()
|
iterable_name = parts[1].strip()
|
||||||
try:
|
try:
|
||||||
iterable = eval(iterable_name, {"__builtins__": {}}, self.context)
|
iterable = self._evaluate(iterable_name)
|
||||||
except Exception:
|
if not hasattr(iterable, "__iter__"):
|
||||||
|
raise TypeError(f"{iterable_name!r} is not iterable")
|
||||||
|
except (AttributeError, IndexError, KeyError, TypeError, ValueError):
|
||||||
iterable = []
|
iterable = []
|
||||||
|
|
||||||
# Capture loop body
|
# Capture loop body
|
||||||
@@ -197,14 +572,14 @@ class InFileLogicParser:
|
|||||||
post_content = expr_parts[1].lstrip()
|
post_content = expr_parts[1].lstrip()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if bool(eval(expr, {"__builtins__": {}}, self.context)):
|
if bool(self._evaluate(expr)):
|
||||||
# If Case 1, we want pre_content. If Case 2, we want post_content.
|
# If Case 1, we want pre_content. If Case 2, we want post_content.
|
||||||
# Usually, if pre_content is empty (or just whitespace), it's Case 2.
|
# Usually, if pre_content is empty (or just whitespace), it's Case 2.
|
||||||
if pre_content:
|
if pre_content:
|
||||||
final_lines.append(pre_content + post_content)
|
final_lines.append(pre_content + post_content)
|
||||||
else:
|
else:
|
||||||
final_lines.append(post_content)
|
final_lines.append(post_content)
|
||||||
except Exception:
|
except (AttributeError, IndexError, KeyError, TypeError, ValueError):
|
||||||
pass
|
pass
|
||||||
i += 1
|
i += 1
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import os
|
|||||||
from djinit.core.base import BaseService
|
from djinit.core.base import BaseService
|
||||||
from djinit.templater import template_engine
|
from djinit.templater import template_engine
|
||||||
from djinit.utils.common import CommonUtils
|
from djinit.utils.common import CommonUtils
|
||||||
|
from djinit.utils.django import DjangoHelper
|
||||||
|
|
||||||
|
|
||||||
class FileCreator(BaseService):
|
class FileCreator(BaseService):
|
||||||
@@ -195,12 +196,26 @@ class FileCreator(BaseService):
|
|||||||
"Created requirements.txt with Django dependencies",
|
"Created requirements.txt with Django dependencies",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _registered_app_modules(self) -> list:
|
||||||
|
"""Dotted module paths of the apps that end up in INSTALLED_APPS.
|
||||||
|
|
||||||
|
The unified and single layouts use the generated package itself as their
|
||||||
|
only app, so they have no separate ``app_names`` to report.
|
||||||
|
"""
|
||||||
|
if self.metadata.get("unified_structure"):
|
||||||
|
return ["apps"]
|
||||||
|
if self.metadata.get("single_structure"):
|
||||||
|
return [self.module_name]
|
||||||
|
return CommonUtils.calculate_app_module_paths(self.app_names, self.metadata)
|
||||||
|
|
||||||
def create_readme(self) -> None:
|
def create_readme(self) -> None:
|
||||||
context = {
|
context = {
|
||||||
"project_name": self.project_name,
|
"project_name": self.project_name,
|
||||||
"app_names": self.app_names,
|
"app_names": self.app_names,
|
||||||
|
"app_list": self._registered_app_modules(),
|
||||||
"predefined_structure": bool(self.metadata.get("predefined_structure")),
|
"predefined_structure": bool(self.metadata.get("predefined_structure")),
|
||||||
"unified_structure": bool(self.metadata.get("unified_structure")),
|
"unified_structure": bool(self.metadata.get("unified_structure")),
|
||||||
|
"single_structure": bool(self.metadata.get("single_structure")),
|
||||||
"module_name": self.module_name,
|
"module_name": self.module_name,
|
||||||
}
|
}
|
||||||
self._render_and_create_file("README.md", "project/readme.md-tpl", context, "Created README.md file")
|
self._render_and_create_file("README.md", "project/readme.md-tpl", context, "Created README.md file")
|
||||||
@@ -293,6 +308,11 @@ class FileCreator(BaseService):
|
|||||||
"Created apps/core/base.py",
|
"Created apps/core/base.py",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Every app that ends up in USER_DEFINED_APPS needs a real app module,
|
||||||
|
# otherwise the generated project cannot start.
|
||||||
|
for app_name in self.app_names or ["users", "core"]:
|
||||||
|
DjangoHelper.startapp(app_name, apps_dir, f"apps.{app_name}")
|
||||||
|
|
||||||
# Create api directory structure
|
# Create api directory structure
|
||||||
api_dir = os.path.join(self.project_root, "api")
|
api_dir = os.path.join(self.project_root, "api")
|
||||||
CommonUtils.create_directory_with_init(api_dir, "Created api/__init__.py")
|
CommonUtils.create_directory_with_init(api_dir, "Created api/__init__.py")
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class SetupCreator:
|
|||||||
if not self.app_names:
|
if not self.app_names:
|
||||||
self.app_names = ["users", "core"]
|
self.app_names = ["users", "core"]
|
||||||
self.project_creator.app_names = self.app_names
|
self.project_creator.app_names = self.app_names
|
||||||
|
self.file_creator.app_names = self.app_names
|
||||||
|
|
||||||
elif self.metadata.get("unified_structure"):
|
elif self.metadata.get("unified_structure"):
|
||||||
self.metadata.setdefault("project_module_name", "core")
|
self.metadata.setdefault("project_module_name", "core")
|
||||||
@@ -43,12 +44,14 @@ class SetupCreator:
|
|||||||
if not self.app_names:
|
if not self.app_names:
|
||||||
self.app_names = []
|
self.app_names = []
|
||||||
self.project_creator.app_names = self.app_names
|
self.project_creator.app_names = self.app_names
|
||||||
|
self.file_creator.app_names = self.app_names
|
||||||
|
|
||||||
elif self.metadata.get("single_structure"):
|
elif self.metadata.get("single_structure"):
|
||||||
self.metadata.setdefault("nested_apps", False)
|
self.metadata.setdefault("nested_apps", False)
|
||||||
if not self.app_names:
|
if not self.app_names:
|
||||||
self.app_names = []
|
self.app_names = []
|
||||||
self.project_creator.app_names = self.app_names
|
self.project_creator.app_names = self.app_names
|
||||||
|
self.file_creator.app_names = self.app_names
|
||||||
|
|
||||||
def create(self) -> bool:
|
def create(self) -> bool:
|
||||||
"""Run the full setup process."""
|
"""Run the full setup process."""
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "[[ project_name ]]"
|
name = "[[ package_name ]]"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = ""
|
description = ""
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
@@ -61,7 +61,7 @@ ignore = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[tool.ruff.lint.isort]
|
[tool.ruff.lint.isort]
|
||||||
known-first-party = ["[[ project_name ]]"]
|
known-first-party = ["[[ module_name ]]"]
|
||||||
|
|
||||||
[tool.ruff.lint.per-file-ignores]
|
[tool.ruff.lint.per-file-ignores]
|
||||||
"[[ project_name ]]/settings/*.py" = ["F403", "F405"]
|
"[[ module_name ]]/settings/*.py" = ["F403", "F405"]
|
||||||
@@ -9,6 +9,16 @@ A Django project folder structure is generated with [djinitx/djinit/dj](https://
|
|||||||
- Essential dependencies and utilities
|
- Essential dependencies and utilities
|
||||||
- Production-ready configuration
|
- Production-ready configuration
|
||||||
|
|
||||||
|
# @IF app_list
|
||||||
|
## Apps
|
||||||
|
|
||||||
|
Registered in `[[ module_name ]]/settings/base.py`:
|
||||||
|
|
||||||
|
# @LOOP app in app_list
|
||||||
|
- `[[ app ]]`
|
||||||
|
# @ENDLOOP
|
||||||
|
|
||||||
|
# @ENDIF
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
### Using Just (Recommended)
|
### Using Just (Recommended)
|
||||||
|
|||||||
Reference in new issue
Block a user