diff --git a/djinit/cli.py b/djinit/cli.py index a6773c3..bf648fe 100644 --- a/djinit/cli.py +++ b/djinit/cli.py @@ -22,7 +22,11 @@ class Cli: self.primary_app = primary_app self.app_names = app_names self.metadata = metadata - self.project_root = os.path.join(os.getcwd(), project_dir) + # Handle '.' for current directory + if project_dir == ".": + self.project_root = os.getcwd() + else: + self.project_root = os.path.join(os.getcwd(), project_dir) # Initialize managers self.project_manager = ProjectManager(project_dir, project_name, app_names, metadata) diff --git a/djinit/scripts/app_generator.py b/djinit/scripts/app_generator.py index 117a30d..10e2296 100644 --- a/djinit/scripts/app_generator.py +++ b/djinit/scripts/app_generator.py @@ -5,6 +5,7 @@ Handles creation of Django apps and updating settings. import os import subprocess +import sys from typing import Optional from djinit.scripts.console_ui import UIFormatter @@ -61,8 +62,9 @@ class AppManager: return False # Create app in the correct location + # Use 'python -m django' instead of 'django-admin' for better compatibility with change_cwd(apps_base_dir): - subprocess.run(["django-admin", "startapp", self.app_name], capture_output=True, text=True, check=True) + subprocess.run([sys.executable, "-m", "django", "startapp", self.app_name], capture_output=True, text=True, check=True) UIFormatter.print_success(f"Created Django app '{self.app_name}' in {apps_base_dir}") return True diff --git a/djinit/scripts/input_handler.py b/djinit/scripts/input_handler.py index a245c76..22aa8ac 100644 --- a/djinit/scripts/input_handler.py +++ b/djinit/scripts/input_handler.py @@ -183,11 +183,14 @@ class InputCollector: console.print(f" [{UIColors.SUCCESS}]{option.key}[/{UIColors.SUCCESS}] {option.description}") console.print() - console.print(f"[{UIColors.HIGHLIGHT}]Your choice: [/{UIColors.HIGHLIGHT}]", end="") - sys.stdout.flush() - - choice = self.char_reader.get_char() - console.print(choice.upper()) + # Get valid keys from CICDOption enum + 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 + ) # Parse choice if choice == CICDOption.BOTH.key: @@ -196,7 +199,7 @@ class InputCollector: return True, False elif choice == CICDOption.GITLAB.key: return False, True - else: # Default to none for any unrecognized input + else: # Default to none (should not happen due to validation, but kept as fallback) return False, False def get_nested_apps_config(self) -> Tuple[bool, str | None]: @@ -211,10 +214,10 @@ class InputCollector: ) console.print() - console.print(f"[{UIColors.HIGHLIGHT}]Nested Django apps? (y/N):[/{UIColors.HIGHLIGHT}] ", end="") - sys.stdout.flush() - choice = self.char_reader.get_char() - console.print(choice.upper()) + choice = CharReader.get_yes_no( + f"[{UIColors.HIGHLIGHT}]Nested Django apps? (y/N):[/{UIColors.HIGHLIGHT}]", + default="n" + ) if choice != "y": return False, None @@ -240,13 +243,13 @@ class InputCollector: console.print(f"[{UIColors.MUTED}]postgres://user:password@host:port/database[/{UIColors.MUTED}]") console.print() - console.print(f"[{UIColors.HIGHLIGHT}]Use DATABASE_URL? (Y/n):[/{UIColors.HIGHLIGHT}] ", end="") - sys.stdout.flush() - choice = self.char_reader.get_char() - console.print(choice.upper()) + choice = CharReader.get_yes_no( + f"[{UIColors.HIGHLIGHT}]Use DATABASE_URL? (Y/n):[/{UIColors.HIGHLIGHT}]", + default="y" + ) - # Default to True (use DATABASE_URL) if user presses Enter or Y - return choice.lower() != "n" + # Return True if choice is 'y', False if 'n' + return choice == "y" class CharReader: @@ -258,6 +261,117 @@ class CharReader: return CharReader._get_char_windows() return CharReader._get_char_unix() + @staticmethod + def get_yes_no(prompt: str, default: str = "n") -> str: + max_attempts = 3 + attempt = 0 + + while attempt < max_attempts: + try: + console.print(f"{prompt} ", end="") + sys.stdout.flush() + + reader = CharReader() + response = reader.get_char() + + # Handle Enter key (default) - in raw mode, Enter is '\r' (carriage return) + # Also handle empty string or other control characters as Enter + if not response or response == '\r' or response == '\n' or ord(response) < 32: + console.print(default.upper()) + return default.lower() + + response = response.lower() + + # Validate response + if response in ('y', 'n'): + console.print(response.upper()) + return response + + # Invalid input - show error (only show if it's a printable character) + console.print() + if response.isprintable(): + UIFormatter.print_error(f"Invalid input '{response}'. Please enter 'y' or 'n'.") + attempt += 1 + if attempt < max_attempts: + console.print(f"[{UIColors.MUTED}]Please try again ({attempt}/{max_attempts}).[/{UIColors.MUTED}]") + console.print() + else: + # Control character entered, treat as Enter + console.print(default.upper()) + return default.lower() + + except KeyboardInterrupt: + console.print() + UIFormatter.print_info("\nOperation cancelled by user.") + raise + + # Max attempts reached - use default + console.print() + UIFormatter.print_warning(f"Maximum attempts reached. Using default: '{default.upper()}'.") + return default.lower() + + @staticmethod + def get_cicd_choice(prompt: str, valid_keys: list[str], default: str = "n") -> str: + """ + Get CI/CD choice input with validation and retries. + + Args: + prompt: The prompt message to display + valid_keys: List of valid key characters (e.g., ['g', 'l', 'b', 'n']) + default: Default value if Enter is pressed + + Returns: + Valid key (lowercase) + """ + max_attempts = 3 + attempt = 0 + valid_keys_lower = [k.lower() for k in valid_keys] + + while attempt < max_attempts: + try: + console.print(f"{prompt} ", end="") + sys.stdout.flush() + + reader = CharReader() + response = reader.get_char() + + # Handle Enter key (default) - in raw mode, Enter is '\r' (carriage return) + # Also handle empty string or other control characters as Enter + if not response or response == '\r' or response == '\n' or ord(response) < 32: + console.print(default.upper()) + return default.lower() + + response = response.lower() + + # Validate response + if response in valid_keys_lower: + console.print(response.upper()) + return response + + # Invalid input - show error (only show if it's a printable character) + console.print() + if response.isprintable(): + valid_keys_str = ", ".join(f"'{k}'" for k in valid_keys) + UIFormatter.print_error(f"Invalid input '{response}'. Please enter one of: {valid_keys_str}.") + attempt += 1 + if attempt < max_attempts: + console.print(f"[{UIColors.MUTED}]Please try again ({attempt}/{max_attempts}).[/{UIColors.MUTED}]") + console.print() + else: + # Control character entered, treat as Enter + console.print(default.upper()) + return default.lower() + + except KeyboardInterrupt: + console.print() + UIFormatter.print_info("\nOperation cancelled by user.") + raise + + # Max attempts reached - use default + console.print() + UIFormatter.print_warning(f"Maximum attempts reached. Using default: '{default.upper()}'.") + return default.lower() + @staticmethod def _get_char_windows() -> str: import msvcrt @@ -299,17 +413,49 @@ def get_user_input() -> Tuple[str, str, str, list, dict]: console.print(f"[{UIColors.HIGHLIGHT}]Project Directory[/{UIColors.HIGHLIGHT}]") console.print(f"[{UIColors.MUTED}]Where your project files will be created[/{UIColors.MUTED}]") - project_dir = collector.get_validated_input( - "Enter project directory name", validate_project_name, "project directory name" - ) - - # Check if directory exists and ask for alternative - while os.path.exists(project_dir): - UIFormatter.print_error(f"Directory '{project_dir}' already exists.") - UIFormatter.print_info("Please choose a different project directory name.") - project_dir = collector.get_validated_input( - "Enter project directory name", validate_project_name, "project directory name" - ) + console.print(f"[{UIColors.MUTED}]Press Enter or enter '.' to create in current directory[/{UIColors.MUTED}]") + + # Get project directory with special handling for '.' and empty input + attempt = 0 + project_dir = None + + while attempt < collector.MAX_ATTEMPTS and project_dir is None: + try: + user_input = console.input(f"[{UIColors.HIGHLIGHT}]Enter project directory name:[/{UIColors.HIGHLIGHT}] ").strip() + + # Handle empty input or '.' - use current directory + if not user_input or user_input == ".": + project_dir = "." + UIFormatter.print_info(f"Creating project in current directory: {os.getcwd()}") + break + + # Validate the input + is_valid, error_msg = validate_project_name(user_input) + if not is_valid: + UIFormatter.print_error(error_msg) + attempt += 1 + collector._show_retry_message(attempt) + continue + + # Check if directory exists and ask for alternative + 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 + collector._show_retry_message(attempt) + continue + + project_dir = user_input + break + + except KeyboardInterrupt: + UIFormatter.print_info("\nSetup cancelled by user.") + sys.exit(0) + + if project_dir is None: + UIFormatter.print_error("Maximum attempts reached for project directory name. Exiting.") + sys.exit(1) + console.print() console.print(f"[{UIColors.HIGHLIGHT}]Django Project Name[/{UIColors.HIGHLIGHT}]") @@ -377,13 +523,10 @@ def confirm_setup(project_dir: str, project_name: str, app_names: list, metadata # Get confirmation try: - console.print(f"[{UIColors.WARNING}]Proceed with setup? (y/N):[/{UIColors.WARNING}] ", end="") - sys.stdout.flush() - - reader = CharReader() - response = reader.get_char() - console.print(response.upper()) - + response = CharReader.get_yes_no( + f"[{UIColors.WARNING}]Proceed with setup? (y/N):[/{UIColors.WARNING}]", + default="n" + ) return response == "y" except KeyboardInterrupt: diff --git a/djinit/scripts/project.py b/djinit/scripts/project.py index c0b346c..a02e301 100644 --- a/djinit/scripts/project.py +++ b/djinit/scripts/project.py @@ -18,7 +18,11 @@ class ProjectManager: self.project_name = project_name self.app_names = app_names self.metadata = metadata - self.project_root = os.path.join(os.getcwd(), project_dir) + # Handle '.' for current directory + if project_dir == ".": + self.project_root = os.getcwd() + else: + self.project_root = os.path.join(os.getcwd(), project_dir) def create_project(self) -> bool: self._ensure_django_installed() @@ -27,8 +31,9 @@ class ProjectManager: os.makedirs(self.project_root, exist_ok=True) # Create Django project inside the directory + # Use 'python -m django' instead of 'django-admin' for better compatibility subprocess.run( - ["django-admin", "startproject", self.project_name, self.project_root], + [sys.executable, "-m", "django", "startproject", self.project_name, self.project_root], check=True, ) diff --git a/djinit/utils.py b/djinit/utils.py index ac02bbe..dfbd7c9 100644 --- a/djinit/utils.py +++ b/djinit/utils.py @@ -5,6 +5,7 @@ Contains common operations used across multiple managers. import os import subprocess +import sys from contextlib import contextmanager from djinit.scripts.console_ui import UIFormatter @@ -12,7 +13,8 @@ from djinit.scripts.console_ui import UIFormatter def format_file(filename: str) -> None: """Format Python file using Ruff formatter.""" - subprocess.run(["ruff", "format", filename], check=False, capture_output=True) + # Use 'python -m ruff' instead of 'ruff' for better compatibility + subprocess.run([sys.executable, "-m", "ruff", "format", filename], check=False, capture_output=True) def create_file_with_content(filename: str, content: str, success_message: str, should_format: bool = False) -> bool: diff --git a/pyproject.toml b/pyproject.toml index b053ef7..0c1fccc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ dependencies = [ "rich>=14.2.0", "ruff>=0.14.2", "jinja2>=3.1.0", + "django>=4.2,<6.0", ] [project.urls]