feat: improve project structure detection and interactive configuration
This commit is contained in:
12 files changed
+392
-526
No files matched your search
@@ -10,5 +10,6 @@
|
||||
## Enhancements
|
||||
|
||||
- [ ] Add more project structure templates
|
||||
- [ ] Add interactive configuration wizard
|
||||
- [x] Add interactive configuration wizard
|
||||
- [ ] Add testing framework setup (pytest, coverage)
|
||||
- [x] Improve structure detection
|
||||
+8
-7
@@ -9,7 +9,6 @@ from typing import Callable, List, Tuple
|
||||
from djinit.services.files import FileManager
|
||||
from djinit.services.project import ProjectManager
|
||||
from djinit.ui.console import UIFormatter
|
||||
from djinit.utils.security import generate_secret_command
|
||||
|
||||
|
||||
class Cli:
|
||||
@@ -27,7 +26,8 @@ class Cli:
|
||||
self.project_manager = ProjectManager(project_dir, project_name, app_names, metadata)
|
||||
self.file_manager = FileManager(self.project_root, project_name, app_names, metadata)
|
||||
|
||||
def run_setup(self) -> bool:
|
||||
def _normalize_metadata(self) -> None:
|
||||
"""Normalize metadata and app names based on structure type."""
|
||||
if self.metadata.get("predefined_structure"):
|
||||
self.metadata.setdefault("project_module_name", "config")
|
||||
self.metadata.setdefault("nested_apps", True)
|
||||
@@ -36,7 +36,7 @@ class Cli:
|
||||
self.app_names = ["users", "core"]
|
||||
self.project_manager.app_names = self.app_names
|
||||
|
||||
if self.metadata.get("unified_structure"):
|
||||
elif self.metadata.get("unified_structure"):
|
||||
self.metadata.setdefault("project_module_name", "core")
|
||||
self.metadata.setdefault("nested_apps", True)
|
||||
self.metadata.setdefault("nested_dir", "apps")
|
||||
@@ -44,12 +44,15 @@ class Cli:
|
||||
self.app_names = []
|
||||
self.project_manager.app_names = self.app_names
|
||||
|
||||
if self.metadata.get("single_structure"):
|
||||
elif self.metadata.get("single_structure"):
|
||||
self.metadata.setdefault("nested_apps", False)
|
||||
if not self.app_names:
|
||||
self.app_names = []
|
||||
self.project_manager.app_names = self.app_names
|
||||
|
||||
def run_setup(self) -> bool:
|
||||
self._normalize_metadata()
|
||||
|
||||
steps: List[Tuple[str, Callable[[], None]]] = []
|
||||
|
||||
steps.append(("Creating Django project", self.project_manager.create_project))
|
||||
@@ -100,6 +103,7 @@ class Cli:
|
||||
self.file_manager.create_requirements,
|
||||
self.file_manager.create_readme,
|
||||
self.file_manager.create_env_file,
|
||||
self.file_manager.create_djinit_config,
|
||||
lambda: self.file_manager.create_pyproject_toml(self.metadata),
|
||||
]
|
||||
|
||||
@@ -115,6 +119,3 @@ class Cli:
|
||||
if self.metadata.get("use_gitlab_ci", True):
|
||||
self.file_manager.create_gitlab_ci()
|
||||
|
||||
def generate_secret_keys(self) -> bool:
|
||||
generate_secret_command()
|
||||
return True
|
||||
+13
-17
@@ -11,7 +11,7 @@ from rich.text import Text
|
||||
|
||||
from djinit.services.app import AppManager
|
||||
from djinit.ui.console import UIColors, UIFormatter, console
|
||||
from djinit.utils.security import display_secret_keys, generate_multiple_keys
|
||||
from djinit.utils.secretkey import display_secret_keys, generate_multiple_keys
|
||||
from djinit.utils.validators import validate_app_name
|
||||
|
||||
|
||||
@@ -67,27 +67,23 @@ def handle_app_command(args: argparse.Namespace) -> None:
|
||||
UIFormatter.print_header("Django App Creation")
|
||||
UIFormatter.print_info("")
|
||||
|
||||
tokens = args.app_name if isinstance(args.app_name, list) else [args.app_name]
|
||||
pieces = []
|
||||
for token in tokens:
|
||||
if token is None:
|
||||
continue
|
||||
for part in str(token).split(","):
|
||||
part = part.strip()
|
||||
if part:
|
||||
pieces.append(part)
|
||||
app_names = pieces
|
||||
raw_tokens = args.app_name if isinstance(args.app_name, list) else [args.app_name]
|
||||
|
||||
# Flatten and clean tokens
|
||||
app_names = [
|
||||
part.strip()
|
||||
for token in raw_tokens
|
||||
if token
|
||||
for part in str(token).split(",")
|
||||
if part.strip()
|
||||
]
|
||||
|
||||
if not app_names:
|
||||
UIFormatter.print_error("App name cannot be empty")
|
||||
sys.exit(1)
|
||||
|
||||
seen = set()
|
||||
deduped_names = []
|
||||
for name in app_names:
|
||||
if name not in seen:
|
||||
deduped_names.append(name)
|
||||
seen.add(name)
|
||||
# Deduplicate while preserving order
|
||||
deduped_names = list(dict.fromkeys(app_names))
|
||||
|
||||
for name in deduped_names:
|
||||
is_valid, error_msg = validate_app_name(name)
|
||||
|
||||
+38
-14
@@ -16,6 +16,7 @@ from djinit.utils.common import (
|
||||
extract_existing_apps,
|
||||
find_project_dir,
|
||||
find_settings_path,
|
||||
get_djinit_config,
|
||||
insert_apps_into_user_defined_apps,
|
||||
is_django_project,
|
||||
)
|
||||
@@ -27,6 +28,7 @@ class AppManager:
|
||||
self.app_name = app_name
|
||||
self.current_dir = os.getcwd()
|
||||
self.manage_py_path = os.path.join(self.current_dir, "manage.py")
|
||||
self.config = get_djinit_config(self.current_dir)
|
||||
self._project_structure_cache = None
|
||||
|
||||
def create_app(self) -> bool:
|
||||
@@ -126,36 +128,58 @@ class AppManager:
|
||||
def _get_project_structure(self) -> tuple[bool, Optional[str], str]:
|
||||
"""Get project structure with caching to avoid repeated detection."""
|
||||
if self._project_structure_cache is None:
|
||||
project_dir, settings_base_path = find_project_dir(self.current_dir)
|
||||
|
||||
if project_dir is None:
|
||||
self._project_structure_cache = (False, None, self.current_dir)
|
||||
if self.config:
|
||||
apps_config = self.config.get("apps", {})
|
||||
nested = apps_config.get("nested", False)
|
||||
nested_dir = apps_config.get("nested_dir")
|
||||
|
||||
# Determine base dir for apps
|
||||
if nested and nested_dir:
|
||||
apps_base_dir = os.path.join(self.current_dir, nested_dir)
|
||||
else:
|
||||
apps_base_dir = self.current_dir
|
||||
|
||||
self._project_structure_cache = (nested, nested_dir, apps_base_dir)
|
||||
else:
|
||||
self._project_structure_cache = detect_nested_structure_from_settings(
|
||||
settings_base_path, self.current_dir
|
||||
)
|
||||
# Fallback to old detection method
|
||||
project_dir, settings_base_path = find_project_dir(self.current_dir)
|
||||
|
||||
if project_dir is None:
|
||||
self._project_structure_cache = (False, None, self.current_dir)
|
||||
else:
|
||||
self._project_structure_cache = detect_nested_structure_from_settings(
|
||||
settings_base_path, self.current_dir
|
||||
)
|
||||
return self._project_structure_cache
|
||||
|
||||
def _is_predefined_structure(self) -> bool:
|
||||
if self.config:
|
||||
return self.config.get("structure", {}).get("predefined", False)
|
||||
|
||||
apps_dir = os.path.join(self.current_dir, "apps")
|
||||
api_dir = os.path.join(self.current_dir, "api")
|
||||
return os.path.isdir(apps_dir) and os.path.isdir(api_dir)
|
||||
|
||||
def _is_restricted_structure(self) -> bool:
|
||||
"""Check if the project structure is Unified or Single Folder."""
|
||||
apps_api_dir = os.path.join(self.current_dir, "apps", "api")
|
||||
if os.path.isdir(apps_api_dir):
|
||||
if self.config:
|
||||
structure = self.config.get("structure", {})
|
||||
return structure.get("unified", False) or structure.get("single", False)
|
||||
|
||||
# Check for Unified structure (apps/api exists)
|
||||
if os.path.isdir(os.path.join(self.current_dir, "apps", "api")):
|
||||
return True
|
||||
|
||||
apps_dir = os.path.join(self.current_dir, "apps")
|
||||
if os.path.isdir(apps_dir):
|
||||
# Check for Predefined structure (apps/ exists but not apps/api)
|
||||
if os.path.isdir(os.path.join(self.current_dir, "apps")):
|
||||
return False
|
||||
|
||||
# Check for Single Folder structure (models/ and api/ exist in project dir)
|
||||
project_dir, _ = find_project_dir(self.current_dir)
|
||||
if project_dir:
|
||||
models_dir = os.path.join(project_dir, "models")
|
||||
api_dir = os.path.join(project_dir, "api")
|
||||
if os.path.isdir(models_dir) and os.path.isdir(api_dir):
|
||||
has_models = os.path.isdir(os.path.join(project_dir, "models"))
|
||||
has_api = os.path.isdir(os.path.join(project_dir, "api"))
|
||||
if has_models and has_api:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
+105
-66
@@ -45,10 +45,12 @@ class FileManager:
|
||||
def _create_files_from_specs(self, base_dir: str, folder_specs: dict, base_context: dict = None) -> None:
|
||||
"""Helper to create multiple folders and files from a specification dict."""
|
||||
base_context = base_context or {}
|
||||
for folder, files in folder_specs.items():
|
||||
folder_path = os.path.join(base_dir, folder)
|
||||
|
||||
for folder_name, file_templates in folder_specs.items():
|
||||
folder_path = os.path.join(base_dir, folder_name)
|
||||
create_directory_with_init(folder_path, f"Created {folder_path}/__init__.py")
|
||||
for filename, template_path in files:
|
||||
|
||||
for filename, template_path in file_templates:
|
||||
file_path = os.path.join(folder_path, filename)
|
||||
create_file_from_template(file_path, template_path, base_context, f"Created {file_path}")
|
||||
|
||||
@@ -66,6 +68,52 @@ class FileManager:
|
||||
message = f"Created {prefix}{subdir}/__init__.py" if prefix else f"Created {subdir}/__init__.py"
|
||||
create_directory_with_init(subdir_path, message)
|
||||
|
||||
def _create_settings_package(self, settings_dir: str, base_context: dict, prefix: str) -> None:
|
||||
"""Helper to create settings package files (base, development, production)."""
|
||||
from djinit.utils.secretkey import generate_secret_key
|
||||
|
||||
secret_key = generate_secret_key()
|
||||
dev_context = {"secret_key": secret_key}
|
||||
|
||||
for filename, context in [
|
||||
("base.py", base_context),
|
||||
("development.py", dev_context),
|
||||
("production.py", base_context),
|
||||
]:
|
||||
filepath = os.path.join(settings_dir, filename)
|
||||
template_path = f"config/settings/{filename.replace('.py', '')}.j2"
|
||||
self._render_and_create_file(
|
||||
filepath,
|
||||
template_path,
|
||||
context,
|
||||
f"Created {prefix}/settings/{filename}",
|
||||
base_dir=os.path.dirname(settings_dir), # Not strictly used by _render_and_create_file but good for context
|
||||
should_format=True
|
||||
)
|
||||
|
||||
def _create_lifecycle_files(self, target_dir: str, prefix: str, api_module: str = None, comment_out_api: bool = False) -> None:
|
||||
"""Helper to create urls.py, wsgi.py, and asgi.py."""
|
||||
|
||||
urls_context = {"api_module": api_module}
|
||||
if comment_out_api:
|
||||
urls_context["comment_out_api_url"] = True
|
||||
|
||||
files = [
|
||||
("urls.py", "config/urls/with_api.j2", urls_context),
|
||||
("wsgi.py", "config/wsgi.j2", {}),
|
||||
("asgi.py", "config/asgi.j2", {}),
|
||||
]
|
||||
|
||||
for filename, template, context in files:
|
||||
filepath = os.path.join(target_dir, filename)
|
||||
self._render_and_create_file(
|
||||
filepath,
|
||||
template,
|
||||
context,
|
||||
f"Created {prefix}/{filename}",
|
||||
should_format=True
|
||||
)
|
||||
|
||||
def create_gitignore(self) -> None:
|
||||
self._render_and_create_file(".gitignore", "project/gitignore.j2", {}, "Created .gitignore file")
|
||||
|
||||
@@ -208,43 +256,17 @@ class FileManager:
|
||||
settings_dir = os.path.join(core_dir, "settings")
|
||||
create_directory_with_init(settings_dir, "Created core/settings/__init__.py")
|
||||
|
||||
from djinit.utils.security import generate_secret_key
|
||||
|
||||
secret_key = generate_secret_key()
|
||||
|
||||
# In this new structure, 'apps' is the main app.
|
||||
# We assume the user wants 'apps' to be the app label.
|
||||
# Or perhaps 'apps' is just a container and the components inside are what matters?
|
||||
# Given the structure: apps/models, apps/views etc.
|
||||
# It seems 'apps' is acting as the app.
|
||||
|
||||
# Create settings files
|
||||
base_context = {
|
||||
"project_name": "core", # Project module is 'core'
|
||||
"app_names": ["apps"], # 'apps' is the main app
|
||||
"project_name": "core",
|
||||
"app_names": ["apps"],
|
||||
"use_database_url": self.metadata.get("use_database_url", True),
|
||||
"database_type": self.metadata.get("database_type", "postgresql"),
|
||||
}
|
||||
dev_context = {"secret_key": secret_key}
|
||||
self._create_settings_package(settings_dir, base_context, "core")
|
||||
|
||||
for filename, context in [
|
||||
("base.py", base_context),
|
||||
("development.py", dev_context),
|
||||
("production.py", base_context),
|
||||
]:
|
||||
filepath = os.path.join(settings_dir, filename)
|
||||
template_path = f"config/settings/{filename.replace('.py', '')}.j2"
|
||||
create_file_from_template(
|
||||
filepath, template_path, context, f"Created core/settings/{filename}", should_format=True
|
||||
)
|
||||
|
||||
core_files = [
|
||||
("urls.py", "config/urls/with_api.j2", {"api_module": "apps.api"}),
|
||||
("wsgi.py", "config/wsgi.j2", {}),
|
||||
("asgi.py", "config/asgi.j2", {}),
|
||||
]
|
||||
for filename, template, context in core_files:
|
||||
filepath = os.path.join(core_dir, filename)
|
||||
create_file_from_template(filepath, template, context, f"Created core/{filename}", should_format=True)
|
||||
# Create lifecycle files (urls, wsgi, asgi)
|
||||
self._create_lifecycle_files(core_dir, "core", api_module="apps.api")
|
||||
|
||||
# 2. Create 'apps' directory (The Main App)
|
||||
apps_dir = os.path.join(self.project_root, "apps")
|
||||
@@ -287,43 +309,22 @@ class FileManager:
|
||||
settings_dir = os.path.join(project_dir, "settings")
|
||||
create_directory_with_init(settings_dir, f"Created {self.module_name}/settings/__init__.py")
|
||||
|
||||
from djinit.utils.security import generate_secret_key
|
||||
|
||||
secret_key = generate_secret_key()
|
||||
|
||||
# Create settings files
|
||||
base_context = {
|
||||
"project_name": self.module_name,
|
||||
"app_names": [self.module_name], # Project acts as the app
|
||||
"app_names": [self.module_name],
|
||||
"use_database_url": self.metadata.get("use_database_url", True),
|
||||
"database_type": self.metadata.get("database_type", "postgresql"),
|
||||
}
|
||||
dev_context = {"secret_key": secret_key}
|
||||
self._create_settings_package(settings_dir, base_context, self.module_name)
|
||||
|
||||
for filename, context in [
|
||||
("base.py", base_context),
|
||||
("development.py", dev_context),
|
||||
("production.py", base_context),
|
||||
]:
|
||||
filepath = os.path.join(settings_dir, filename)
|
||||
template_path = f"config/settings/{filename.replace('.py', '')}.j2"
|
||||
create_file_from_template(
|
||||
filepath, template_path, context, f"Created {self.module_name}/settings/{filename}", should_format=True
|
||||
)
|
||||
|
||||
project_files = [
|
||||
(
|
||||
"urls.py",
|
||||
"config/urls/with_api.j2",
|
||||
{"api_module": f"{self.module_name}.api", "comment_out_api_url": True},
|
||||
),
|
||||
("wsgi.py", "config/wsgi.j2", {}),
|
||||
("asgi.py", "config/asgi.j2", {}),
|
||||
]
|
||||
for filename, template, context in project_files:
|
||||
filepath = os.path.join(project_dir, filename)
|
||||
create_file_from_template(
|
||||
filepath, template, context, f"Created {self.module_name}/{filename}", should_format=True
|
||||
)
|
||||
# Create lifecycle files (urls, wsgi, asgi)
|
||||
self._create_lifecycle_files(
|
||||
project_dir,
|
||||
self.module_name,
|
||||
api_module=f"{self.module_name}.api",
|
||||
comment_out_api=True
|
||||
)
|
||||
|
||||
components = ["admin", "api", "models", "tests"]
|
||||
self._create_subdirectories_with_init(project_dir, components, f"{self.module_name}/")
|
||||
@@ -395,3 +396,41 @@ class FileManager:
|
||||
context,
|
||||
"Created GitLab CI configuration (.gitlab-ci.yml)",
|
||||
)
|
||||
|
||||
def create_djinit_config(self) -> None:
|
||||
"""Create .djinit configuration file."""
|
||||
import json
|
||||
|
||||
is_predefined = self.metadata.get("predefined_structure", False)
|
||||
is_unified = self.metadata.get("unified_structure", False)
|
||||
is_single = self.metadata.get("single_structure", False)
|
||||
is_standard = not (is_predefined or is_unified or is_single)
|
||||
|
||||
config = {
|
||||
"project_name": self.project_name,
|
||||
"structure": {
|
||||
"standard": is_standard,
|
||||
"predefined": is_predefined,
|
||||
"unified": is_unified,
|
||||
"single": is_single,
|
||||
},
|
||||
"apps": {
|
||||
"nested": self.metadata.get("nested_apps", False),
|
||||
"nested_dir": self.metadata.get("nested_dir"),
|
||||
},
|
||||
"settings": {
|
||||
"use_database_url": self.metadata.get("use_database_url", True),
|
||||
"database_type": self.metadata.get("database_type", "postgresql"),
|
||||
},
|
||||
"cicd": {
|
||||
"github": self.metadata.get("use_github_actions", False),
|
||||
"gitlab": self.metadata.get("use_gitlab_ci", False),
|
||||
}
|
||||
}
|
||||
|
||||
filepath = os.path.join(self.project_root, ".djinit")
|
||||
create_file_with_content(
|
||||
filepath,
|
||||
json.dumps(config, indent=4),
|
||||
"Created .djinit configuration file"
|
||||
)
|
||||
+22
-19
@@ -89,23 +89,25 @@ class ProjectManager:
|
||||
UIFormatter.print_success(f"Added apps to USER_DEFINED_APPS: {added_apps_str}")
|
||||
|
||||
def validate_project_structure(self) -> None:
|
||||
join = lambda *args: os.path.join(self.project_root, *args)
|
||||
|
||||
required_files = [
|
||||
os.path.join(self.project_root, "manage.py"),
|
||||
os.path.join(self.project_root, self.module_name, "__init__.py"),
|
||||
os.path.join(self.project_root, self.module_name, "settings", "__init__.py"),
|
||||
os.path.join(self.project_root, self.module_name, "settings", "base.py"),
|
||||
os.path.join(self.project_root, self.module_name, "settings", "development.py"),
|
||||
os.path.join(self.project_root, self.module_name, "settings", "production.py"),
|
||||
os.path.join(self.project_root, self.module_name, "urls.py"),
|
||||
os.path.join(self.project_root, self.module_name, "wsgi.py"),
|
||||
os.path.join(self.project_root, self.module_name, "asgi.py"),
|
||||
join("manage.py"),
|
||||
join(self.module_name, "__init__.py"),
|
||||
join(self.module_name, "settings", "__init__.py"),
|
||||
join(self.module_name, "settings", "base.py"),
|
||||
join(self.module_name, "settings", "development.py"),
|
||||
join(self.module_name, "settings", "production.py"),
|
||||
join(self.module_name, "urls.py"),
|
||||
join(self.module_name, "wsgi.py"),
|
||||
join(self.module_name, "asgi.py"),
|
||||
]
|
||||
|
||||
apps_base_dir = self._get_apps_base_dir()
|
||||
|
||||
if self.metadata.get("unified_structure"):
|
||||
# Validate Unified Structure
|
||||
apps_dir = os.path.join(self.project_root, "apps")
|
||||
apps_dir = join("apps")
|
||||
unified_files = [
|
||||
os.path.join(apps_dir, "__init__.py"),
|
||||
os.path.join(apps_dir, "apps.py"),
|
||||
@@ -124,16 +126,17 @@ class ProjectManager:
|
||||
|
||||
elif not self.metadata.get("predefined_structure") and not self.metadata.get("single_structure"):
|
||||
for app_name in self.app_names:
|
||||
app_path = lambda f: os.path.join(apps_base_dir, app_name, f)
|
||||
app_files = [
|
||||
os.path.join(apps_base_dir, app_name, "__init__.py"),
|
||||
os.path.join(apps_base_dir, app_name, "apps.py"),
|
||||
os.path.join(apps_base_dir, app_name, "models.py"),
|
||||
os.path.join(apps_base_dir, app_name, "views.py"),
|
||||
os.path.join(apps_base_dir, app_name, "serializers.py"),
|
||||
os.path.join(apps_base_dir, app_name, "routes.py"),
|
||||
os.path.join(apps_base_dir, app_name, "tests.py"),
|
||||
os.path.join(apps_base_dir, app_name, "migrations"),
|
||||
os.path.join(apps_base_dir, app_name, "admin.py"),
|
||||
app_path("__init__.py"),
|
||||
app_path("apps.py"),
|
||||
app_path("models.py"),
|
||||
app_path("views.py"),
|
||||
app_path("serializers.py"),
|
||||
app_path("routes.py"),
|
||||
app_path("tests.py"),
|
||||
app_path("migrations"),
|
||||
app_path("admin.py"),
|
||||
]
|
||||
required_files.extend(app_files)
|
||||
|
||||
|
||||
+19
-10
@@ -12,6 +12,7 @@ from rich.panel import Panel
|
||||
from rich.progress import BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
import questionary
|
||||
|
||||
console = Console()
|
||||
|
||||
@@ -164,20 +165,28 @@ class UIFormatter:
|
||||
|
||||
@staticmethod
|
||||
def confirm(prompt: str, default: bool = True) -> bool:
|
||||
"""Display a confirmation prompt"""
|
||||
default_str = "Y/n" if default else "y/N"
|
||||
response = console.input(f"[bold cyan]?[/bold cyan] {prompt} [{default_str}]: ").strip().lower()
|
||||
if not response:
|
||||
return default
|
||||
return response in ("y", "yes")
|
||||
"""Display a confirmation prompt as a selectable list"""
|
||||
choices = [
|
||||
questionary.Choice("Yes", value=True),
|
||||
questionary.Choice("No", value=False),
|
||||
]
|
||||
default_choice = choices[0] if default else choices[1]
|
||||
|
||||
result = questionary.select(
|
||||
prompt,
|
||||
choices=choices,
|
||||
default=default_choice,
|
||||
).ask()
|
||||
|
||||
if result is None:
|
||||
raise KeyboardInterrupt
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def prompt(message: str, default: Optional[str] = None) -> str:
|
||||
"""Display an input prompt"""
|
||||
if default:
|
||||
message = f"{message} [{default}]"
|
||||
response = console.input(f"[bold cyan]?[/bold cyan] {message}: ").strip()
|
||||
return response or default or ""
|
||||
return questionary.text(message, default=default or "").ask()
|
||||
|
||||
@staticmethod
|
||||
def handle_exception(e: Exception):
|
||||
|
||||
+125
-374
@@ -5,88 +5,56 @@ Handles collection and processing of user inputs during setup.
|
||||
|
||||
import os
|
||||
import sys
|
||||
import termios
|
||||
import tty
|
||||
from enum import Enum
|
||||
from typing import Callable, Tuple
|
||||
from typing import Tuple, TypedDict
|
||||
|
||||
class StructureOptions(TypedDict):
|
||||
project_dir: str
|
||||
predefined: bool
|
||||
unified: bool
|
||||
single: bool
|
||||
single_module_name: str | None
|
||||
database_type: str
|
||||
use_database_url: bool
|
||||
use_github: bool
|
||||
use_gitlab: bool
|
||||
|
||||
import questionary
|
||||
from djinit.core.types import ProjectMetadata
|
||||
from djinit.ui.console import UIColors, UIFormatter, console
|
||||
from djinit.utils.common import get_package_name
|
||||
from djinit.utils.validators import validate_app_name, validate_project_name
|
||||
|
||||
|
||||
class CICDOption(Enum):
|
||||
GITHUB = ("g", "GitHub Actions only")
|
||||
GITLAB = ("l", "GitLab CI only")
|
||||
BOTH = ("b", "Both (GitHub Actions + GitLab CI)")
|
||||
NONE = ("n", "None (skip CI/CD)")
|
||||
|
||||
def __init__(self, key: str, description: str):
|
||||
self.key = key
|
||||
self.description = description
|
||||
|
||||
|
||||
class InputCollector:
|
||||
MAX_ATTEMPTS = 3
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def get_validated_input(
|
||||
self, prompt: str, validator: Callable[[str], Tuple[bool, str]], input_type: str, allow_empty: bool = False
|
||||
self, prompt: str, validator: callable, input_type: str, allow_empty: bool = False
|
||||
) -> str:
|
||||
attempt = 0
|
||||
def q_validator(text):
|
||||
if not text and allow_empty:
|
||||
return True
|
||||
if not text:
|
||||
return f"{input_type.capitalize()} cannot be empty"
|
||||
is_valid, error_msg = validator(text)
|
||||
if is_valid:
|
||||
return True
|
||||
return error_msg
|
||||
|
||||
while attempt < self.MAX_ATTEMPTS:
|
||||
try:
|
||||
user_input = console.input(f"[{UIColors.HIGHLIGHT}]{prompt}:[/{UIColors.HIGHLIGHT}] ")
|
||||
|
||||
if not user_input.strip():
|
||||
if allow_empty:
|
||||
return ""
|
||||
UIFormatter.print_error(f"{input_type.capitalize()} cannot be empty")
|
||||
attempt += 1
|
||||
self._show_retry_message(attempt)
|
||||
continue
|
||||
|
||||
is_valid, error_msg = validator(user_input)
|
||||
|
||||
if is_valid:
|
||||
return user_input.strip()
|
||||
|
||||
UIFormatter.print_error(error_msg)
|
||||
attempt += 1
|
||||
self._show_retry_message(attempt)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
UIFormatter.print_info("\nSetup cancelled by user.")
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
UIFormatter.print_error(f"Unexpected error: {str(e)}")
|
||||
attempt += 1
|
||||
self._show_retry_message(attempt)
|
||||
|
||||
UIFormatter.print_error(f"Maximum attempts reached for {input_type}. Exiting.")
|
||||
sys.exit(1)
|
||||
|
||||
def _show_retry_message(self, attempt: int, max_attempts: int = None) -> None:
|
||||
max_attempts = max_attempts or self.MAX_ATTEMPTS
|
||||
if attempt < max_attempts:
|
||||
console.print(f"[{UIColors.MUTED}]Please try again ({attempt}/{max_attempts}).[/{UIColors.MUTED}]")
|
||||
result = questionary.text(prompt, validate=q_validator).ask()
|
||||
if result is None:
|
||||
raise KeyboardInterrupt
|
||||
return result
|
||||
|
||||
def get_app_names(self) -> list[str]:
|
||||
console.print(
|
||||
f"[{UIColors.MUTED}]Enter app names separated by commas(e.g. users, products, orders)[/{UIColors.MUTED}]"
|
||||
)
|
||||
user_input = questionary.text(
|
||||
"Enter app names (comma-separated or single, e.g. users, products, orders):",
|
||||
validate=lambda text: True if text.strip() else "At least one app name is required",
|
||||
).ask()
|
||||
|
||||
user_input = console.input(
|
||||
f"[{UIColors.HIGHLIGHT}]Enter app names (comma-separated or single):[/{UIColors.HIGHLIGHT}] "
|
||||
)
|
||||
|
||||
if not user_input.strip():
|
||||
UIFormatter.print_error("At least one app name is required")
|
||||
return self.get_app_names()
|
||||
if user_input is None:
|
||||
raise KeyboardInterrupt
|
||||
|
||||
if "," in user_input:
|
||||
return self._parse_comma_separated_apps(user_input)
|
||||
@@ -120,76 +88,73 @@ class InputCollector:
|
||||
return app_list
|
||||
|
||||
def _get_project_directory(self) -> str | None:
|
||||
attempt = 0
|
||||
def validate_dir(text):
|
||||
if not text or text == ".":
|
||||
return True
|
||||
|
||||
is_valid, error_msg = validate_project_name(text)
|
||||
if not is_valid:
|
||||
return error_msg
|
||||
|
||||
if os.path.exists(text):
|
||||
return f"Directory '{text}' already exists."
|
||||
|
||||
return True
|
||||
|
||||
while attempt < self.MAX_ATTEMPTS:
|
||||
try:
|
||||
user_input = console.input(
|
||||
f"[{UIColors.HIGHLIGHT}]Enter project directory name:[/{UIColors.HIGHLIGHT}] "
|
||||
).strip()
|
||||
project_dir = questionary.text(
|
||||
"Enter project directory name:",
|
||||
validate=validate_dir
|
||||
).ask()
|
||||
|
||||
if not user_input or user_input == ".":
|
||||
UIFormatter.print_info(f"Creating project in current directory: {os.getcwd()}")
|
||||
return "."
|
||||
if project_dir is None:
|
||||
raise KeyboardInterrupt
|
||||
|
||||
is_valid, error_msg = validate_project_name(user_input)
|
||||
if not is_valid:
|
||||
UIFormatter.print_error(error_msg)
|
||||
attempt += 1
|
||||
self._show_retry_message(attempt)
|
||||
continue
|
||||
if not project_dir or project_dir == ".":
|
||||
UIFormatter.print_info(f"Creating project in current directory: {os.getcwd()}")
|
||||
return "."
|
||||
|
||||
return project_dir
|
||||
|
||||
if os.path.exists(user_input):
|
||||
UIFormatter.print_error(f"Directory '{user_input}' already exists.")
|
||||
UIFormatter.print_info(
|
||||
"Please choose a different project directory name (or press Enter for current directory)."
|
||||
)
|
||||
attempt += 1
|
||||
self._show_retry_message(attempt)
|
||||
continue
|
||||
|
||||
return user_input
|
||||
|
||||
except KeyboardInterrupt:
|
||||
UIFormatter.print_info("\nSetup cancelled by user.")
|
||||
sys.exit(0)
|
||||
|
||||
return None
|
||||
def _get_selection(self, message: str, choices: list[tuple[str, str]], default: str = None) -> str:
|
||||
q_choices = [questionary.Choice(title, value=value) for title, value in choices]
|
||||
default_choice = next((c for c in q_choices if c.value == default), q_choices[0])
|
||||
|
||||
result = questionary.select(
|
||||
message,
|
||||
choices=q_choices,
|
||||
default=default_choice,
|
||||
).ask()
|
||||
|
||||
if result is None:
|
||||
raise KeyboardInterrupt
|
||||
|
||||
return result
|
||||
|
||||
def get_cicd_choice(self) -> Tuple[bool, bool]:
|
||||
UIFormatter.print_separator()
|
||||
console.print(f"\n[{UIColors.INFO}]CI/CD Pipeline[/{UIColors.INFO}]\n")
|
||||
console.print()
|
||||
choices = [
|
||||
("None (skip CI/CD)", "none"),
|
||||
("GitHub Actions only", "github"),
|
||||
("GitLab CI only", "gitlab"),
|
||||
("Both (GitHub Actions + GitLab CI)", "both"),
|
||||
]
|
||||
|
||||
for option in CICDOption:
|
||||
console.print(f" [{UIColors.SUCCESS}]{option.key}[/{UIColors.SUCCESS}] {option.description}")
|
||||
console.print()
|
||||
|
||||
valid_keys = [option.key for option in CICDOption]
|
||||
|
||||
choice = CharReader.get_cicd_choice(
|
||||
f"[{UIColors.HIGHLIGHT}]Your choice: [/{UIColors.HIGHLIGHT}]",
|
||||
valid_keys=valid_keys,
|
||||
default=CICDOption.NONE.key,
|
||||
)
|
||||
choice = self._get_selection("Select CI/CD pipeline:", choices, default="none")
|
||||
|
||||
choice_map = {
|
||||
CICDOption.BOTH.key: (True, True),
|
||||
CICDOption.GITHUB.key: (True, False),
|
||||
CICDOption.GITLAB.key: (False, True),
|
||||
"both": (True, True),
|
||||
"github": (True, False),
|
||||
"gitlab": (False, True),
|
||||
"none": (False, False),
|
||||
}
|
||||
return choice_map.get(choice, (False, False))
|
||||
|
||||
def get_nested_apps_config(self) -> Tuple[bool, str | None]:
|
||||
UIFormatter.print_separator()
|
||||
console.print(
|
||||
f"[{UIColors.MUTED}]Do you want to place apps inside a package directory (e.g. 'src/')?[/{UIColors.MUTED}]"
|
||||
choice = UIFormatter.confirm(
|
||||
"Do you want to place apps inside a package directory (e.g. 'src/')?",
|
||||
default=False
|
||||
)
|
||||
console.print()
|
||||
|
||||
choice = CharReader.get_yes_no(f"[{UIColors.HIGHLIGHT}]Nested Django apps? (y/N):[/{UIColors.HIGHLIGHT}]")
|
||||
|
||||
if choice != "y":
|
||||
if not choice:
|
||||
return False, None
|
||||
|
||||
dir_name = self.get_validated_input(
|
||||
@@ -200,262 +165,66 @@ class InputCollector:
|
||||
return True, dir_name
|
||||
|
||||
def get_database_config_choice(self) -> bool:
|
||||
UIFormatter.print_separator()
|
||||
console.print()
|
||||
console.print(f" [{UIColors.SUCCESS}]Y[/{UIColors.SUCCESS}] Use DATABASE_URL (recommended for production)")
|
||||
console.print(f" [{UIColors.SUCCESS}]N[/{UIColors.SUCCESS}] Use individual database parameters")
|
||||
console.print()
|
||||
|
||||
return CharReader.get_yes_no(f"[{UIColors.HIGHLIGHT}]Use DATABASE_URL? (Y/n):[/{UIColors.HIGHLIGHT}]") == "y"
|
||||
|
||||
def get_database_type_choice(self) -> str:
|
||||
console.print()
|
||||
console.print(f"[{UIColors.INFO}]Select Database Type[/{UIColors.INFO}]\n")
|
||||
console.print(f" [{UIColors.SUCCESS}]1[/{UIColors.SUCCESS}] PostgreSQL")
|
||||
console.print(f" [{UIColors.SUCCESS}]2[/{UIColors.SUCCESS}] MySQL")
|
||||
console.print()
|
||||
|
||||
choice = CharReader.get_validated_char_input(
|
||||
f"[{UIColors.HIGHLIGHT}]Choose database (1/2):[/{UIColors.HIGHLIGHT}]",
|
||||
valid_keys=["1", "2"],
|
||||
default=None,
|
||||
error_message="Invalid input '{response}'. Please enter '1' or '2'.",
|
||||
return UIFormatter.confirm(
|
||||
"Use DATABASE_URL? (recommended for production)",
|
||||
default=True
|
||||
)
|
||||
|
||||
return "mysql" if choice == "2" else "postgresql"
|
||||
def get_database_type_choice(self) -> str:
|
||||
choices = [
|
||||
("PostgreSQL", "postgresql"),
|
||||
("MySQL", "mysql"),
|
||||
]
|
||||
|
||||
return self._get_selection("Choose database:", choices, default="postgresql")
|
||||
|
||||
def _get_structure_metadata(
|
||||
self,
|
||||
project_dir: str,
|
||||
predefined: bool = False,
|
||||
unified: bool = False,
|
||||
single: bool = False,
|
||||
single_module_name: str = None,
|
||||
database_type: str = "postgresql",
|
||||
use_database_url: bool = True,
|
||||
use_github: bool = False,
|
||||
use_gitlab: bool = False,
|
||||
) -> Tuple[str, str, list[str], dict]:
|
||||
def _get_structure_metadata(self, options: StructureOptions) -> Tuple[str, str, list[str], dict]:
|
||||
"""Helper method to generate metadata dictionary."""
|
||||
project_dir = options["project_dir"]
|
||||
project_name = project_dir
|
||||
app_names: list[str] = []
|
||||
|
||||
# Default package_name to "backend" if project_dir is "." or empty
|
||||
package_name = get_package_name(project_dir)
|
||||
|
||||
project_module_name = "config" if predefined else "core" if unified else None
|
||||
project_module_name = "config" if options["predefined"] else "core" if options["unified"] else None
|
||||
|
||||
if single:
|
||||
project_module_name = single_module_name or "project"
|
||||
if options["single"]:
|
||||
project_module_name = options["single_module_name"] or "project"
|
||||
|
||||
metadata = ProjectMetadata(
|
||||
package_name=package_name,
|
||||
use_github_actions=use_github,
|
||||
use_gitlab_ci=use_gitlab,
|
||||
nested_apps=True,
|
||||
nested_dir="apps",
|
||||
use_database_url=use_database_url,
|
||||
database_type=database_type,
|
||||
predefined_structure=predefined,
|
||||
unified_structure=unified,
|
||||
single_structure=single,
|
||||
use_github_actions=options["use_github"],
|
||||
use_gitlab_ci=options["use_gitlab"],
|
||||
nested_apps=not options["single"],
|
||||
nested_dir="apps" if not options["single"] else None,
|
||||
use_database_url=options["use_database_url"],
|
||||
database_type=options["database_type"],
|
||||
predefined_structure=options["predefined"],
|
||||
unified_structure=options["unified"],
|
||||
single_structure=options["single"],
|
||||
project_module_name=project_module_name,
|
||||
)
|
||||
|
||||
return project_name, "", app_names, metadata.to_dict()
|
||||
|
||||
|
||||
class CharReader:
|
||||
MAX_ATTEMPTS = 3
|
||||
|
||||
@staticmethod
|
||||
def get_char() -> str:
|
||||
if os.name == "nt":
|
||||
return CharReader._get_char_windows()
|
||||
return CharReader._get_char_unix()
|
||||
|
||||
@staticmethod
|
||||
def _is_enter_key(response: str) -> bool:
|
||||
"""Check if response represents Enter key or control character."""
|
||||
return not response or response in ("\r", "\n") or ord(response) < 32
|
||||
|
||||
@staticmethod
|
||||
def _get_char_input(prompt: str) -> str:
|
||||
console.print(f"{prompt} ", end="")
|
||||
sys.stdout.flush()
|
||||
return CharReader().get_char()
|
||||
|
||||
@staticmethod
|
||||
def _handle_default(default: str) -> str:
|
||||
console.print(default.upper())
|
||||
return default.lower()
|
||||
|
||||
@staticmethod
|
||||
def get_validated_char_input(
|
||||
prompt: str,
|
||||
valid_keys: list[str],
|
||||
default: str | None,
|
||||
error_message: str,
|
||||
) -> str:
|
||||
attempt = 0
|
||||
valid_keys_lower = [k.lower() for k in valid_keys]
|
||||
|
||||
while attempt < CharReader.MAX_ATTEMPTS:
|
||||
try:
|
||||
response = CharReader._get_char_input(prompt)
|
||||
|
||||
# Handle Enter key (default)
|
||||
if CharReader._is_enter_key(response):
|
||||
if default is not None:
|
||||
return CharReader._handle_default(default)
|
||||
# If default is None, treat as invalid input (fall through)
|
||||
|
||||
response = response.lower()
|
||||
|
||||
# Validate response
|
||||
if response in valid_keys_lower:
|
||||
console.print(response.upper())
|
||||
return response
|
||||
|
||||
# Invalid input - show error
|
||||
console.print()
|
||||
if response.isprintable():
|
||||
UIFormatter.print_error(error_message.format(response=response))
|
||||
attempt += 1
|
||||
if attempt < CharReader.MAX_ATTEMPTS:
|
||||
console.print(
|
||||
f"[{UIColors.MUTED}]Please try again ({attempt}/{CharReader.MAX_ATTEMPTS}).[/{UIColors.MUTED}]"
|
||||
)
|
||||
console.print()
|
||||
else:
|
||||
# Control character entered (e.g. Enter with no default)
|
||||
if default is not None:
|
||||
return CharReader._handle_default(default)
|
||||
|
||||
UIFormatter.print_error("Input required.")
|
||||
attempt += 1
|
||||
if attempt < CharReader.MAX_ATTEMPTS:
|
||||
console.print(
|
||||
f"[{UIColors.MUTED}]Please try again ({attempt}/{CharReader.MAX_ATTEMPTS}).[/{UIColors.MUTED}]"
|
||||
)
|
||||
console.print()
|
||||
|
||||
except KeyboardInterrupt:
|
||||
console.print()
|
||||
UIFormatter.print_info("\nOperation cancelled by user.")
|
||||
raise
|
||||
|
||||
# Max attempts reached
|
||||
console.print()
|
||||
if default is not None:
|
||||
UIFormatter.print_warning(f"Maximum attempts reached. Using default: '{default.upper()}'.")
|
||||
return default.lower()
|
||||
|
||||
UIFormatter.print_error("Maximum attempts reached. Exiting.")
|
||||
sys.exit(1)
|
||||
|
||||
@staticmethod
|
||||
def get_yes_no(prompt: str, default: str = None) -> str:
|
||||
if default is None:
|
||||
if "(Y/n)" in prompt or "(Y/N)" in prompt:
|
||||
default = "y"
|
||||
elif "(y/N)" in prompt:
|
||||
default = "n"
|
||||
else:
|
||||
default = "y"
|
||||
|
||||
return CharReader.get_validated_char_input(
|
||||
prompt=prompt,
|
||||
valid_keys=["y", "n"],
|
||||
default=default,
|
||||
error_message="Invalid input '{response}'. Please enter 'y' or 'n'.",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_cicd_choice(prompt: str, valid_keys: list[str], default: str = "n") -> str:
|
||||
valid_keys_str = ", ".join(f"'{k}'" for k in valid_keys)
|
||||
return CharReader.get_validated_char_input(
|
||||
prompt=prompt,
|
||||
valid_keys=valid_keys,
|
||||
default=default,
|
||||
error_message=f"Invalid input '{{response}}'. Please enter one of: {valid_keys_str}.",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_structure_choice() -> str:
|
||||
"""Get structure type choice without requiring Enter key."""
|
||||
return CharReader.get_validated_char_input(
|
||||
prompt=f"[{UIColors.HIGHLIGHT}]Choose structure type (1/2/3/4) [default: 1]:[/{UIColors.HIGHLIGHT}]",
|
||||
valid_keys=["1", "2", "3", "4"],
|
||||
default="1",
|
||||
error_message="Invalid input '{response}'. Please enter '1', '2', '3', or '4'.",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_char_windows() -> str:
|
||||
import msvcrt
|
||||
|
||||
encodings = ["utf-8", "latin1"]
|
||||
|
||||
for encoding in encodings:
|
||||
try:
|
||||
ch = msvcrt.getch()
|
||||
if isinstance(ch, bytes):
|
||||
if ch == b"\x03":
|
||||
raise KeyboardInterrupt
|
||||
decoded = ch.decode(encoding).lower()
|
||||
else:
|
||||
if ord(ch) == 3: # Ctrl+C
|
||||
raise KeyboardInterrupt
|
||||
decoded = ch.lower()
|
||||
return decoded
|
||||
except (UnicodeDecodeError, AttributeError):
|
||||
continue
|
||||
|
||||
# Fallback
|
||||
try:
|
||||
ch = msvcrt.getch()
|
||||
if isinstance(ch, bytes):
|
||||
if ch == b"\x03":
|
||||
raise KeyboardInterrupt
|
||||
return ch.decode("utf-8", errors="ignore").lower()
|
||||
else:
|
||||
if ord(ch) == 3: # Ctrl+C
|
||||
raise KeyboardInterrupt
|
||||
return ch.lower()
|
||||
except KeyboardInterrupt:
|
||||
raise
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _get_char_unix() -> str:
|
||||
fd = sys.stdin.fileno()
|
||||
old_settings = termios.tcgetattr(fd)
|
||||
try:
|
||||
tty.setraw(sys.stdin.fileno())
|
||||
ch = sys.stdin.read(1)
|
||||
if ord(ch) == 3: # Ctrl+C
|
||||
raise KeyboardInterrupt
|
||||
return ch.lower()
|
||||
finally:
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
||||
|
||||
|
||||
def get_user_input() -> Tuple[str, str, str, list, dict]:
|
||||
try:
|
||||
collector = InputCollector()
|
||||
console.print()
|
||||
|
||||
UIFormatter.print_separator()
|
||||
console.print(f"\n[{UIColors.INFO}]Choose Structure Type[/{UIColors.INFO}]\n")
|
||||
console.print(f"[{UIColors.MUTED}]1. Standard structure (default Django layout)[/{UIColors.MUTED}]")
|
||||
console.print(f"[{UIColors.MUTED}]2. Predefined structure (apps/users, apps/core, api/)[/{UIColors.MUTED}]")
|
||||
console.print(f"[{UIColors.MUTED}]3. Unified structure (core/, apps/core, apps/api)[/{UIColors.MUTED}]")
|
||||
console.print(f"[{UIColors.MUTED}]4. Single folder layout (everything in one folder)[/{UIColors.MUTED}]")
|
||||
console.print()
|
||||
structure_choices = [
|
||||
("Standard structure (default Django layout)", "1"),
|
||||
("Predefined structure (apps/users, apps/core, api/)", "2"),
|
||||
("Unified structure (core/, apps/core, apps/api)", "3"),
|
||||
("Single folder layout (everything in one folder)", "4"),
|
||||
]
|
||||
|
||||
structure_choice = CharReader.get_structure_choice()
|
||||
structure_choice = collector._get_selection(
|
||||
"Choose structure type:",
|
||||
structure_choices,
|
||||
default="1"
|
||||
)
|
||||
|
||||
use_standard = structure_choice == "1"
|
||||
use_predefined = structure_choice == "2"
|
||||
@@ -463,26 +232,20 @@ def get_user_input() -> Tuple[str, str, str, list, dict]:
|
||||
use_single = structure_choice == "4"
|
||||
|
||||
# Step 1: Project Setup
|
||||
UIFormatter.print_separator()
|
||||
console.print(f"\n[{UIColors.INFO}]Step 1: Project Setup[/{UIColors.INFO}]\n")
|
||||
console.print(f"[{UIColors.MUTED}]Press Enter or enter '.' to create in current directory[/{UIColors.MUTED}]")
|
||||
|
||||
project_dir = collector._get_project_directory()
|
||||
if project_dir is None:
|
||||
UIFormatter.print_error("Maximum attempts reached for project directory name. Exiting.")
|
||||
# Should be handled by questionary loop, but just in case
|
||||
sys.exit(1)
|
||||
|
||||
project_name = project_dir
|
||||
single_module_name = None
|
||||
|
||||
if use_standard:
|
||||
console.print()
|
||||
console.print(f"[{UIColors.MUTED}]Common names: config, core, settings[/{UIColors.MUTED}]")
|
||||
project_name = collector.get_validated_input(
|
||||
"Enter Django project name", validate_project_name, "Django project name"
|
||||
)
|
||||
elif use_single:
|
||||
console.print()
|
||||
console.print(f"[{UIColors.MUTED}]Common names: project, core, app[/{UIColors.MUTED}]")
|
||||
single_module_name = (
|
||||
collector.get_validated_input(
|
||||
@@ -495,9 +258,6 @@ def get_user_input() -> Tuple[str, str, str, list, dict]:
|
||||
)
|
||||
|
||||
# Step 2: Database Configuration
|
||||
console.print()
|
||||
UIFormatter.print_separator()
|
||||
console.print(f"\n[{UIColors.INFO}]Step 2: Database Configuration[/{UIColors.INFO}]")
|
||||
database_type = collector.get_database_type_choice()
|
||||
use_database_url = collector.get_database_config_choice()
|
||||
|
||||
@@ -507,15 +267,10 @@ def get_user_input() -> Tuple[str, str, str, list, dict]:
|
||||
app_names = []
|
||||
|
||||
if use_standard:
|
||||
console.print()
|
||||
UIFormatter.print_separator()
|
||||
console.print(f"\n[{UIColors.INFO}]Step 3: Django Apps[/{UIColors.INFO}]\n")
|
||||
nested, nested_dir = collector.get_nested_apps_config()
|
||||
app_names = collector.get_app_names()
|
||||
|
||||
# Step 3/4: CI/CD Pipeline
|
||||
step_num = 4 if use_standard else 3
|
||||
console.print(f"\n[{UIColors.INFO}]Step {step_num}: CI/CD Pipeline[/{UIColors.INFO}]")
|
||||
use_github, use_gitlab = collector.get_cicd_choice()
|
||||
|
||||
# Generate Metadata
|
||||
@@ -532,8 +287,8 @@ def get_user_input() -> Tuple[str, str, str, list, dict]:
|
||||
)
|
||||
return project_dir, project_name, app_names[0], app_names, metadata.to_dict()
|
||||
else:
|
||||
project_name, primary_app, app_names, metadata_dict = collector._get_structure_metadata(
|
||||
project_dir,
|
||||
options = StructureOptions(
|
||||
project_dir=project_dir,
|
||||
predefined=use_predefined,
|
||||
unified=use_unified,
|
||||
single=use_single,
|
||||
@@ -543,6 +298,7 @@ def get_user_input() -> Tuple[str, str, str, list, dict]:
|
||||
use_github=use_github,
|
||||
use_gitlab=use_gitlab,
|
||||
)
|
||||
project_name, primary_app, app_names, metadata_dict = collector._get_structure_metadata(options)
|
||||
return project_dir, project_name, primary_app, app_names, metadata_dict
|
||||
|
||||
except KeyboardInterrupt:
|
||||
@@ -576,8 +332,7 @@ def confirm_setup(project_dir: str, project_name: str, app_names: list, metadata
|
||||
console.print()
|
||||
|
||||
try:
|
||||
response = CharReader.get_yes_no(f"[{UIColors.WARNING}]Proceed with setup? (y/N):[/{UIColors.WARNING}]")
|
||||
return response == "y"
|
||||
return UIFormatter.confirm("Proceed with setup?", default=True)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
UIFormatter.print_info("\nSetup cancelled by user.")
|
||||
@@ -591,7 +346,3 @@ def _get_cicd_display(metadata: dict) -> str:
|
||||
if metadata.get("use_gitlab_ci", False):
|
||||
choices.append("GitLab CI")
|
||||
return ", ".join(choices) if choices else "None"
|
||||
|
||||
|
||||
get_char = CharReader.get_char
|
||||
_get_validated_input = InputCollector().get_validated_input
|
||||
@@ -278,3 +278,25 @@ def detect_nested_structure_from_settings(
|
||||
pass
|
||||
|
||||
return False, None, search_dir
|
||||
|
||||
|
||||
def get_djinit_config(project_root: str = None) -> dict | None:
|
||||
"""Read .djinit configuration file."""
|
||||
import json
|
||||
|
||||
if project_root is None:
|
||||
project_root = os.getcwd()
|
||||
|
||||
config_path = os.path.join(project_root, ".djinit")
|
||||
if not os.path.exists(config_path):
|
||||
# Try finding it in parent directories
|
||||
parent = os.path.dirname(project_root)
|
||||
if parent and parent != project_root:
|
||||
return get_djinit_config(parent)
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(config_path) as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return None
|
||||
@@ -38,22 +38,6 @@ def display_secret_keys(keys: list[str]) -> None:
|
||||
console.print()
|
||||
|
||||
|
||||
def generate_secret_command():
|
||||
if __name__ == "__main__":
|
||||
keys = generate_multiple_keys(3, 50)
|
||||
display_secret_keys(keys)
|
||||
|
||||
instructions = Text()
|
||||
instructions.append("📋 Usage Instructions:\n", style=UIColors.ACCENT)
|
||||
instructions.append("1. Copy the appropriate secret key for your environment\n")
|
||||
instructions.append("2. Add it to your .env file:\n")
|
||||
instructions.append(" SECRET_KEY=your_secret_key_here\n", style=UIColors.CODE)
|
||||
instructions.append("3. Or set it as an environment variable:\n")
|
||||
instructions.append(" export SECRET_KEY=your_secret_key_here\n", style=UIColors.CODE)
|
||||
instructions.append("4. Never commit secret keys to version control!\n", style=UIColors.WARNING)
|
||||
|
||||
console.print(Panel(instructions, title="💡 How to Use", border_style="blue"))
|
||||
console.print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
generate_secret_command()
|
||||
+2
-1
@@ -10,7 +10,7 @@ name = "djinitx"
|
||||
version = "0.3.0"
|
||||
description = "A CLI tool to setup Django project"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
requires-python = ">=3.10"
|
||||
authors = [
|
||||
{ name = "Sankalp Tharu", email = "sankalptharu50028@gmail.com" }
|
||||
]
|
||||
@@ -52,6 +52,7 @@ dependencies = [
|
||||
"ruff>=0.14.2",
|
||||
"jinja2>=3.1.0",
|
||||
"pre-commit>=4.5.0",
|
||||
"questionary>=2.0.1",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
@@ -49,6 +49,7 @@ dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "pre-commit" },
|
||||
{ name = "questionary" },
|
||||
{ name = "rich" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
@@ -58,6 +59,7 @@ requires-dist = [
|
||||
{ name = "click", specifier = ">=8.3.0" },
|
||||
{ name = "jinja2", specifier = ">=3.1.0" },
|
||||
{ name = "pre-commit", specifier = ">=4.5.0" },
|
||||
{ name = "questionary", specifier = ">=2.0.1" },
|
||||
{ name = "rich", specifier = ">=14.2.0" },
|
||||
{ name = "ruff", specifier = ">=0.14.2" },
|
||||
]
|
||||
@@ -199,6 +201,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/c4/b2d28e9d2edf4f1713eb3c29307f1a63f3d67cf09bdda29715a36a68921a/pre_commit-4.5.0-py2.py3-none-any.whl", hash = "sha256:25e2ce09595174d9c97860a95609f9f852c0614ba602de3561e267547f2335e1", size = 226429, upload-time = "2025-11-22T21:02:40.836Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prompt-toolkit"
|
||||
version = "3.0.52"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "wcwidth" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.19.2"
|
||||
@@ -244,6 +258,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "questionary"
|
||||
version = "2.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "prompt-toolkit" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "14.2.0"
|
||||
@@ -296,3 +322,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wcwidth"
|
||||
version = "0.2.14"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293, upload-time = "2025-09-22T16:29:53.023Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" },
|
||||
]
|
||||
Reference in new issue
Block a user