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
|
||||
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:
|
||||
@@ -11,18 +381,21 @@ class InFileLogicParser:
|
||||
def __init__(self, context: Dict[str, Any] = None):
|
||||
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:
|
||||
"""
|
||||
Supports nested/dotted access like [[ user.name ]] or [[ settings["DEBUG"] ]].
|
||||
For simplicity, it uses eval in the provided context for complex expressions,
|
||||
but falls back to safe dictionary lookup for simple keys.
|
||||
Dotted names are resolved against the context, and arbitrary expressions are
|
||||
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()
|
||||
try:
|
||||
# Try evaluating the expression in the context
|
||||
return str(eval(key, {"__builtins__": {}}, self.context))
|
||||
except (NameError, SyntaxError, KeyError, TypeError, AttributeError):
|
||||
# Fallback for common patterns or just return the key as is if it fails
|
||||
return str(self._evaluate(key))
|
||||
except (AttributeError, IndexError, KeyError, TypeError, ValueError):
|
||||
return f"[[ {key} ]]"
|
||||
|
||||
def render(self, template_text: str, context: Dict[str, Any] = None) -> str:
|
||||
@@ -44,8 +417,8 @@ class InFileLogicParser:
|
||||
if stripped.startswith("# @IF "):
|
||||
expr = stripped[6:].strip()
|
||||
try:
|
||||
result = bool(eval(expr, {"__builtins__": {}}, self.context))
|
||||
except Exception:
|
||||
result = bool(self._evaluate(expr))
|
||||
except (AttributeError, IndexError, KeyError, TypeError, ValueError):
|
||||
result = False
|
||||
stack.append([result, result])
|
||||
i += 1
|
||||
@@ -65,8 +438,8 @@ class InFileLogicParser:
|
||||
current_stack[0] = False
|
||||
else:
|
||||
try:
|
||||
result = bool(eval(expr, {"__builtins__": {}}, self.context))
|
||||
except Exception:
|
||||
result = bool(self._evaluate(expr))
|
||||
except (AttributeError, IndexError, KeyError, TypeError, ValueError):
|
||||
result = False
|
||||
current_stack[0] = result
|
||||
if result:
|
||||
@@ -113,13 +486,15 @@ class InFileLogicParser:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
parts = stripped[7:].strip().split(" in ")
|
||||
if len(parts) == 2:
|
||||
parts = stripped[7:].strip().split(" in ", 1)
|
||||
if len(parts) == 2 and parts[0].strip() and parts[1].strip():
|
||||
var_name = parts[0].strip()
|
||||
iterable_name = parts[1].strip()
|
||||
try:
|
||||
iterable = eval(iterable_name, {"__builtins__": {}}, self.context)
|
||||
except Exception:
|
||||
iterable = self._evaluate(iterable_name)
|
||||
if not hasattr(iterable, "__iter__"):
|
||||
raise TypeError(f"{iterable_name!r} is not iterable")
|
||||
except (AttributeError, IndexError, KeyError, TypeError, ValueError):
|
||||
iterable = []
|
||||
|
||||
# Capture loop body
|
||||
@@ -197,14 +572,14 @@ class InFileLogicParser:
|
||||
post_content = expr_parts[1].lstrip()
|
||||
|
||||
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.
|
||||
# Usually, if pre_content is empty (or just whitespace), it's Case 2.
|
||||
if pre_content:
|
||||
final_lines.append(pre_content + post_content)
|
||||
else:
|
||||
final_lines.append(post_content)
|
||||
except Exception:
|
||||
except (AttributeError, IndexError, KeyError, TypeError, ValueError):
|
||||
pass
|
||||
i += 1
|
||||
continue
|
||||
|
||||
@@ -8,6 +8,7 @@ import os
|
||||
from djinit.core.base import BaseService
|
||||
from djinit.templater import template_engine
|
||||
from djinit.utils.common import CommonUtils
|
||||
from djinit.utils.django import DjangoHelper
|
||||
|
||||
|
||||
class FileCreator(BaseService):
|
||||
@@ -195,12 +196,26 @@ class FileCreator(BaseService):
|
||||
"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:
|
||||
context = {
|
||||
"project_name": self.project_name,
|
||||
"app_names": self.app_names,
|
||||
"app_list": self._registered_app_modules(),
|
||||
"predefined_structure": bool(self.metadata.get("predefined_structure")),
|
||||
"unified_structure": bool(self.metadata.get("unified_structure")),
|
||||
"single_structure": bool(self.metadata.get("single_structure")),
|
||||
"module_name": self.module_name,
|
||||
}
|
||||
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",
|
||||
)
|
||||
|
||||
# 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
|
||||
api_dir = os.path.join(self.project_root, "api")
|
||||
CommonUtils.create_directory_with_init(api_dir, "Created api/__init__.py")
|
||||
|
||||
@@ -35,6 +35,7 @@ class SetupCreator:
|
||||
if not self.app_names:
|
||||
self.app_names = ["users", "core"]
|
||||
self.project_creator.app_names = self.app_names
|
||||
self.file_creator.app_names = self.app_names
|
||||
|
||||
elif self.metadata.get("unified_structure"):
|
||||
self.metadata.setdefault("project_module_name", "core")
|
||||
@@ -43,12 +44,14 @@ class SetupCreator:
|
||||
if not self.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"):
|
||||
self.metadata.setdefault("nested_apps", False)
|
||||
if not self.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:
|
||||
"""Run the full setup process."""
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[project]
|
||||
name = "[[ project_name ]]"
|
||||
name = "[[ package_name ]]"
|
||||
version = "0.1.0"
|
||||
description = ""
|
||||
readme = "README.md"
|
||||
@@ -61,7 +61,7 @@ ignore = [
|
||||
]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
known-first-party = ["[[ project_name ]]"]
|
||||
known-first-party = ["[[ module_name ]]"]
|
||||
|
||||
[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
|
||||
- Production-ready configuration
|
||||
|
||||
# @IF app_list
|
||||
## Apps
|
||||
|
||||
Registered in `[[ module_name ]]/settings/base.py`:
|
||||
|
||||
# @LOOP app in app_list
|
||||
- `[[ app ]]`
|
||||
# @ENDLOOP
|
||||
|
||||
# @ENDIF
|
||||
## Setup
|
||||
|
||||
### Using Just (Recommended)
|
||||
|
||||
Reference in new issue
Block a user