diff --git a/TODO.md b/TODO.md index 9bda3f3..9cefc9d 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/djinit/cli.py b/djinit/cli.py index 98b5a25..aaced6b 100644 --- a/djinit/cli.py +++ b/djinit/cli.py @@ -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 diff --git a/djinit/commands/handler.py b/djinit/commands/handler.py index 775af63..de45af2 100644 --- a/djinit/commands/handler.py +++ b/djinit/commands/handler.py @@ -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) diff --git a/djinit/services/app.py b/djinit/services/app.py index 3402d78..8f971a6 100644 --- a/djinit/services/app.py +++ b/djinit/services/app.py @@ -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 diff --git a/djinit/services/files.py b/djinit/services/files.py index 53c3bd4..4ce4fc0 100644 --- a/djinit/services/files.py +++ b/djinit/services/files.py @@ -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" + ) diff --git a/djinit/services/project.py b/djinit/services/project.py index 3aef59b..dbdda33 100644 --- a/djinit/services/project.py +++ b/djinit/services/project.py @@ -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) diff --git a/djinit/ui/console.py b/djinit/ui/console.py index 2a72812..cbeb8ea 100644 --- a/djinit/ui/console.py +++ b/djinit/ui/console.py @@ -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): diff --git a/djinit/ui/input.py b/djinit/ui/input.py index 20d20c0..cda3472 100644 --- a/djinit/ui/input.py +++ b/djinit/ui/input.py @@ -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 diff --git a/djinit/utils/common.py b/djinit/utils/common.py index cbc04d7..27fd4ee 100644 --- a/djinit/utils/common.py +++ b/djinit/utils/common.py @@ -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 diff --git a/djinit/utils/security.py b/djinit/utils/secretkey.py similarity index 59% rename from djinit/utils/security.py rename to djinit/utils/secretkey.py index b7abca1..10ef910 100644 --- a/djinit/utils/security.py +++ b/djinit/utils/secretkey.py @@ -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() diff --git a/pyproject.toml b/pyproject.toml index 2c6f1f6..dcd13aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/uv.lock b/uv.lock index 8413d2d..3a86b52 100644 --- a/uv.lock +++ b/uv.lock @@ -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" }, +]