From 9091c79ea39263d3955c3b5458f94ae11b205985 Mon Sep 17 00:00:00 2001 From: psywarrior1998 Date: Sun, 31 Aug 2025 16:51:08 +0530 Subject: [PATCH] feat(upgrade): Implement detailed error reporting and robust testing --- .github/workflows/ci.yml | 38 +++++++++++-- pyproject.toml | 2 +- tests/test_main.py | 112 +++++++++++++++++++++++++++++---------- upgrade_tool/main.py | 55 +++++++++++-------- 4 files changed, 150 insertions(+), 57 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1f364e..5c741e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,12 +19,42 @@ jobs: uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - + + # --- NEW: Cache dependencies --- + - name: Get pip cache dir + id: pip-cache + run: | + echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT + - name: Cache dependencies + uses: actions/cache@v3 + with: + path: ${{ steps.pip-cache.outputs.dir }} + key: ${{ runner.os }}-pip-${{ hashFiles('**/pyproject.toml') }} + restore-keys: | + ${{ runner.os }}-pip- + - name: Install dependencies run: | python -m pip install --upgrade pip - pip install ".[test]" # Installs project and test dependencies + # Install black and ruff for linting/formatting + pip install ".[test]" black ruff - - name: Test with pytest + # --- NEW: Linter and Formatter Steps --- + - name: Check formatting with Black + run: | + black --check . + - name: Lint with Ruff run: | - pytest \ No newline at end of file + ruff check . + + - name: Test with pytest and generate coverage report + run: | + # Add --cov to measure code coverage + pytest --cov=upgrade_tool --cov-report=xml + + # --- NEW (Optional but Recommended): Upload coverage report --- + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + token: ${{ secrets.CODECOV_TOKEN }} # You need to set this secret in your repo + file: ./coverage.xml \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 81b3e59..637f4e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" name = "ultimate-python-upgrader" version = "1.3.0" authors = [ - { name="Your Name", email="your@email.com" }, + { name="Sanyam Sanjay Sharma", email="infta2020+pypi@gmail.com" }, ] description = "An intelligent CLI tool to upgrade Python packages with dependency analysis and automatic rollback on failure." readme = "README.md" diff --git a/tests/test_main.py b/tests/test_main.py index 41c9fd2..a556dd2 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,5 +1,9 @@ +import sys +import subprocess +from unittest.mock import MagicMock, patch + from typer.testing import CliRunner -from upgrade_tool.main import app +from upgrade_tool.main import app, upgrade_package, UpgradeStatus # CliRunner is a utility from Typer for testing command-line applications runner = CliRunner() @@ -7,43 +11,93 @@ def test_app_shows_up_to_date_message(monkeypatch): """ Tests that the correct message is shown when no packages are outdated. - This test mocks `get_outdated_packages` to return an empty list. """ - # Create a mock function that returns an empty list - def mock_get_outdated(): - return [] - - # Use monkeypatch to replace the function *where it is used* in the main module - monkeypatch.setattr("upgrade_tool.main.get_outdated_packages", mock_get_outdated) - - # Run the command + monkeypatch.setattr("upgrade_tool.main.get_outdated_packages", lambda: []) result = runner.invoke(app) - - # Assert that the exit code is 0 (success) and the correct message is in the output assert result.exit_code == 0 assert "All packages are up to date!" in result.stdout def test_app_exclusion_logic(monkeypatch): """ Tests the --exclude functionality. - This test mocks a fixed list of outdated packages to verify that the - exclusion logic works as intended. """ - # Create a mock function that returns a predefined list of packages - def mock_get_outdated(): - return [ - {'name': 'requests', 'version': '2.25.0', 'latest_version': '2.28.0'}, - {'name': 'numpy', 'version': '1.20.0', 'latest_version': '1.23.0'} - ] + mock_outdated = [ + {'name': 'requests', 'version': '2.25.0', 'latest_version': '2.28.0'}, + {'name': 'numpy', 'version': '1.20.0', 'latest_version': '1.23.0'} + ] + monkeypatch.setattr("upgrade_tool.main.get_outdated_packages", lambda: mock_outdated) + result = runner.invoke(app, ["--exclude", "requests", "--dry-run"]) + assert result.exit_code == 0 + assert "requests" not in result.stdout + assert "numpy" in result.stdout + assert "1 packages selected" in result.stdout - # Use monkeypatch to replace the function *where it is used* in the main module - monkeypatch.setattr("upgrade_tool.main.get_outdated_packages", mock_get_outdated) +# --- NEW: Comprehensive tests for the upgrade_package worker function --- - # Run the command with the --exclude flag and --dry-run to prevent actual upgrades - result = runner.invoke(app, ["--exclude", "requests", "--dry-run"]) +@patch('upgrade_tool.main.subprocess.run') +def test_upgrade_package_success(mock_run): + """Tests the successful upgrade path of the worker function.""" + mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="") + pkg = {'name': 'requests', 'version': '2.25.0', 'latest_version': '2.28.0'} + + name, _, status, _, error = upgrade_package(pkg, no_rollback=False) - # Assertions - assert result.exit_code == 0 - assert "requests" not in result.stdout # The excluded package should NOT be in the output table - assert "numpy" in result.stdout # The other package should be present - assert "1 packages selected" in result.stdout # The table caption should reflect the exclusion \ No newline at end of file + assert status == UpgradeStatus.SUCCESS + assert name == 'requests' + assert error == "" + # Assert that pip install --upgrade was called + mock_run.assert_called_once_with( + [sys.executable, "-m", "pip", "install", "--upgrade", "requests"], + capture_output=True, text=True, check=True, encoding='utf-8' + ) + +@patch('upgrade_tool.main.subprocess.run') +def test_upgrade_failure_with_successful_rollback(mock_run): + """Tests that a failed upgrade triggers a successful rollback.""" + # Simulate a failure on the first call (upgrade) and success on the second (rollback) + mock_run.side_effect = [ + subprocess.CalledProcessError(returncode=1, cmd=[], stderr="Upgrade failed!"), + subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="") # This is the rollback call + ] + pkg = {'name': 'requests', 'version': '2.25.0', 'latest_version': '2.28.0'} + + _, _, status, _, error = upgrade_package(pkg, no_rollback=False) + + assert status == UpgradeStatus.ROLLBACK_SUCCESS + assert "Upgrade failed!" in error + assert mock_run.call_count == 2 + # Check the second call was the rollback + rollback_call_args = mock_run.call_args_list[1].args[0] + assert "--force-reinstall" in rollback_call_args + assert "requests==2.25.0" in rollback_call_args + +@patch('upgrade_tool.main.subprocess.run') +def test_upgrade_failure_with_failed_rollback(mock_run): + """Tests a critical failure where both upgrade and rollback fail.""" + # Simulate failure on both calls + mock_run.side_effect = [ + subprocess.CalledProcessError(returncode=1, cmd=[], stderr="Upgrade failed!"), + subprocess.CalledProcessError(returncode=1, cmd=[], stderr="Rollback also failed!") + ] + pkg = {'name': 'requests', 'version': '2.25.0', 'latest_version': '2.28.0'} + + _, _, status, _, error = upgrade_package(pkg, no_rollback=False) + + assert status == UpgradeStatus.ROLLBACK_FAILED + assert "Upgrade Error: Upgrade failed!" in error + assert "Rollback Error: Rollback also failed!" in error + assert mock_run.call_count == 2 + +@patch('upgrade_tool.main.subprocess.run') +def test_upgrade_failure_with_no_rollback_enabled(mock_run): + """Tests that a failed upgrade does not attempt rollback when disabled.""" + # Simulate failure on the first call + mock_run.side_effect = subprocess.CalledProcessError(returncode=1, cmd=[], stderr="Upgrade failed!") + pkg = {'name': 'requests', 'version': '2.25.0', 'latest_version': '2.28.0'} + + _, _, status, _, error = upgrade_package(pkg, no_rollback=True) + + assert status == UpgradeStatus.UPGRADE_FAILED + assert "Upgrade failed!" in error + # With no_rollback=True, only one call to subprocess.run should be made + mock_run.assert_called_once() \ No newline at end of file diff --git a/upgrade_tool/main.py b/upgrade_tool/main.py index e14047c..9740aad 100644 --- a/upgrade_tool/main.py +++ b/upgrade_tool/main.py @@ -12,7 +12,7 @@ from .utils import get_outdated_packages, generate_packages_table -# --- New: Define status enums for clear results --- +# --- Status enums for clear results --- class UpgradeStatus(Enum): SUCCESS = "SUCCESS" UPGRADE_FAILED = "UPGRADE_FAILED" @@ -50,39 +50,42 @@ def check_for_conflicts(packages_to_check: List[str]) -> Optional[str]: return conflict_match.group(1).strip() return None -def upgrade_package(pkg: Dict, no_rollback: bool) -> Tuple[str, str, UpgradeStatus, str]: +def upgrade_package(pkg: Dict, no_rollback: bool) -> Tuple[str, str, UpgradeStatus, str, str]: """ Worker function to upgrade a single package, with rollback on failure. Returns: - A tuple of (package_name, new_version, status_enum, original_version). + A tuple of (package_name, new_version, status_enum, original_version, error_message). """ pkg_name = pkg['name'] original_version = pkg['version'] latest_version = pkg['latest_version'] + error_message = "" try: - # Attempt the upgrade - subprocess.check_call( + # Use subprocess.run to capture output and check for errors + subprocess.run( [sys.executable, "-m", "pip", "install", "--upgrade", f"{pkg_name}"], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + capture_output=True, text=True, check=True, encoding='utf-8' ) - return pkg_name, latest_version, UpgradeStatus.SUCCESS, original_version - except subprocess.CalledProcessError: - # The upgrade failed. + return pkg_name, latest_version, UpgradeStatus.SUCCESS, original_version, "" + except subprocess.CalledProcessError as e: + # The upgrade failed. Capture the precise error from stderr. + error_message = e.stderr.strip() if no_rollback: - return pkg_name, latest_version, UpgradeStatus.UPGRADE_FAILED, original_version + return pkg_name, latest_version, UpgradeStatus.UPGRADE_FAILED, original_version, error_message # Attempt to roll back to the original version. try: - subprocess.check_call( + subprocess.run( [sys.executable, "-m", "pip", "install", "--force-reinstall", f"{pkg_name}=={original_version}"], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + capture_output=True, text=True, check=True, encoding='utf-8' ) - return pkg_name, latest_version, UpgradeStatus.ROLLBACK_SUCCESS, original_version - except subprocess.CalledProcessError: + return pkg_name, latest_version, UpgradeStatus.ROLLBACK_SUCCESS, original_version, error_message + except subprocess.CalledProcessError as rollback_e: # The rollback itself failed, which is critical. - return pkg_name, latest_version, UpgradeStatus.ROLLBACK_FAILED, original_version + critical_error = f"Upgrade Error: {error_message}\nRollback Error: {rollback_e.stderr.strip()}" + return pkg_name, latest_version, UpgradeStatus.ROLLBACK_FAILED, original_version, critical_error @app.command() def upgrade( @@ -96,35 +99,39 @@ def upgrade( """ 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() + 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() - # --- 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))) else: console.print("[bold green]✅ No dependency conflicts detected.[/bold green]") + if not yes: prompt_message = "\nProceed with the upgrade?" if conflicts: @@ -138,7 +145,6 @@ def upgrade( console.print("\nUpgrade cancelled by user.") raise typer.Exit() - # --- 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) @@ -149,13 +155,14 @@ def upgrade( UpgradeStatus.ROLLBACK_FAILED: 0, } failed_rollbacks = [] + failed_upgrades_no_rollback = [] 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, no_rollback): pkg for pkg in target_packages} for future in as_completed(future_to_pkg): - pkg_name, latest_version, status, original_version = future.result() + pkg_name, latest_version, status, original_version, error_msg = future.result() results[status] += 1 if status == UpgradeStatus.SUCCESS: @@ -164,23 +171,25 @@ def upgrade( 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]") + failed_upgrades_no_rollback.append((pkg_name, error_msg)) 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})") + failed_rollbacks.append((pkg_name, error_msg)) progress.advance(upgrade_task) - # --- New Detailed Summary Report --- console.print("\n--- [bold]Upgrade Complete[/bold] ---") 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]}") + for pkg_name, error in failed_upgrades_no_rollback: + console.print(f" - [bold]{pkg_name}[/bold]: {error.splitlines()[0]}") # Show first line of error 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}") + for pkg_name, error in failed_rollbacks: + console.print(Panel(f"[bold]{pkg_name}[/bold]\n---\n{error}", title="[bold red]Detailed Error[/bold red]", border_style="red")) console.print("--------------------------") if __name__ == "__main__":