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
35 changes: 12 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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**
Expand All @@ -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.
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
170 changes: 81 additions & 89 deletions upgrade_tool/main.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -11,24 +12,22 @@

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.",
add_completion=False,
)

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,
Expand All @@ -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:
Expand All @@ -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__":
Expand Down