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/__init__.py b/tests/__init__.py index 2e70fad..e3eebaa 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1 @@ -# This file makes the 'tests' directory a Python package. \ No newline at end of file +# This file makes the 'tests' directory a Python package. diff --git a/tests/test_main.py b/tests/test_main.py index 41c9fd2..ba48721 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,49 +1,123 @@ +import sys +import subprocess +from unittest.mock import 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() + 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) - # Run the command with the --exclude flag and --dry-run to prevent actual upgrades - result = runner.invoke(app, ["--exclude", "requests", "--dry-run"]) +# --- NEW: Comprehensive tests for the upgrade_package worker function --- - # 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 + +@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) + + 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() diff --git a/upgrade_tool/__init__.py b/upgrade_tool/__init__.py index f77efa9..dfbc566 100644 --- a/upgrade_tool/__init__.py +++ b/upgrade_tool/__init__.py @@ -2,4 +2,4 @@ Ultimate Python Upgrader (`py-upgrade`) An intelligent, feature-rich CLI tool to manage and upgrade Python packages. -""" \ No newline at end of file +""" diff --git a/upgrade_tool/main.py b/upgrade_tool/main.py index e14047c..63812f0 100644 --- a/upgrade_tool/main.py +++ b/upgrade_tool/main.py @@ -12,13 +12,15 @@ 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" ROLLBACK_SUCCESS = "ROLLBACK_SUCCESS" ROLLBACK_FAILED = "ROLLBACK_FAILED" + console = Console() app = typer.Typer( name="py-upgrade", @@ -26,9 +28,12 @@ class UpgradeStatus(Enum): add_completion=False, ) + def check_for_conflicts(packages_to_check: List[str]) -> Optional[str]: # This function remains unchanged from the previous version - console.print("\n[bold cyan]Checking for potential dependency conflicts...[/bold cyan]") + console.print( + "\n[bold cyan]Checking for potential dependency conflicts...[/bold cyan]" + ) command = [ sys.executable, "-m", @@ -38,7 +43,11 @@ def check_for_conflicts(packages_to_check: List[str]) -> Optional[str]: "--upgrade", ] + packages_to_check process = subprocess.Popen( - command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding='utf-8' + command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", ) _, stderr = process.communicate() conflict_match = re.search( @@ -50,81 +59,152 @@ 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'] + 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( - [sys.executable, "-m", "pip", "install", "--force-reinstall", f"{pkg_name}=={original_version}"], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + subprocess.run( + [ + sys.executable, + "-m", + "pip", + "install", + "--force-reinstall", + f"{pkg_name}=={original_version}", + ], + 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( - 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.") + 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 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] + 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] + 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]") + 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]") + 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] + 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]") + if not yes: prompt_message = "\nProceed with the upgrade?" if conflicts: @@ -138,10 +218,17 @@ 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) - + 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, + ) + results = { UpgradeStatus.SUCCESS: 0, UpgradeStatus.UPGRADE_FAILED: 0, @@ -149,39 +236,74 @@ 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)) + 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} + 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: - progress.console.print(f" ✅ [green]Successfully upgraded {pkg_name} to {latest_version}[/green]") + progress.console.print( + f" ✅ [green]Successfully upgraded {pkg_name} to {latest_version}[/green]" + ) elif status == UpgradeStatus.ROLLBACK_SUCCESS: - progress.console.print(f" ↪️ [yellow]Failed to upgrade {pkg_name}, but successfully rolled back to {original_version}[/yellow]") + 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]") + 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})") + 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((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]}") + 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]}") + 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]}") + 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}") + console.print( + f"[bold red]CRITICAL-FAILURE (unstable):[/bold red] {results[UpgradeStatus.ROLLBACK_FAILED]}" + ) + 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__": - app() \ No newline at end of file + app() diff --git a/upgrade_tool/utils.py b/upgrade_tool/utils.py index 83f3270..6b1cee5 100644 --- a/upgrade_tool/utils.py +++ b/upgrade_tool/utils.py @@ -8,6 +8,7 @@ console = Console() + def get_outdated_packages() -> List[dict]: """ Retrieves a list of outdated packages using pip's JSON output format. @@ -15,7 +16,7 @@ def get_outdated_packages() -> List[dict]: Returns: A list of dictionaries, where each dictionary represents an outdated package. - + Raises: SystemExit: If pip command fails or is not found. """ @@ -26,18 +27,18 @@ def get_outdated_packages() -> List[dict]: stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, - encoding='utf-8' + encoding="utf-8", ) stdout, stderr = process.communicate() if process.returncode != 0: console.print(f"[bold red]Error running pip:[/bold red]\n{stderr}") raise SystemExit(1) - + output = stdout.strip() if not output: return [] - + # First, try to parse the entire output as a single JSON array. # This is the modern format for pip. try: @@ -45,15 +46,20 @@ def get_outdated_packages() -> List[dict]: except json.JSONDecodeError: # If that fails, fall back to parsing line-by-line. # This handles older pip versions or unexpected formats. - return [json.loads(line) for line in output.split('\n')] + return [json.loads(line) for line in output.split("\n")] except FileNotFoundError: - console.print("[bold red]Fatal Error:[/bold red] `pip` is not installed or not in your PATH.") + console.print( + "[bold red]Fatal Error:[/bold red] `pip` is not installed or not in your PATH." + ) raise SystemExit(1) except Exception as e: - console.print(f"[bold red]An unexpected error occurred while parsing pip output:[/bold red] {e}") + console.print( + f"[bold red]An unexpected error occurred while parsing pip output:[/bold red] {e}" + ) raise SystemExit(1) + def generate_packages_table(packages: List[dict], title: str) -> Table: """ Generates a Rich Table to display package information. @@ -77,9 +83,5 @@ def generate_packages_table(packages: List[dict], title: str) -> Table: for pkg in packages: # Using .get() provides safety against missing keys, returning None instead of erroring. - table.add_row( - pkg.get('name'), - pkg.get('version'), - pkg.get('latest_version') - ) - return table \ No newline at end of file + table.add_row(pkg.get("name"), pkg.get("version"), pkg.get("latest_version")) + return table