Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 12 additions & 8 deletions src/pyvm_updater/installers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand Down
25 changes: 23 additions & 2 deletions src/pyvm_updater/plugins/standard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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)

Expand Down
91 changes: 90 additions & 1 deletion src/pyvm_updater/tui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand All @@ -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"""
Expand All @@ -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"),
Expand Down Expand Up @@ -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",
)

Expand All @@ -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")

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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
Expand Down
Loading