diff --git a/src/pyvm_updater/installers.py b/src/pyvm_updater/installers.py index 1d2a6e4..e9c651a 100644 --- a/src/pyvm_updater/installers.py +++ b/src/pyvm_updater/installers.py @@ -2,33 +2,37 @@ from __future__ import annotations +from typing import Any + import click from .config import get_config from .plugins.manager import get_plugin_manager -def update_python_windows(version_str: str, preferred: str = "auto") -> bool: +def update_python_windows(version_str: str, preferred: str = "auto", **kwargs: Any) -> bool: """Update Python on Windows.""" - return _install_with_plugins(version_str, preferred=preferred) + return _install_with_plugins(version_str, preferred=preferred, **kwargs) -def update_python_linux(version_str: str, build_from_source: bool = False, preferred: str = "auto") -> bool: +def update_python_linux( + version_str: str, build_from_source: bool = False, preferred: str = "auto", **kwargs: Any +) -> bool: """Install Python on Linux.""" if preferred == "auto" and build_from_source: preferred = "source" elif preferred == "auto": preferred = get_config().preferred_installer - return _install_with_plugins(version_str, preferred=preferred) + return _install_with_plugins(version_str, preferred=preferred, **kwargs) -def update_python_macos(version_str: str, preferred: str = "auto") -> bool: +def update_python_macos(version_str: str, preferred: str = "auto", **kwargs: Any) -> bool: """Update Python on macOS.""" - return _install_with_plugins(version_str, preferred=preferred) + return _install_with_plugins(version_str, preferred=preferred, **kwargs) -def _install_with_plugins(version_str: str, preferred: str = "auto") -> bool: +def _install_with_plugins(version_str: str, preferred: str = "auto", **kwargs: Any) -> bool: """Generic installation logic using the plugin system.""" pm = get_plugin_manager() installer = pm.get_best_installer(preferred=preferred) @@ -42,7 +46,7 @@ def _install_with_plugins(version_str: str, preferred: str = "auto") -> bool: f"⚠️ Requested installer '{preferred}' is not supported or not found. Falling back to '{installer.get_name()}'." ) - return installer.install(version_str) + return installer.install(version_str, **kwargs) def remove_python_windows(version_str: str) -> bool: diff --git a/src/pyvm_updater/plugins/standard.py b/src/pyvm_updater/plugins/standard.py index 97c93ac..38c4cca 100644 --- a/src/pyvm_updater/plugins/standard.py +++ b/src/pyvm_updater/plugins/standard.py @@ -259,7 +259,20 @@ def install(self, version: str, **kwargs: Any) -> bool: print("Please follow the installer prompts.") try: - result = subprocess.run([installer_path], check=False) + cmd = [installer_path] + + # Add options from wizard if provided + if kwargs.get("add_to_path"): + cmd.append("PrependPath=1") + + if kwargs.get("install_path"): + cmd.append(f"TargetDir={kwargs['install_path']}") + + # Default to passive installation if options are provided to avoid too much manual interaction + if kwargs.get("add_to_path") or kwargs.get("install_path"): + cmd.append("/passive") + + result = subprocess.run(cmd, check=False) if result.returncode != 0: # 1602: User cancelled, 1603: Fatal error during installation (common for cancellation) if result.returncode in [1602, 1603]: @@ -342,7 +355,15 @@ def install(self, version: str, **kwargs: Any) -> bool: print(f"🔧 Configuring and building with {os.cpu_count() or 2} cores...") cpu_cores = str(os.cpu_count() or 2) - subprocess.run(["./configure", "--enable-optimizations"], cwd=build_dir, check=True) + + configure_cmd = ["./configure"] + if kwargs.get("optimizations", True): + configure_cmd.append("--enable-optimizations") + + if kwargs.get("install_path"): + configure_cmd.append(f"--prefix={kwargs['install_path']}") + + subprocess.run(configure_cmd, cwd=build_dir, check=True) subprocess.run(["make", f"-j{cpu_cores}"], cwd=build_dir, check=True) subprocess.run(["sudo", "make", "altinstall"], cwd=build_dir, check=True) diff --git a/src/pyvm_updater/tui.py b/src/pyvm_updater/tui.py index 04f7c7c..30bf1fd 100644 --- a/src/pyvm_updater/tui.py +++ b/src/pyvm_updater/tui.py @@ -34,6 +34,7 @@ ) from .utils import get_os_info from .version import check_python_version, get_active_python_releases, get_installed_python_versions +from .wizard import WizardScreen class StatusBar(Static): @@ -119,6 +120,7 @@ class AvailableList(ListView): Binding("tab", "focus_next_panel", "Next Panel", show=False), Binding("shift+tab", "focus_prev_panel", "Prev Panel", show=False), Binding("enter", "install_selected", "Install", show=True), + Binding("w", "wizard_selected", "Wizard", show=True), ] def action_focus_next_panel(self) -> None: @@ -135,6 +137,12 @@ def action_install_selected(self) -> None: if hasattr(self.screen, "start_install"): self.screen.start_install(version) # type: ignore[attr-defined] + def action_wizard_selected(self) -> None: + if self.highlighted_child and isinstance(self.highlighted_child, VersionItem): + version = self.highlighted_child.version + if hasattr(self.screen, "start_wizard"): + self.screen.start_wizard(version) # type: ignore[attr-defined] + class MainScreen(Screen): """Main TUI screen with navigable panels""" @@ -144,6 +152,7 @@ class MainScreen(Screen): Binding("r", "refresh", "Refresh"), Binding("u", "update_latest", "Update"), Binding("b", "rollback", "Rollback"), + Binding("w", "start_wizard", "Wizard"), Binding("1", "focus_installed", "Installed", show=False), Binding("2", "focus_available", "Available", show=False), Binding("?", "help", "Help"), @@ -290,7 +299,7 @@ def compose(self) -> ComposeResult: yield Label("Loading...") yield Static( - "[dim]Tab: switch panels | Arrow keys: navigate | Enter: install | X: remove | R: refresh | U: update | B: rollback | Q: quit[/dim]", + "[dim]Tab: switch panels | Arrow keys: navigate | Enter: install | W: wizard | X: remove | R: refresh | U: update | B: rollback | Q: quit[/dim]", id="hint-bar", ) @@ -311,6 +320,7 @@ def compose(self) -> ComposeResult: with Horizontal(id="button-area"): yield Button("Refresh [R]", id="refresh-btn", variant="default") yield Button("Update [U]", id="update-btn", variant="primary") + yield Button("Wizard [W]", id="wizard-btn", variant="default") yield Button("Rollback [B]", id="rollback-btn", variant="warning") yield Button("Quit [Q]", id="quit-btn", variant="error") @@ -474,6 +484,19 @@ def start_install(self, version: str) -> None: """Start installing a version (called from AvailableList)""" self.run_install_with_suspend(version) + def action_start_wizard(self) -> None: + """Start wizard without a version pre-selected""" + self.start_wizard() + + def start_wizard(self, version: Optional[str] = None) -> None: + """Open the installation wizard""" + + def handle_wizard_result(options: Optional[dict[str, Any]]) -> None: + if options: + self.run_wizard_install_with_suspend(options) + + self.app.push_screen(WizardScreen(version), handle_wizard_result) + def start_remove(self, version: str) -> None: """Start removing a version (called from InstalledList)""" self.run_remove_with_suspend(version) @@ -532,6 +555,69 @@ def do_installation(): # Refresh to show updated installed versions self.refresh_all() + def run_wizard_install_with_suspend(self, options: dict[str, Any]) -> None: + """Run installation with wizard options with TUI suspended""" + from textual.app import SuspendNotSupported + + version = options["version"] + os_name, _ = get_os_info() + success = False + + def do_installation(): + print(f"\n{'=' * 50}") + print(f"Wizard: Installing Python {version}") + print(f"Installer: {options['installer']}") + if options.get("install_path"): + print(f"Path: {options['install_path']}") + print(f"{'=' * 50}\n") + + # Prepare installer-specific arguments + installer_kwargs = options.copy() + preferred = installer_kwargs.pop("installer", "auto") + installer_kwargs.pop("version", None) + + if os_name == "windows": + return update_python_windows(version, preferred=preferred, **installer_kwargs) + elif os_name == "linux": + # Ensure build_from_source is passed correctly if source installer is used + bfs = options.get("build_from_source", False) + if preferred == "source": + bfs = True + return update_python_linux(version, build_from_source=bfs, preferred=preferred, **installer_kwargs) + elif os_name == "darwin": + return update_python_macos(version, preferred=preferred, **installer_kwargs) + else: + print(f"Unsupported OS: {os_name}") + return False + + try: + with self.app.suspend(): + success = do_installation() + print(f"\n{'=' * 50}") + if success: + print("Installation complete!") + else: + print("Installation had issues.") + print(f"{'=' * 50}") + print("\nPress Enter to return to TUI...") + try: + input() + except EOFError: + pass + except SuspendNotSupported: + status_bar = self.query_one("#status-bar", StatusBar) + status_bar.set_message(f"Installing Python {version}... (check terminal)", "yellow") + success = do_installation() + + status_bar = self.query_one("#status-bar", StatusBar) + if success: + status_bar.set_message(f"Python {version} installed successfully!", "green") + self.app.push_screen(SuccessScreen(version, os_name)) + else: + status_bar.set_message("Installation had issues.", "yellow") + + self.refresh_all() + def run_remove_with_suspend(self, version: str) -> None: """Run removal with TUI suspended so terminal output is visible""" from textual.app import SuspendNotSupported @@ -603,6 +689,8 @@ def on_button_pressed(self, event: Button.Pressed) -> None: self.action_refresh() elif event.button.id == "update-btn": self.action_update_latest() + elif event.button.id == "wizard-btn": + self.action_start_wizard() elif event.button.id == "rollback-btn": self.action_rollback() elif event.button.id == "quit-btn": @@ -834,6 +922,7 @@ def compose(self) -> ComposeResult: X Remove selected version R Refresh data U Update to latest version + W Install wizard B Rollback last action ? This help Q Quit diff --git a/src/pyvm_updater/wizard.py b/src/pyvm_updater/wizard.py new file mode 100644 index 0000000..7170702 --- /dev/null +++ b/src/pyvm_updater/wizard.py @@ -0,0 +1,236 @@ +"""Interactive installation wizard for pyvm.""" + +from __future__ import annotations + +import platform +import re +from typing import Any + +from textual import on +from textual.app import ComposeResult +from textual.containers import Horizontal, Vertical +from textual.screen import ModalScreen +from textual.widgets import ( + Button, + Checkbox, + ContentSwitcher, + Input, + Label, + RadioButton, + RadioSet, + Static, +) + +from .plugins.manager import get_plugin_manager + + +class WizardScreen(ModalScreen[dict[str, Any]]): + """A guided installation wizard for Python versions.""" + + CSS = """ + WizardScreen { + align: center middle; + background: rgba(0, 0, 0, 0.5); + } + + #wizard-container { + width: 60; + height: auto; + border: thick $primary; + background: $surface; + padding: 1 2; + } + + .wizard-title { + text-align: center; + text-style: bold; + margin-bottom: 1; + background: $primary; + color: $text; + } + + .step-container { + margin: 1 0; + height: auto; + } + + .step-label { + margin-bottom: 1; + } + + #button-nav { + margin-top: 1; + align: center middle; + height: auto; + } + + Button { + margin: 0 1; + } + + .hidden { + display: none; + } + + RadioSet { + margin: 1 0; + } + """ + + def __init__(self, version: str | None = None): + super().__init__() + self.version = version or "" + self.steps = ["step-version", "step-installer", "step-options", "step-confirm"] + self.current_step_idx = 0 + if self.version: + self.current_step_idx = 1 # Skip version selection if provided + + self.options: dict[str, Any] = { + "version": self.version, + "installer": "auto", + "build_from_source": False, + "optimizations": True, + "install_path": "", + "add_to_path": True, + } + + def compose(self) -> ComposeResult: + with Vertical(id="wizard-container"): + yield Label("PYTHON INSTALLATION WIZARD", classes="wizard-title") + + with ContentSwitcher(initial=self.steps[self.current_step_idx]): + # Step 1: Version Selection + with Vertical(id="step-version", classes="step-container"): + yield Label("Enter the Python version you want to install:", classes="step-label") + yield Label("(e.g., 3.12.1)", classes="step-label") + yield Input(placeholder="3.12.1", id="version-input", value=self.version) + + # Step 2: Installer Selection + with Vertical(id="step-installer", classes="step-container"): + yield Label("Select preferred installer:", classes="step-label") + with RadioSet(id="installer-select"): + yield RadioButton("Automatic (Recommended)", id="installer-auto", value=True) + pm = get_plugin_manager() + for plugin in pm.get_supported_plugins(): + yield RadioButton(plugin.get_name().title(), id=f"installer-{plugin.get_name()}") + + # Step 3: Advanced Options + with Vertical(id="step-options", classes="step-container"): + yield Label("Advanced Installation Options:", classes="step-label") + + if platform.system() == "Linux": + yield Checkbox("Build from source (Recommended for performance)", id="opt-source", value=False) + yield Checkbox( + "Enable optimizations (--enable-optimizations)", id="opt-optimizations", value=True + ) + + yield Label("Custom Installation Path (Optional):", classes="step-label") + yield Input(placeholder="/usr/local/custom-python", id="opt-path") + + if platform.system() == "Windows": + yield Checkbox("Add Python to PATH", id="opt-add-path", value=True) + + # Step 4: Confirmation + with Vertical(id="step-confirm", classes="step-container"): + yield Label("Confirm Installation Details:", classes="step-label") + yield Static(id="confirm-details") + yield Label("\nProceed with installation?", classes="step-label") + + with Horizontal(id="button-nav"): + yield Button("Back", id="btn-back", variant="default") + yield Button("Next", id="btn-next", variant="primary") + yield Button("Cancel", id="btn-cancel", variant="error") + + def on_mount(self) -> None: + self._update_nav_buttons() + + def _update_nav_buttons(self) -> None: + back_btn = self.query_one("#btn-back", Button) + next_btn = self.query_one("#btn-next", Button) + + if self.current_step_idx == 0: + back_btn.disabled = True + else: + back_btn.disabled = False + + if self.current_step_idx == len(self.steps) - 1: + next_btn.label = "Install" + next_btn.variant = "success" + self._update_confirm_details() + else: + next_btn.label = "Next" + next_btn.variant = "primary" + + def _update_confirm_details(self) -> None: + details = f"Version: [cyan]{self.options['version']}[/cyan]\n" + details += f"Installer: [cyan]{self.options['installer']}[/cyan]\n" + + if platform.system() == "Linux" and self.options.get("build_from_source"): + details += "Build: [cyan]From Source[/cyan]\n" + details += f"Optimizations: [cyan]{'Enabled' if self.options.get('optimizations') else 'Disabled'}[/cyan]\n" + + if self.options.get("install_path"): + details += f"Path: [cyan]{self.options['install_path']}[/cyan]\n" + + if platform.system() == "Windows": + details += f"Add to PATH: [cyan]{'Yes' if self.options.get('add_to_path') else 'No'}[/cyan]\n" + + self.query_one("#confirm-details", Static).update(details) + + @on(Button.Pressed, "#btn-cancel") + def cancel_wizard(self) -> None: + self.dismiss(None) + + @on(Button.Pressed, "#btn-back") + def prev_step(self) -> None: + if self.current_step_idx > 0: + self.current_step_idx -= 1 + self.query_one(ContentSwitcher).current = self.steps[self.current_step_idx] + self._update_nav_buttons() + + @on(Button.Pressed, "#btn-next") + def next_step(self) -> None: + # Validate current step + if self.current_step_idx == 0: + ver = self.query_one("#version-input", Input).value.strip() + if not ver or not re.match(r"^\d+\.\d+\.\d+$", ver): + self.query_one("#version-input", Input).styles.border = ("solid", "red") + return + self.query_one("#version-input", Input).styles.border = None + self.options["version"] = ver + + elif self.current_step_idx == 1: + # Installer selection is handled by radio set event or we can check it here + rs = self.query_one("#installer-select", RadioSet) + if rs.pressed_button and rs.pressed_button.id: + btn_id = rs.pressed_button.id + if btn_id == "installer-auto": + self.options["installer"] = "auto" + else: + self.options["installer"] = btn_id.replace("installer-", "") + + elif self.current_step_idx == 2: + # Collect options + if platform.system() == "Linux": + try: + self.options["build_from_source"] = self.query_one("#opt-source", Checkbox).value + self.options["optimizations"] = self.query_one("#opt-optimizations", Checkbox).value + except Exception: + pass + + path_input = self.query_one("#opt-path", Input).value.strip() + self.options["install_path"] = path_input + + if platform.system() == "Windows": + try: + self.options["add_to_path"] = self.query_one("#opt-add-path", Checkbox).value + except Exception: + pass + + if self.current_step_idx < len(self.steps) - 1: + self.current_step_idx += 1 + self.query_one(ContentSwitcher).current = self.steps[self.current_step_idx] + self._update_nav_buttons() + else: + # Final step: Install + self.dismiss(self.options)