Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a3950c2cf | ||
|
|
482ff13dd3 | ||
|
|
7c3ec11070 | ||
|
|
aad053c52c | ||
|
|
298945835b | ||
|
|
8619c220d3 | ||
|
|
018d71e999 | ||
|
|
75c049d7ed | ||
|
|
dd3ea6ca8e |
No files matched your search
+2
-1
@@ -7,7 +7,7 @@ packages = ["src/djinit"]
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "djinitx"
|
name = "djinitx"
|
||||||
version = "0.3.7"
|
version = "0.3.8"
|
||||||
description = "A CLI tool to setup Django project"
|
description = "A CLI tool to setup Django project"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
@@ -38,6 +38,7 @@ classifiers=[
|
|||||||
"Framework :: Django :: 4.2",
|
"Framework :: Django :: 4.2",
|
||||||
"Framework :: Django :: 5.1",
|
"Framework :: Django :: 5.1",
|
||||||
"Framework :: Django :: 5.2",
|
"Framework :: Django :: 5.2",
|
||||||
|
"Framework :: Django :: 6.0",
|
||||||
"Intended Audience :: Developers",
|
"Intended Audience :: Developers",
|
||||||
"License :: OSI Approved :: MIT License",
|
"License :: OSI Approved :: MIT License",
|
||||||
"Operating System :: OS Independent",
|
"Operating System :: OS Independent",
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
questionary==2.1.1
|
questionary==2.1.1
|
||||||
rich==15.0.0
|
rich==15.0.0
|
||||||
# dev
|
# dev
|
||||||
ruff==0.16.6
|
ruff==0.16.9
|
||||||
pre-commit==4.6.2
|
pre-commit==4.6.2
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
from setuptools import find_packages, setup
|
|
||||||
|
|
||||||
setup(
|
|
||||||
name="djinitx",
|
|
||||||
version="0.3.7",
|
|
||||||
description="A CLI tool to setup Django projects",
|
|
||||||
long_description=open("README.md").read(),
|
|
||||||
long_description_content_type="text/markdown",
|
|
||||||
author="Sankalp Tharu",
|
|
||||||
author_email="sankalptharu50028@gmail.com",
|
|
||||||
url="https://github.com/S4NKALP/djinit",
|
|
||||||
packages=find_packages(where="src"),
|
|
||||||
package_dir={"": "src"},
|
|
||||||
include_package_data=True,
|
|
||||||
install_requires=[
|
|
||||||
"rich>=14.2.0",
|
|
||||||
"questionary>=2.0.1",
|
|
||||||
"typer>=0.21.1",
|
|
||||||
],
|
|
||||||
entry_points={
|
|
||||||
"console_scripts": [
|
|
||||||
"djinit=djinit.cli:app",
|
|
||||||
"dj=djinit.cli:app",
|
|
||||||
]
|
|
||||||
},
|
|
||||||
classifiers=[
|
|
||||||
"Programming Language :: Python",
|
|
||||||
"Programming Language :: Python :: 3",
|
|
||||||
"Programming Language :: Python :: 3.10",
|
|
||||||
"Programming Language :: Python :: 3.11",
|
|
||||||
"Programming Language :: Python :: 3.12",
|
|
||||||
"Programming Language :: Python :: 3.13",
|
|
||||||
"Environment :: Web Environment",
|
|
||||||
"Framework :: Django",
|
|
||||||
"Framework :: Django :: 4.2",
|
|
||||||
"Framework :: Django :: 5.1",
|
|
||||||
"Framework :: Django :: 5.2",
|
|
||||||
"Intended Audience :: Developers",
|
|
||||||
"License :: OSI Approved :: MIT License",
|
|
||||||
"Operating System :: OS Independent",
|
|
||||||
"Topic :: Internet :: WWW/HTTP",
|
|
||||||
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
|
|
||||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
||||||
"Topic :: Software Development :: Code Generators",
|
|
||||||
],
|
|
||||||
keywords=[
|
|
||||||
"django",
|
|
||||||
"cli",
|
|
||||||
"project",
|
|
||||||
"djinit",
|
|
||||||
"djinitx",
|
|
||||||
"dj",
|
|
||||||
"django-init",
|
|
||||||
"django project",
|
|
||||||
"django starter",
|
|
||||||
],
|
|
||||||
python_requires=">=3.10",
|
|
||||||
project_urls={
|
|
||||||
"Bug Reports": "https://github.com/S4NKALP/djinit/issues",
|
|
||||||
"Source": "https://github.com/S4NKALP/djinit",
|
|
||||||
"Documentation": "https://github.com/S4NKALP/djinit#readme",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
+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")
|
||||||
@@ -285,6 +300,19 @@ class FileCreator(BaseService):
|
|||||||
core_subfolders = ["utils", "mixins", "middleware"]
|
core_subfolders = ["utils", "mixins", "middleware"]
|
||||||
self._create_subdirectories_with_init(core_dir, core_subfolders, "apps/core/")
|
self._create_subdirectories_with_init(core_dir, core_subfolders, "apps/core/")
|
||||||
|
|
||||||
|
# Inject base model
|
||||||
|
CommonUtils.create_file_from_template(
|
||||||
|
os.path.join(core_dir, "base.py"),
|
||||||
|
"components/base.py-tpl",
|
||||||
|
{},
|
||||||
|
"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")
|
||||||
@@ -352,6 +380,14 @@ class FileCreator(BaseService):
|
|||||||
components = ["admin", "models", "serializers", "tests", "urls", "views"]
|
components = ["admin", "models", "serializers", "tests", "urls", "views"]
|
||||||
self._create_subdirectories_with_init(apps_dir, components, "apps/")
|
self._create_subdirectories_with_init(apps_dir, components, "apps/")
|
||||||
|
|
||||||
|
# Inject base model
|
||||||
|
CommonUtils.create_file_from_template(
|
||||||
|
os.path.join(apps_dir, "models", "base.py"),
|
||||||
|
"components/base.py-tpl",
|
||||||
|
{},
|
||||||
|
"Created apps/models/base.py",
|
||||||
|
)
|
||||||
|
|
||||||
# Create 'api' directory with v1
|
# Create 'api' directory with v1
|
||||||
api_dir = os.path.join(apps_dir, "api")
|
api_dir = os.path.join(apps_dir, "api")
|
||||||
CommonUtils.create_directory_with_init(api_dir, "Created apps/api/__init__.py")
|
CommonUtils.create_directory_with_init(api_dir, "Created apps/api/__init__.py")
|
||||||
@@ -401,6 +437,14 @@ class FileCreator(BaseService):
|
|||||||
components = ["admin", "api", "models", "tests"]
|
components = ["admin", "api", "models", "tests"]
|
||||||
self._create_subdirectories_with_init(project_dir, components, f"{self.module_name}/")
|
self._create_subdirectories_with_init(project_dir, components, f"{self.module_name}/")
|
||||||
|
|
||||||
|
# Inject base model
|
||||||
|
CommonUtils.create_file_from_template(
|
||||||
|
os.path.join(project_dir, "models", "base.py"),
|
||||||
|
"components/base.py-tpl",
|
||||||
|
{},
|
||||||
|
f"Created {self.module_name}/models/base.py",
|
||||||
|
)
|
||||||
|
|
||||||
# Create README files for guidance
|
# Create README files for guidance
|
||||||
CommonUtils.create_file_from_template(
|
CommonUtils.create_file_from_template(
|
||||||
os.path.join(project_dir, "api", "README.md"),
|
os.path.join(project_dir, "api", "README.md"),
|
||||||
@@ -510,3 +554,15 @@ class FileCreator(BaseService):
|
|||||||
CommonUtils.create_file_with_content(
|
CommonUtils.create_file_with_content(
|
||||||
filepath, json.dumps(config, indent=4), "Created .djinit configuration file"
|
filepath, json.dumps(config, indent=4), "Created .djinit configuration file"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def create_standard_shared(self) -> None:
|
||||||
|
"""Create shared directory and inject base model for standard structure."""
|
||||||
|
shared_dir = os.path.join(self.project_root, "shared")
|
||||||
|
CommonUtils.create_directory_with_init(shared_dir, "Created shared/__init__.py")
|
||||||
|
|
||||||
|
CommonUtils.create_file_from_template(
|
||||||
|
os.path.join(shared_dir, "base.py"),
|
||||||
|
"components/base.py-tpl",
|
||||||
|
{},
|
||||||
|
"Created shared/base.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."""
|
||||||
@@ -68,6 +71,7 @@ class SetupCreator:
|
|||||||
else:
|
else:
|
||||||
steps.append(("Creating Django apps", self.project_creator.create_apps))
|
steps.append(("Creating Django apps", self.project_creator.create_apps))
|
||||||
steps.append(("Creating project URLs", self.file_creator.create_project_urls))
|
steps.append(("Creating project URLs", self.file_creator.create_project_urls))
|
||||||
|
steps.append(("Creating shared module", self.file_creator.create_standard_shared))
|
||||||
|
|
||||||
steps.extend(
|
steps.extend(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"""generated with djinit"""
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class TimeStampedModel(models.Model):
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
@@ -17,7 +17,7 @@ environ.Env.read_env(env_path)
|
|||||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||||
|
|
||||||
# Quick-start development settings - unsuitable for production
|
# Quick-start development settings - unsuitable for production
|
||||||
# See https://docs.djangoproject.com/en/6.1/howto/deployment/checklist/
|
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
|
||||||
|
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
@@ -26,7 +26,6 @@ THIRD_PARTY_APPS = [
|
|||||||
"rest_framework_simplejwt",
|
"rest_framework_simplejwt",
|
||||||
"rest_framework_simplejwt.token_blacklist",
|
"rest_framework_simplejwt.token_blacklist",
|
||||||
"corsheaders",
|
"corsheaders",
|
||||||
"drf_yasg",
|
|
||||||
"django_tailwind_cli", # @IF use_tailwind
|
"django_tailwind_cli", # @IF use_tailwind
|
||||||
"django_htmx", # @IF use_htmx
|
"django_htmx", # @IF use_htmx
|
||||||
]
|
]
|
||||||
@@ -83,7 +82,7 @@ WSGI_APPLICATION = "[[ project_name ]].wsgi.application"
|
|||||||
|
|
||||||
|
|
||||||
# Password validation
|
# Password validation
|
||||||
# https://docs.djangoproject.com/en/6.1/ref/settings/#auth-password-validators
|
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
|
||||||
|
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
{
|
{
|
||||||
@@ -102,12 +101,11 @@ AUTH_PASSWORD_VALIDATORS = [
|
|||||||
|
|
||||||
|
|
||||||
# Internationalization
|
# Internationalization
|
||||||
# https://docs.djangoproject.com/en/6.1/topics/i18n/
|
# https://docs.djangoproject.com/en/6.0/topics/i18n/
|
||||||
|
|
||||||
LANGUAGE_CODE = "en-us"
|
LANGUAGE_CODE = "en-us"
|
||||||
TIME_ZONE = "UTC"
|
TIME_ZONE = "UTC"
|
||||||
USE_I18N = True
|
USE_I18N = True
|
||||||
USE_TZ = True
|
|
||||||
|
|
||||||
# Static files and media files
|
# Static files and media files
|
||||||
STATIC_URL = "/static/"
|
STATIC_URL = "/static/"
|
||||||
@@ -139,7 +137,6 @@ REST_FRAMEWORK = {
|
|||||||
"rest_framework_simplejwt.authentication.JWTAuthentication",
|
"rest_framework_simplejwt.authentication.JWTAuthentication",
|
||||||
"rest_framework.authentication.SessionAuthentication",
|
"rest_framework.authentication.SessionAuthentication",
|
||||||
),
|
),
|
||||||
"DEFAULT_SCHEMA_CLASS": "rest_framework.schemas.coreapi.AutoSchema",
|
|
||||||
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
|
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
|
||||||
"PAGE_SIZE": 20,
|
"PAGE_SIZE": 20,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ DEBUG = True
|
|||||||
ALLOWED_HOSTS = ["*"]
|
ALLOWED_HOSTS = ["*"]
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
# https://docs.djangoproject.com/en/6.1/ref/settings/#databases
|
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases
|
||||||
|
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
"default": {
|
"default": {
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ from django.views.static import serve
|
|||||||
from django.urls import include
|
from django.urls import include
|
||||||
# @ENDIF
|
# @ENDIF
|
||||||
# @IF include_api
|
# @IF include_api
|
||||||
from drf_yasg.views import get_schema_view
|
|
||||||
from drf_yasg import openapi
|
|
||||||
from rest_framework_simplejwt.views import (
|
from rest_framework_simplejwt.views import (
|
||||||
TokenObtainPairView,
|
TokenObtainPairView,
|
||||||
TokenRefreshView,
|
TokenRefreshView,
|
||||||
@@ -39,22 +37,6 @@ urlpatterns = [
|
|||||||
# @ENDLOOP
|
# @ENDLOOP
|
||||||
# @ENDIF
|
# @ENDIF
|
||||||
]
|
]
|
||||||
# @IF include_api
|
|
||||||
|
|
||||||
# Don't show schema in production
|
|
||||||
if settings.DEBUG:
|
|
||||||
schema_view = get_schema_view(
|
|
||||||
openapi.Info(
|
|
||||||
title="[[ project_name ]] API",
|
|
||||||
default_version="v1",
|
|
||||||
),
|
|
||||||
public=True,
|
|
||||||
)
|
|
||||||
urlpatterns += [
|
|
||||||
path("docs/", schema_view.with_ui("swagger", cache_timeout=0)),
|
|
||||||
path("schema/", schema_view.with_ui("redoc", cache_timeout=0)),
|
|
||||||
]
|
|
||||||
# @ENDIF
|
|
||||||
# @IF use_tailwind
|
# @IF use_tailwind
|
||||||
|
|
||||||
# django-browser-reload for tailwind live reload (dev only)
|
# django-browser-reload for tailwind live reload (dev only)
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "[[ project_name ]]"
|
name = "[[ package_name ]]"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = ""
|
description = ""
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
license = { text = "MIT" }
|
license = { text = "MIT" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"django>=6.1,<7.0",
|
"django>=6.0,<7.0",
|
||||||
"django-environ",
|
"django-environ",
|
||||||
"django-jazzmin",
|
"django-jazzmin",
|
||||||
"djangorestframework",
|
"djangorestframework",
|
||||||
"djangorestframework-simplejwt",
|
"djangorestframework-simplejwt",
|
||||||
"drf-yasg",
|
|
||||||
"django-cors-headers",
|
"django-cors-headers",
|
||||||
"whitenoise",
|
"whitenoise",
|
||||||
# @IF database_type == 'mysql'
|
# @IF database_type == 'mysql'
|
||||||
@@ -62,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)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ django-environ
|
|||||||
django-jazzmin
|
django-jazzmin
|
||||||
djangorestframework
|
djangorestframework
|
||||||
djangorestframework_simplejwt
|
djangorestframework_simplejwt
|
||||||
drf-yasg
|
|
||||||
django-cors-headers
|
django-cors-headers
|
||||||
whitenoise
|
whitenoise
|
||||||
django-tailwind-cli # @IF use_tailwind
|
django-tailwind-cli # @IF use_tailwind
|
||||||
|
|||||||
@@ -187,6 +187,9 @@ class InputCollector:
|
|||||||
"""Helper method to generate metadata dictionary."""
|
"""Helper method to generate metadata dictionary."""
|
||||||
project_dir = options["project_dir"]
|
project_dir = options["project_dir"]
|
||||||
project_name = project_dir
|
project_name = project_dir
|
||||||
|
if project_name == "." or not project_name:
|
||||||
|
project_name = os.path.basename(os.path.abspath(os.getcwd()))
|
||||||
|
|
||||||
app_names: list[str] = []
|
app_names: list[str] = []
|
||||||
|
|
||||||
# Default package_name to "backend" if project_dir is "." or empty
|
# Default package_name to "backend" if project_dir is "." or empty
|
||||||
|
|||||||
Reference in new issue
Block a user