Files
djinit/src/scripts/input_handler.py
T

354 lines
12 KiB
Python

"""
User input handling for Django project setup.
Handles collection and processing of user inputs during setup.
"""
import os
import sys
import termios
import tty
from dataclasses import dataclass
from enum import Enum
from typing import Callable, Tuple
from src.scripts.console_ui import UIColors, UIFormatter, console
from src.scripts.name_validator 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
@dataclass
class ProjectMetadata:
package_name: str
use_github_actions: bool = False
use_gitlab_ci: bool = False
def to_dict(self) -> dict:
"""Convert to dictionary format."""
return {
"package_name": self.package_name,
"use_github_actions": self.use_github_actions,
"use_gitlab_ci": self.use_gitlab_ci,
}
@dataclass
class ProjectSetup:
"""Complete project setup configuration."""
project_dir: str
project_name: str
primary_app: str
app_names: list[str]
metadata: ProjectMetadata
def to_tuple(self) -> Tuple[str, str, str, list, dict]:
"""Convert to legacy tuple format for backward compatibility."""
return (self.project_dir, self.project_name, self.primary_app, self.app_names, self.metadata.to_dict())
class InputCollector:
"""Handles collection of user inputs with validation."""
MAX_ATTEMPTS = 3
def __init__(self):
self.char_reader = CharReader()
def get_validated_input(
self, prompt: str, validator: Callable[[str], Tuple[bool, str]], input_type: str, allow_empty: bool = False
) -> str:
attempt = 0
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) -> None:
if attempt < self.MAX_ATTEMPTS:
console.print(f"[{UIColors.MUTED}]Please try again ({attempt}/{self.MAX_ATTEMPTS}).[/{UIColors.MUTED}]")
def get_app_names(self) -> list[str]:
try:
console.print(f"[{UIColors.HIGHLIGHT}]App Names[/{UIColors.HIGHLIGHT}]")
console.print(
f"[{UIColors.MUTED}]Enter app names separated by commas or press Enter to add one at a time[/{UIColors.MUTED}]"
)
console.print(f"[{UIColors.MUTED}]Example: users, products, orders[/{UIColors.MUTED}]")
user_input = console.input(
f"[{UIColors.HIGHLIGHT}]Enter app names (comma-separated or one at a time):[/{UIColors.HIGHLIGHT}] "
)
# Empty input - prompt for single app
if not user_input.strip():
return self._get_apps_interactive()
# Comma-separated input
if "," in user_input:
return self._parse_comma_separated_apps(user_input)
# Single app with option to add more
return self._get_apps_starting_with(user_input.strip())
except KeyboardInterrupt:
UIFormatter.print_info("\nSetup cancelled by user.")
sys.exit(0)
except Exception as e:
UIFormatter.print_error(f"Unexpected error: {str(e)}")
return self.get_app_names() # Retry
def _parse_comma_separated_apps(self, user_input: str) -> list[str]:
app_list = [app.strip() for app in user_input.split(",")]
app_list = [app for app in app_list if app] # Remove empty strings
if not app_list:
UIFormatter.print_error("At least one app name is required")
return self.get_app_names()
invalid_apps = []
for app in app_list:
is_valid, error_msg = validate_app_name(app)
if not is_valid:
invalid_apps.append((app, error_msg))
if invalid_apps:
for app, error_msg in invalid_apps:
UIFormatter.print_error(f"Invalid app name '{app}': {error_msg}")
return self.get_app_names()
return app_list
def _get_apps_starting_with(self, first_app: str) -> list[str]:
is_valid, error_msg = validate_app_name(first_app)
if not is_valid:
UIFormatter.print_error(error_msg)
return self.get_app_names()
app_names = [first_app]
app_names.extend(self._prompt_for_additional_apps())
return app_names
def _get_apps_interactive(self) -> list[str]:
app_name = self.get_validated_input("Enter at least one app name", validate_app_name, "app name")
app_names = [app_name]
app_names.extend(self._prompt_for_additional_apps())
return app_names
def _prompt_for_additional_apps(self) -> list[str]:
additional_apps = []
while True:
console.print(f"[{UIColors.HIGHLIGHT}]Add another app? (y/N):[/{UIColors.HIGHLIGHT}] ", end="")
sys.stdout.flush()
response = self.char_reader.get_char()
console.print(response.upper())
if response != "y":
break
next_app = self.get_validated_input("Enter next app name", validate_app_name, "app name")
additional_apps.append(next_app)
return additional_apps
def get_cicd_choice(self) -> Tuple[bool, bool]:
UIFormatter.print_separator()
console.print(f"\n[{UIColors.INFO}]Step 3: CI/CD Pipeline[/{UIColors.INFO}]\n")
console.print(f"[{UIColors.MUTED}]Choose CI/CD pipeline for your project[/{UIColors.MUTED}]")
console.print()
# Show options
for option in CICDOption:
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())
# Parse choice
if choice == CICDOption.BOTH.key:
return True, True
elif choice == CICDOption.GITHUB.key:
return True, False
elif choice == CICDOption.GITLAB.key:
return False, True
else: # Default to none for any unrecognized input
return False, False
class CharReader:
"""Handles single character input across platforms."""
@staticmethod
def get_char() -> str:
if os.name == "nt":
return CharReader._get_char_windows()
return CharReader._get_char_unix()
@staticmethod
def _get_char_windows() -> str:
import msvcrt
encodings = ["utf-8", "latin1"]
for encoding in encodings:
try:
ch = msvcrt.getch()
return ch.decode(encoding).lower()
except (UnicodeDecodeError, AttributeError):
continue
# Fallback
try:
return msvcrt.getch().lower()
except:
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)
return ch.lower()
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
def get_user_input() -> Tuple[str, str, str, list, dict]:
collector = InputCollector()
console.print()
# Section 1: Project Setup
UIFormatter.print_separator()
console.print(f"\n[{UIColors.INFO}]Step 1: Project Setup[/{UIColors.INFO}]\n")
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"
)
console.print()
console.print(f"[{UIColors.HIGHLIGHT}]Django Project Name[/{UIColors.HIGHLIGHT}]")
console.print(f"[{UIColors.MUTED}]Name used in 'django-admin startproject' command[/{UIColors.MUTED}]")
console.print(f"[{UIColors.MUTED}]Common names: config, core, settings, project_name[/{UIColors.MUTED}]")
project_name = collector.get_validated_input(
"Enter Django project name", validate_project_name, "Django project name"
)
console.print()
# Section 2: Django Apps
console.print()
UIFormatter.print_separator()
console.print(f"\n[{UIColors.INFO}]Step 2: Django Apps[/{UIColors.INFO}]\n")
app_names = collector.get_app_names()
console.print()
# Section 3: CI/CD Pipeline
use_github, use_gitlab = collector.get_cicd_choice()
# Create metadata
metadata = ProjectMetadata(package_name=project_dir, use_github_actions=use_github, use_gitlab_ci=use_gitlab)
console.print()
# Return in legacy format for backward compatibility
return project_dir, project_name, app_names[0], app_names, metadata.to_dict()
def confirm_setup(project_dir: str, project_name: str, app_names: list, metadata: dict) -> bool:
console.print()
UIFormatter.print_separator()
console.print()
console.print(f"[{UIColors.INFO}]Setup Summary[/{UIColors.INFO}]")
console.print()
# Display configuration
console.print(f"[{UIColors.HIGHLIGHT}]Project Directory:[/{UIColors.HIGHLIGHT}] {project_dir}")
console.print(f"[{UIColors.HIGHLIGHT}]Django Project:[/{UIColors.HIGHLIGHT}] {project_name}")
console.print(f"[{UIColors.HIGHLIGHT}]Apps:[/{UIColors.HIGHLIGHT}] {', '.join(app_names)}")
console.print(f"[{UIColors.HIGHLIGHT}]Package:[/{UIColors.HIGHLIGHT}] {metadata['package_name']}")
# Show CI/CD choices
cicd_choices = _get_cicd_display(metadata)
console.print(f"[{UIColors.HIGHLIGHT}]CI/CD:[/{UIColors.HIGHLIGHT}] {cicd_choices}")
console.print()
UIFormatter.print_separator()
console.print()
# 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())
return response == "y"
except KeyboardInterrupt:
UIFormatter.print_info("\nSetup cancelled by user.")
return False
def _get_cicd_display(metadata: dict) -> str:
cicd_choices = []
if metadata.get("use_github_actions", False):
cicd_choices.append("GitHub Actions")
if metadata.get("use_gitlab_ci", False):
cicd_choices.append("GitLab CI")
return ", ".join(cicd_choices) if cicd_choices else "None"
get_char = CharReader.get_char
_get_validated_input = InputCollector().get_validated_input