""" 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