diff --git a/README.md b/README.md index 7a2f4e0..c455e3b 100644 --- a/README.md +++ b/README.md @@ -3,18 +3,19 @@ [![PyPI version](https://badge.fury.io/py/ultimate-python-upgrader.svg)](https://badge.fury.io/py/ultimate-python-upgrader) [![CI](https://github.com/psywarrior1998/upgrade_all_python/actions/workflows/ci.yml/badge.svg)](https://github.com/psywarrior1998/upgrade_all_python/actions/workflows/ci.yml) -An intelligent, feature-rich CLI tool to manage and upgrade Python packages with a clean, modern interface and a powerful dependency safety-check. +An intelligent, feature-rich CLI tool to manage and upgrade Python packages with a clean, modern interface and a powerful dependency safety-net. ## Key Features -- **Intelligent Dependency Analysis**: Automatically performs a pre-flight check to detect and warn you about potential dependency conflicts *before* you upgrade, preventing broken environments. -- **Concurrent & Fast**: Upgrades packages in parallel using multiple workers, dramatically reducing the time you spend waiting. -- **Rich & Interactive UI**: Uses `rich` to display outdated packages in a clean, readable table with clear progress bars. -- **Selective Upgrades**: Upgrade all packages, or specify exactly which ones to include or exclude. -- **Safety First**: Includes a `--dry-run` mode to see what would be upgraded without making any changes. -- **Automation Friendly**: A `--yes` flag allows for use in automated scripts. +- **🛡️ Rollback on Failure**: If an upgrade fails for any reason, the tool automatically reverts the package to its previous stable version. This ensures your environment is never left in a broken state. +- **Intelligent Dependency Analysis**: Performs a pre-flight check to detect and warn you about potential dependency conflicts *before* you upgrade. +- **Concurrent & Fast**: Upgrades packages in parallel using multiple workers, dramatically reducing the time you spend waiting. +- **Rich & Interactive UI**: Uses `rich` to display outdated packages in a clean, readable table with clear progress bars. +- **Selective Upgrades**: Upgrade all packages, or specify exactly which ones to include or exclude. +- **Safety First**: Includes a `--dry-run` mode to see what would be upgraded without making any changes. +- **Automation Friendly**: A `--yes` flag allows for use in automated scripts. ## Installation @@ -29,16 +30,16 @@ pip install ultimate-python-upgrader Once installed, the `py-upgrade` command will be available. **1. Check and upgrade all packages interactively** -The tool will first check for dependency conflicts before asking to proceed. +The tool will check for conflicts and automatically roll back any failed upgrades. ```bash py-upgrade ``` -**2. Upgrade with more parallel workers** +**2. Disable automatic rollback (not recommended)** ```bash -py-upgrade --yes --workers 20 +py-upgrade --yes --no-rollback ``` **3. Perform a dry run to see what needs upgrading** @@ -47,18 +48,6 @@ py-upgrade --yes --workers 20 py-upgrade --dry-run ``` -**4. Upgrade only specific packages** - -```bash -py-upgrade numpy pandas -``` - -**5. Upgrade all packages EXCEPT certain ones** - -```bash -py-upgrade --exclude black ruff -``` - ## Contributing -Contributions are welcome\! Please feel free to submit a pull request. +Contributions are welcome\! Please feel free to submit a pull request. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index a2c0470..81b3e59 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,11 +4,11 @@ build-backend = "hatchling.build" [project] name = "ultimate-python-upgrader" -version = "1.2.0" +version = "1.3.0" authors = [ { name="Your Name", email="your@email.com" }, ] -description = "An intelligent, feature-rich CLI tool to manage and upgrade Python packages with dependency analysis." +description = "An intelligent CLI tool to upgrade Python packages with dependency analysis and automatic rollback on failure." readme = "README.md" requires-python = ">=3.8" license = { file="LICENSE" } diff --git a/upgrade_tool/main.py b/upgrade_tool/main.py index 8e27426..e14047c 100644 --- a/upgrade_tool/main.py +++ b/upgrade_tool/main.py @@ -1,7 +1,8 @@ import subprocess import sys import re -from typing import List, Optional, Tuple +from typing import List, Optional, Tuple, Dict +from enum import Enum import typer from rich.console import Console @@ -11,8 +12,14 @@ from .utils import get_outdated_packages, generate_packages_table -console = Console() +# --- New: Define status enums for clear results --- +class UpgradeStatus(Enum): + SUCCESS = "SUCCESS" + UPGRADE_FAILED = "UPGRADE_FAILED" + ROLLBACK_SUCCESS = "ROLLBACK_SUCCESS" + ROLLBACK_FAILED = "ROLLBACK_FAILED" +console = Console() app = typer.Typer( name="py-upgrade", help="An intelligent, feature-rich CLI tool to manage and upgrade Python packages.", @@ -20,15 +27,7 @@ ) def check_for_conflicts(packages_to_check: List[str]) -> Optional[str]: - """ - Performs a dry-run upgrade to detect dependency conflicts. - - Args: - packages_to_check: A list of package names to be upgraded. - - Returns: - A formatted string of conflict messages, or None if no conflicts are found. - """ + # This function remains unchanged from the previous version console.print("\n[bold cyan]Checking for potential dependency conflicts...[/bold cyan]") command = [ sys.executable, @@ -38,111 +37,98 @@ def check_for_conflicts(packages_to_check: List[str]) -> Optional[str]: "--dry-run", "--upgrade", ] + packages_to_check - process = subprocess.Popen( command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding='utf-8' ) _, stderr = process.communicate() - - # Find the specific dependency conflict block in pip's output conflict_match = re.search( r"ERROR: pip's dependency resolver does not currently take into account all the packages that are installed\. This behaviour is the source of the following dependency conflicts\.(.+)", stderr, re.DOTALL, ) - if conflict_match: - conflict_text = conflict_match.group(1).strip() - return conflict_text + return conflict_match.group(1).strip() return None -def upgrade_package(pkg: dict) -> Tuple[str, str, bool]: +def upgrade_package(pkg: Dict, no_rollback: bool) -> Tuple[str, str, UpgradeStatus, str]: """ - Worker function to upgrade a single package in a separate thread. + Worker function to upgrade a single package, with rollback on failure. + + Returns: + A tuple of (package_name, new_version, status_enum, original_version). """ pkg_name = pkg['name'] + original_version = pkg['version'] latest_version = pkg['latest_version'] + try: + # Attempt the upgrade subprocess.check_call( - [sys.executable, "-m", "pip", "install", "--upgrade", pkg_name], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, + [sys.executable, "-m", "pip", "install", "--upgrade", f"{pkg_name}"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL ) - return pkg_name, latest_version, True + return pkg_name, latest_version, UpgradeStatus.SUCCESS, original_version except subprocess.CalledProcessError: - return pkg_name, latest_version, False + # The upgrade failed. + if no_rollback: + return pkg_name, latest_version, UpgradeStatus.UPGRADE_FAILED, original_version + + # Attempt to roll back to the original version. + try: + subprocess.check_call( + [sys.executable, "-m", "pip", "install", "--force-reinstall", f"{pkg_name}=={original_version}"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + ) + return pkg_name, latest_version, UpgradeStatus.ROLLBACK_SUCCESS, original_version + except subprocess.CalledProcessError: + # The rollback itself failed, which is critical. + return pkg_name, latest_version, UpgradeStatus.ROLLBACK_FAILED, original_version @app.command() def upgrade( - packages_to_upgrade: Optional[List[str]] = typer.Argument( - None, help="Specific packages to upgrade. If not provided, all outdated packages are targeted." - ), - exclude: Optional[List[str]] = typer.Option( - None, "--exclude", "-e", help="List of packages to exclude from the upgrade." - ), - yes: bool = typer.Option( - False, "--yes", "-y", help="Automatically confirm and proceed with the upgrade." - ), - dry_run: bool = typer.Option( - False, "--dry-run", help="Simulate the upgrade without making any changes." - ), - workers: int = typer.Option( - 10, "--workers", "-w", help="Number of concurrent workers for parallel upgrades." - ), + packages_to_upgrade: Optional[List[str]] = typer.Argument(None, help="Specific packages to upgrade."), + exclude: Optional[List[str]] = typer.Option(None, "--exclude", "-e", help="List of packages to exclude."), + yes: bool = typer.Option(False, "--yes", "-y", help="Automatically confirm all prompts."), + dry_run: bool = typer.Option(False, "--dry-run", help="Simulate the upgrade without making changes."), + workers: int = typer.Option(10, "--workers", "-w", help="Number of concurrent workers."), + no_rollback: bool = typer.Option(False, "--no-rollback", help="Disable automatic rollback on failure.") ): """ - Checks for and concurrently upgrades outdated Python packages with dependency analysis. + Checks for and concurrently upgrades outdated Python packages with dependency analysis and rollback-on-failure. """ + # --- Filtering and Display Logic (Unchanged) --- outdated_packages = get_outdated_packages() - if not outdated_packages: console.print("[bold green]✨ All packages are up to date! ✨[/bold green]") raise typer.Exit() - - # --- Filtering Logic --- if packages_to_upgrade: name_to_pkg = {pkg['name'].lower(): pkg for pkg in outdated_packages} target_packages = [name_to_pkg[name.lower()] for name in packages_to_upgrade if name.lower() in name_to_pkg] else: target_packages = outdated_packages - if exclude: exclude_set = {name.lower() for name in exclude} target_packages = [pkg for pkg in target_packages if pkg['name'].lower() not in exclude_set] - if not target_packages: console.print("[bold yellow]No packages match the specified criteria for upgrade.[/bold yellow]") raise typer.Exit() - table = generate_packages_table(target_packages, title="Outdated Python Packages") console.print(table) - if dry_run: console.print(f"\n[bold yellow]--dry-run enabled. Would simulate upgrade of {len(target_packages)} packages.[/bold yellow]") raise typer.Exit() - - # --- Intelligent Dependency Analysis --- + + # --- Dependency Analysis and Confirmation (Unchanged) --- package_names = [pkg['name'] for pkg in target_packages] conflicts = check_for_conflicts(package_names) - if conflicts: - console.print( - Panel.fit( - f"[bold]The following dependency conflicts were found:[/bold]\n\n{conflicts}", - title="[bold yellow]⚠️ Dependency Warning[/bold yellow]", - border_style="yellow", - padding=(1, 2), - ) - ) + console.print(Panel.fit(f"[bold]The following dependency conflicts were found:[/bold]\n\n{conflicts}", title="[bold yellow]⚠️ Dependency Warning[/bold yellow]", border_style="yellow", padding=(1, 2))) else: console.print("[bold green]✅ No dependency conflicts detected.[/bold green]") - - # --- Confirmation --- if not yes: prompt_message = "\nProceed with the upgrade?" if conflicts: - prompt_message = "\nConflicts were detected. Do you still wish to proceed with the upgrade?" - + prompt_message = "\nConflicts were detected. Do you still wish to proceed?" try: confirmed = typer.confirm(prompt_message) if not confirmed: @@ -151,44 +137,50 @@ def upgrade( except typer.Abort: console.print("\nUpgrade cancelled by user.") raise typer.Exit() - - # --- Concurrent Execution Logic --- + + # --- Concurrent Execution with Detailed Reporting --- console.print(f"\n[bold blue]Starting parallel upgrade with {workers} workers...[/bold blue]") + progress = Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"), BarColumn(), TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), console=console) - progress = Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - BarColumn(), - TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), - console=console - ) + results = { + UpgradeStatus.SUCCESS: 0, + UpgradeStatus.UPGRADE_FAILED: 0, + UpgradeStatus.ROLLBACK_SUCCESS: 0, + UpgradeStatus.ROLLBACK_FAILED: 0, + } + failed_rollbacks = [] - success_count = 0 - fail_count = 0 - with progress: upgrade_task = progress.add_task("[green]Upgrading...", total=len(target_packages)) - with ThreadPoolExecutor(max_workers=workers) as executor: - future_to_pkg = {executor.submit(upgrade_package, pkg): pkg for pkg in target_packages} - + future_to_pkg = {executor.submit(upgrade_package, pkg, no_rollback): pkg for pkg in target_packages} for future in as_completed(future_to_pkg): - pkg_name, latest_version, success = future.result() - - if success: + pkg_name, latest_version, status, original_version = future.result() + results[status] += 1 + + if status == UpgradeStatus.SUCCESS: progress.console.print(f" ✅ [green]Successfully upgraded {pkg_name} to {latest_version}[/green]") - success_count += 1 - else: - progress.console.print(f" ❌ [red]Failed to upgrade {pkg_name}[/red]") - fail_count += 1 - + elif status == UpgradeStatus.ROLLBACK_SUCCESS: + progress.console.print(f" ↪️ [yellow]Failed to upgrade {pkg_name}, but successfully rolled back to {original_version}[/yellow]") + elif status == UpgradeStatus.UPGRADE_FAILED: + progress.console.print(f" ❌ [red]Failed to upgrade {pkg_name}. Rollback was disabled.[/red]") + elif status == UpgradeStatus.ROLLBACK_FAILED: + progress.console.print(f" 🚨 [bold red]CRITICAL: Failed to upgrade {pkg_name} AND failed to roll back to {original_version}. Your environment may be unstable.[/bold red]") + failed_rollbacks.append(f"{pkg_name} (intended: {latest_version}, original: {original_version})") + progress.advance(upgrade_task) - # --- Summary Report --- + # --- New Detailed Summary Report --- console.print("\n--- [bold]Upgrade Complete[/bold] ---") - console.print(f"[green]Successfully upgraded:[/green] {success_count} packages") - if fail_count > 0: - console.print(f"[red]Failed to upgrade:[/red] {fail_count} packages") + console.print(f"[green]Successful upgrades:[/green] {results[UpgradeStatus.SUCCESS]}") + if results[UpgradeStatus.ROLLBACK_SUCCESS] > 0: + console.print(f"[yellow]Failed upgrades (rolled back):[/yellow] {results[UpgradeStatus.ROLLBACK_SUCCESS]}") + if results[UpgradeStatus.UPGRADE_FAILED] > 0: + console.print(f"[red]Failed upgrades (no rollback):[/red] {results[UpgradeStatus.UPGRADE_FAILED]}") + if results[UpgradeStatus.ROLLBACK_FAILED] > 0: + console.print(f"[bold red]CRITICAL-FAILURE (unstable):[/bold red] {results[UpgradeStatus.ROLLBACK_FAILED]}") + for pkg_info in failed_rollbacks: + console.print(f" - {pkg_info}") console.print("--------------------------") if __name__ == "__main__":