diff --git a/scripts/upgrade_all.sh b/scripts/upgrade_all.sh index f0d6bd2..11b4480 100755 --- a/scripts/upgrade_all.sh +++ b/scripts/upgrade_all.sh @@ -55,17 +55,91 @@ log_success() { TOTAL_UPGRADED=$((TOTAL_UPGRADED + 1)) } +get_version() { + local version_cmd="$1" + local version + version="$(eval "$version_cmd" 2>/dev/null || true)" + printf '%s' "${version:-unknown}" +} + log_success_with_info() { local name="$1" - local version_cmd="$2" + local binary="$2" + local version_cmd="$3" + local before_version="$4" + local after_version + after_version="$(get_version "$version_cmd")" + log_success_with_versions "$name" "$binary" "$before_version" "$after_version" +} + +log_success_with_versions() { + local name="$1" + local binary="$2" + local before_version="$3" + local after_version="$4" local location - location="$(command -v "$name" 2>/dev/null || echo "unknown")" - local version - version="$(eval "$version_cmd" 2>/dev/null || echo "unknown")" - echo " ${GREEN}✓${RESET} $name ($version at $location)" | tee -a "$LOG_FILE" + location="$(command -v "$binary" 2>/dev/null || echo "unknown")" + local version_change + if [ "$before_version" = "unknown" ] || [ "$after_version" = "unknown" ]; then + version_change="version unavailable: $before_version → $after_version" + elif [ "$before_version" = "$after_version" ]; then + version_change="$after_version unchanged" + else + version_change="$before_version → $after_version" + fi + echo " ${GREEN}✓${RESET} $name ($version_change at $location)" | tee -a "$LOG_FILE" TOTAL_UPGRADED=$((TOTAL_UPGRADED + 1)) } +get_uv_tool_version() { + local package="$1" + local version + version="$(uv tool list 2>/dev/null | awk -v package="$package" ' + $1 == package { + version = $2 + sub(/^v/, "", version) + print version + exit + } + ')" + printf '%s' "${version:-unknown}" +} + +get_uv_tool_binary() { + local package="$1" + uv tool list 2>/dev/null | awk -v package="$package" ' + $1 == package { found = 1; next } + found && $1 == "-" { print $2; exit } + found { exit } + ' +} + +upgrade_uv_tools() { + local tools + tools="$(uv tool list 2>/dev/null | awk '$1 != "-" && NF >= 2 { print $1 }' || true)" + if [ -z "$tools" ]; then + log_skip "uv (no tools installed)" + return 0 + fi + + local count + count="$(printf '%s\n' "$tools" | wc -l)" + log_info "Found $count uv tools to upgrade" + + local tool before_version after_version binary + while IFS= read -r tool; do + [ -z "$tool" ] && continue + before_version="$(get_uv_tool_version "$tool")" + binary="$(get_uv_tool_binary "$tool")" + if uv tool upgrade "$tool" >> "$LOG_FILE" 2>&1; then + after_version="$(get_uv_tool_version "$tool")" + log_success_with_versions "$tool" "${binary:-$tool}" "$before_version" "$after_version" + else + log_skip "uv tool: $tool (failed)" + fi + done <<< "$tools" +} + log_skip() { echo " ${YELLOW}⏭${RESET} $*" | tee -a "$LOG_FILE" TOTAL_SKIPPED=$((TOTAL_SKIPPED + 1)) @@ -129,7 +203,8 @@ stage_1_refresh() { log_info "DRY-RUN: make update" log_skip "Version data refresh (dry-run)" else - local start=$(date +%s) + local start + start="$(date +%s)" log_info "Fetching latest versions (this may take a minute)..." # Run make update with progress indication @@ -149,7 +224,8 @@ stage_1_refresh() { ) if [ $? -eq 0 ]; then - local end=$(date +%s) + local end + end="$(date +%s)" local duration=$((end - start)) log_success "Fetched latest version data (${duration}s)" else @@ -172,47 +248,92 @@ stage_2_managers() { if [ "$DRY_RUN" = "1" ]; then log_info "DRY-RUN: apt-get update && apt-get upgrade" else - run_cmd "apt (system)" sudo apt-get update >/dev/null 2>&1 && sudo apt-get upgrade -y >/dev/null 2>&1 || log_skip "apt (not available or failed)" + local before_version + before_version="$(get_version "apt-get --version | head -1 | awk '{print \$2}'")" + # The caller owns LOG_FILE; only apt-get itself needs sudo. + # shellcheck disable=SC2024 + if { + sudo apt-get update && + sudo apt-get upgrade -y + } >> "$LOG_FILE" 2>&1; then + log_success_with_info "apt (system)" "apt-get" "apt-get --version | head -1 | awk '{print \$2}'" "$before_version" + else + log_skip "apt (not available or failed)" + fi fi else log_skip "apt (not installed)" fi if command -v brew >/dev/null 2>&1; then - run_cmd "brew" brew update >/dev/null 2>&1 && brew upgrade >/dev/null 2>&1 || log_skip "brew (failed)" + if [ "$DRY_RUN" = "1" ]; then + log_info "DRY-RUN: brew update && brew upgrade" + else + local before_version + before_version="$(get_version "brew --version | head -1 | awk '{print \$2}'")" + if brew update >> "$LOG_FILE" 2>&1 && + brew upgrade >> "$LOG_FILE" 2>&1; then + log_success_with_info "brew" "brew" "brew --version | head -1 | awk '{print \$2}'" "$before_version" + else + log_skip "brew (failed)" + fi + fi else log_skip "brew (not installed)" fi if command -v snap >/dev/null 2>&1; then - run_cmd "snap" sudo snap refresh || log_skip "snap (failed)" + if [ "$DRY_RUN" = "1" ]; then + log_info "DRY-RUN: snap refresh" + else + local before_version + before_version="$(get_version "snap version | awk '/^snap / {print \$2}'")" + # The caller owns LOG_FILE; only snap itself needs sudo. + # shellcheck disable=SC2024 + if sudo snap refresh >> "$LOG_FILE" 2>&1; then + log_success_with_info "snap" "snap" "snap version | awk '/^snap / {print \$2}'" "$before_version" + else + log_skip "snap (failed)" + fi + fi else log_skip "snap (not installed)" fi if command -v flatpak >/dev/null 2>&1; then - run_cmd "flatpak" flatpak update -y || log_skip "flatpak (failed)" + if [ "$DRY_RUN" = "1" ]; then + log_info "DRY-RUN: flatpak update -y" + else + local before_version + before_version="$(get_version "flatpak --version | awk '{print \$2}'")" + if flatpak update -y >> "$LOG_FILE" 2>&1; then + log_success_with_info "flatpak" "flatpak" "flatpak --version | awk '{print \$2}'" "$before_version" + else + log_skip "flatpak (failed)" + fi + fi else log_skip "flatpak (not installed)" fi # Language-specific package managers - if command -v pip3 >/dev/null 2>&1; then + if command -v python3 >/dev/null 2>&1 && + python3 -m pip --version >/dev/null 2>&1; then # Skip pip if uv is managing Python packages, suggest migration if command -v uv >/dev/null 2>&1; then # Check if there are user-installed pip packages to migrate - local user_packages=$(python3 -m pip list --user --format=freeze 2>/dev/null | grep -v "^#" | wc -l) + local user_packages + user_packages="$(python3 -m pip list --user --format=freeze 2>/dev/null | grep -v "^#" | wc -l)" if [ "$user_packages" -gt 0 ]; then log_reconcile "pip ($user_packages user packages, run: make reconcile-pip-to-uv to migrate)" else log_skip "pip (uv is managing Python packages)" fi - # Check if pip module is actually available - elif ! python3 -m pip --version >/dev/null 2>&1; then - log_skip "pip (python3 has no pip module)" elif [ "$DRY_RUN" = "1" ]; then log_info "DRY-RUN: pip upgrade" else + local before_version + before_version="$(get_version "python3 -m pip --version | awk '{print \$2}'")" local upgrade_success=0 # Check if in virtualenv - skip --user flag if so if [ -n "${VIRTUAL_ENV:-}" ]; then @@ -222,21 +343,23 @@ stage_2_managers() { fi if [ "$upgrade_success" = "1" ]; then - log_success_with_info "pip" "python3 -m pip --version | awk '{print \$2}'" + log_success_with_info "pip (python3 -m pip)" "python3" "python3 -m pip --version | awk '{print \$2}'" "$before_version" else log_fail "pip (see $LOG_FILE for details)" fi fi else - log_skip "pip (not installed)" + log_skip "pip (python3 module not installed)" fi if command -v uv >/dev/null 2>&1; then if [ "$DRY_RUN" = "1" ]; then log_info "DRY-RUN: uv self update" else + local before_version + before_version="$(get_version "uv --version | awk '{print \$2}'")" if uv self update >> "$LOG_FILE" 2>&1; then - log_success_with_info "uv" "uv --version | awk '{print \$2}'" + log_success_with_info "uv" "uv" "uv --version | awk '{print \$2}'" "$before_version" else log_fail "uv (see $LOG_FILE for details)" fi @@ -249,7 +372,8 @@ stage_2_managers() { # Skip pipx if uv is managing Python tools, suggest migration if command -v uv >/dev/null 2>&1; then # Check if there are pipx tools to migrate - local pipx_tools=$(pipx list --short 2>/dev/null | wc -l) + local pipx_tools + pipx_tools="$(pipx list --short 2>/dev/null | wc -l)" if [ "$pipx_tools" -gt 0 ]; then log_reconcile "pipx ($pipx_tools tools installed, run: make reconcile-pipx-to-uv to migrate)" else @@ -258,6 +382,8 @@ stage_2_managers() { elif [ "$DRY_RUN" = "1" ]; then log_info "DRY-RUN: pip3 install --upgrade pipx" else + local before_version + before_version="$(get_version "pipx --version")" # Check if in virtualenv - skip --user flag if so local upgrade_success=0 if [ -n "${VIRTUAL_ENV:-}" ]; then @@ -267,7 +393,7 @@ stage_2_managers() { fi if [ "$upgrade_success" = "1" ]; then - log_success_with_info "pipx" "pipx --version" + log_success_with_info "pipx" "pipx" "pipx --version" "$before_version" else log_fail "pipx (see $LOG_FILE for details)" fi @@ -286,8 +412,10 @@ stage_2_managers() { if [ "$DRY_RUN" = "1" ]; then log_info "DRY-RUN: npm install -g npm@latest" else + local before_version + before_version="$(get_version "npm --version")" if npm install -g npm@latest >> "$LOG_FILE" 2>&1; then - log_success_with_info "npm" "npm --version" + log_success_with_info "npm" "npm" "npm --version" "$before_version" else log_fail "npm (see $LOG_FILE for details)" fi @@ -307,6 +435,8 @@ stage_2_managers() { if [ "$DRY_RUN" = "1" ]; then log_info "DRY-RUN: pnpm upgrade" else + local before_version + before_version="$(get_version "pnpm --version")" local upgrade_success=0 if command -v corepack >/dev/null 2>&1; then corepack prepare pnpm@latest --activate >> "$LOG_FILE" 2>&1 && upgrade_success=1 @@ -315,7 +445,7 @@ stage_2_managers() { fi if [ "$upgrade_success" = "1" ]; then - log_success_with_info "pnpm" "pnpm --version" + log_success_with_info "pnpm" "pnpm" "pnpm --version" "$before_version" else log_fail "pnpm (see $LOG_FILE for details)" fi @@ -335,6 +465,8 @@ stage_2_managers() { if [ "$DRY_RUN" = "1" ]; then log_info "DRY-RUN: yarn upgrade" else + local before_version + before_version="$(get_version "yarn --version")" local upgrade_success=0 if command -v corepack >/dev/null 2>&1; then corepack prepare yarn@stable --activate >> "$LOG_FILE" 2>&1 && upgrade_success=1 @@ -343,7 +475,7 @@ stage_2_managers() { fi if [ "$upgrade_success" = "1" ]; then - log_success_with_info "yarn" "yarn --version" + log_success_with_info "yarn" "yarn" "yarn --version" "$before_version" else log_fail "yarn (see $LOG_FILE for details)" fi @@ -357,8 +489,10 @@ stage_2_managers() { if [ "$DRY_RUN" = "1" ]; then log_info "DRY-RUN: rustup update" else + local before_version + before_version="$(get_version "rustup --version | head -1 | awk '{print \$2}'")" if rustup update >> "$LOG_FILE" 2>&1; then - log_success_with_info "rustup" "rustup --version | head -1 | awk '{print \$2}'" + log_success_with_info "rustup" "rustup" "rustup --version | head -1 | awk '{print \$2}'" "$before_version" else log_fail "rustup (see $LOG_FILE for details)" fi @@ -371,8 +505,10 @@ stage_2_managers() { if [ "$DRY_RUN" = "1" ]; then log_info "DRY-RUN: gem update --system" else + local before_version + before_version="$(get_version "gem --version")" if gem update --system >> "$LOG_FILE" 2>&1; then - log_success_with_info "gem" "gem --version" + log_success_with_info "gem" "gem" "gem --version" "$before_version" else log_fail "gem (see $LOG_FILE for details)" fi @@ -388,8 +524,10 @@ stage_2_managers() { elif [ "$DRY_RUN" = "1" ]; then log_info "DRY-RUN: composer self-update" else + local before_version + before_version="$(get_version "composer --version | head -1 | awk '{print \$3}'")" if composer self-update >> "$LOG_FILE" 2>&1; then - log_success_with_info "composer" "composer --version | head -1 | awk '{print \$3}'" + log_success_with_info "composer" "composer" "composer --version | head -1 | awk '{print \$3}'" "$before_version" else log_fail "composer (see $LOG_FILE for details)" fi @@ -402,6 +540,8 @@ stage_2_managers() { if [ "$DRY_RUN" = "1" ]; then log_info "DRY-RUN: poetry upgrade" else + local before_version + before_version="$(get_version "poetry --version | awk '{print \$3}'")" local upgrade_success=0 # Try poetry self update first (Poetry 1.2+) if poetry self update --help >/dev/null 2>&1; then @@ -418,7 +558,7 @@ stage_2_managers() { fi if [ "$upgrade_success" = "1" ]; then - log_success_with_info "poetry" "poetry --version | awk '{print \$3}'" + log_success_with_info "poetry" "poetry" "poetry --version | awk '{print \$3}'" "$before_version" elif [ "$upgrade_success" = "0" ]; then log_fail "poetry (see $LOG_FILE for details)" fi @@ -441,9 +581,11 @@ stage_3_runtimes() { if [ "$DRY_RUN" = "1" ]; then log_info "DRY-RUN: ./scripts/install_python.sh update" else + local before_version + before_version="$(get_version "python3 --version | awk '{print \$2}'")" if ./scripts/install_python.sh update >> "$LOG_FILE" 2>&1; then if command -v python3 >/dev/null 2>&1; then - log_success_with_info "Python" "python3 --version | awk '{print \$2}'" + log_success_with_info "Python" "python3" "python3 --version | awk '{print \$2}'" "$before_version" else log_success "Python runtime" fi @@ -460,9 +602,11 @@ stage_3_runtimes() { if [ "$DRY_RUN" = "1" ]; then log_info "DRY-RUN: ./scripts/install_node.sh update" else + local before_version + before_version="$(get_version "node --version | sed 's/^v//'")" if ./scripts/install_node.sh update >> "$LOG_FILE" 2>&1; then if command -v node >/dev/null 2>&1; then - log_success_with_info "Node.js" "node --version | sed 's/^v//'" + log_success_with_info "Node.js" "node" "node --version | sed 's/^v//'" "$before_version" else log_success "Node.js runtime" fi @@ -479,9 +623,11 @@ stage_3_runtimes() { if [ "$DRY_RUN" = "1" ]; then log_info "DRY-RUN: ./scripts/install_go.sh update" else + local before_version + before_version="$(get_version "go version | awk '{print \$3}' | sed 's/^go//'")" if ./scripts/install_go.sh update >> "$LOG_FILE" 2>&1; then if command -v go >/dev/null 2>&1; then - log_success_with_info "Go" "go version | awk '{print \$3}' | sed 's/^go//'" + log_success_with_info "Go" "go" "go version | awk '{print \$3}' | sed 's/^go//'" "$before_version" else log_success "Go runtime" fi @@ -498,9 +644,11 @@ stage_3_runtimes() { if [ "$DRY_RUN" = "1" ]; then log_info "DRY-RUN: ./scripts/install_ruby.sh update" else + local before_version + before_version="$(get_version "ruby --version | awk '{print \$2}'")" if ./scripts/install_ruby.sh update >> "$LOG_FILE" 2>&1; then if command -v ruby >/dev/null 2>&1; then - log_success_with_info "Ruby" "ruby --version | awk '{print \$2}'" + log_success_with_info "Ruby" "ruby" "ruby --version | awk '{print \$2}'" "$before_version" else log_success "Ruby runtime" fi @@ -517,9 +665,11 @@ stage_3_runtimes() { if [ "$DRY_RUN" = "1" ]; then log_info "DRY-RUN: ./scripts/install_rust.sh update" else + local before_version + before_version="$(get_version "rustc --version | awk '{print \$2}'")" if ./scripts/install_rust.sh update >> "$LOG_FILE" 2>&1; then if command -v rustc >/dev/null 2>&1; then - log_success_with_info "Rust" "rustc --version | awk '{print \$2}'" + log_success_with_info "Rust" "rustc" "rustc --version | awk '{print \$2}'" "$before_version" else log_success "Rust runtime" fi @@ -544,27 +694,7 @@ stage_4_user_packages() { if command -v uv >/dev/null 2>&1; then log_info "Upgrading uv tools..." if [ "$DRY_RUN" = "0" ]; then - local tools - # Filter out binary lines (starting with dash) and keep only tool names - tools="$(uv tool list 2>/dev/null | grep -v '^-' | awk 'NF > 0 {print $1}' || true)" - if [ -n "$tools" ]; then - local count=$(echo "$tools" | wc -l) - log_info "Found $count uv tools to upgrade" - while IFS= read -r tool; do - [ -z "$tool" ] && continue - if uv tool upgrade "$tool" >> "$LOG_FILE" 2>&1; then - if command -v "$tool" >/dev/null 2>&1; then - log_success_with_info "$tool" "$tool --version 2>/dev/null | head -1 | awk '{print \$NF}' || echo 'installed'" - else - log_success "uv tool: $tool" - fi - else - log_skip "uv tool: $tool (failed)" - fi - done <<< "$tools" - else - log_skip "uv (no tools installed)" - fi + upgrade_uv_tools else log_info "DRY-RUN: uv tool upgrade " fi @@ -576,7 +706,8 @@ stage_4_user_packages() { if command -v pipx >/dev/null 2>&1; then # Skip pipx packages if uv is managing Python tools if command -v uv >/dev/null 2>&1; then - local pipx_tools=$(pipx list --short 2>/dev/null | wc -l) + local pipx_tools + pipx_tools="$(pipx list --short 2>/dev/null | wc -l)" if [ "$pipx_tools" -gt 0 ]; then log_reconcile "pipx packages ($pipx_tools tools, run: make reconcile-pipx-to-uv to migrate)" else @@ -585,13 +716,15 @@ stage_4_user_packages() { elif [ "$DRY_RUN" = "1" ]; then log_info "DRY-RUN: pipx upgrade-all" else - local temp_log=$(mktemp) + local temp_log + temp_log="$(mktemp)" if pipx upgrade-all >> "$LOG_FILE" 2>&1; then log_success "pipx packages" else # Check if only failure was missing metadata (known issue) if grep -q "missing internal pipx metadata" "$LOG_FILE" 2>/dev/null; then - local broken_pkg=$(grep -oP "Not upgrading \K\w+" "$LOG_FILE" 2>/dev/null | tail -1) + local broken_pkg + broken_pkg="$(grep -oP "Not upgrading \K\w+" "$LOG_FILE" 2>/dev/null | tail -1)" log_reconcile "pipx packages (partial: $broken_pkg has missing metadata, run: pipx uninstall $broken_pkg && pipx install $broken_pkg)" else log_fail "pipx packages (see $LOG_FILE for details)" @@ -719,7 +852,8 @@ main() { stage_6_health_checks || true # Summary - local end_time=$(date +%s) + local end_time + end_time="$(date +%s)" local total_time=$((end_time - START_TIME)) local minutes=$((total_time / 60)) local seconds=$((total_time % 60)) diff --git a/tests/test_upgrade_all_reporting.py b/tests/test_upgrade_all_reporting.py new file mode 100644 index 0000000..8b03cd7 --- /dev/null +++ b/tests/test_upgrade_all_reporting.py @@ -0,0 +1,319 @@ +"""Regression tests for make upgrade-all version and path reporting.""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +SCRIPT = PROJECT_ROOT / "scripts" / "upgrade_all.sh" + +skip_on_windows = pytest.mark.skipif(sys.platform == "win32", reason="Shell script tests require POSIX shell") + + +def _run_reporting_function(tmp_path: Path, before: str, after: str) -> subprocess.CompletedProcess[str]: + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + python = bin_dir / "python3" + python.write_text(f"#!/bin/sh\nprintf 'Python {after}\\n'\n") + python.chmod(0o755) + + bash_code = f""" +set -euo pipefail +GREEN= +RESET= +TOTAL_UPGRADED=0 +LOG_FILE={str(tmp_path / "upgrade.log")!r} +eval "$(sed -n '/^get_version()/,/^}}/p' {str(SCRIPT)!r})" +eval "$(sed -n '/^log_success_with_versions()/,/^}}/p' {str(SCRIPT)!r})" +eval "$(sed -n '/^log_success_with_info()/,/^}}/p' {str(SCRIPT)!r})" +log_success_with_info \ + "Python" \ + "python3" \ + "python3 --version | awk '{{print \\$2}}'" \ + {before!r} +""" + env = {**os.environ, "PATH": f"{bin_dir}:{os.environ['PATH']}"} + return subprocess.run( + ["bash", "-c", bash_code], + capture_output=True, + text=True, + env=env, + ) + + +def _run_unavailable_reporting(tmp_path: Path) -> subprocess.CompletedProcess[str]: + bash_code = f""" +set -euo pipefail +GREEN= +RESET= +TOTAL_UPGRADED=0 +LOG_FILE={str(tmp_path / "upgrade.log")!r} +eval "$(sed -n '/^log_success_with_versions()/,/^}}/p' {str(SCRIPT)!r})" +log_success_with_versions "Mystery" "missing-binary" "unknown" "unknown" +""" + return subprocess.run( + ["bash", "-c", bash_code], + capture_output=True, + text=True, + ) + + +def _run_uv_tool_upgrade(tmp_path: Path) -> subprocess.CompletedProcess[str]: + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + version_file = tmp_path / "ansible-core-version" + version_file.write_text("2.20.0") + + uv = bin_dir / "uv" + uv.write_text("""#!/bin/sh +if [ "$1 $2" = "tool list" ]; then + printf 'ansible-core v%s\\n- ansible\\n' "$(cat "$UV_VERSION_FILE")" +elif [ "$1 $2 $3" = "tool upgrade ansible-core" ]; then + printf '2.21.0' > "$UV_VERSION_FILE" +else + exit 1 +fi +""") + uv.chmod(0o755) + + ansible = bin_dir / "ansible" + ansible.write_text("#!/bin/sh\nexit 0\n") + ansible.chmod(0o755) + + bash_code = f""" +set -euo pipefail +GREEN= +RESET= +BLUE= +YELLOW= +DRY_RUN=0 +TOTAL_UPGRADED=0 +TOTAL_SKIPPED=0 +LOG_FILE={str(tmp_path / "upgrade.log")!r} +log_info() {{ :; }} +log_skip() {{ :; }} +log_success() {{ :; }} +eval "$(sed -n '/^log_success_with_versions()/,/^}}/p' {str(SCRIPT)!r})" +eval "$(sed -n '/^get_uv_tool_version()/,/^}}/p' {str(SCRIPT)!r})" +eval "$(sed -n '/^get_uv_tool_binary()/,/^}}/p' {str(SCRIPT)!r})" +eval "$(sed -n '/^upgrade_uv_tools()/,/^}}/p' {str(SCRIPT)!r})" +upgrade_uv_tools +""" + env = { + **os.environ, + "PATH": f"{bin_dir}:{os.environ['PATH']}", + "UV_VERSION_FILE": str(version_file), + } + return subprocess.run( + ["bash", "-c", bash_code], + capture_output=True, + text=True, + env=env, + ) + + +def _run_system_manager_stage(tmp_path: Path) -> subprocess.CompletedProcess[str]: + project = tmp_path / "project" + project.mkdir() + + bash_code = f""" +set -euo pipefail +GREEN= +RESET= +BLUE= +YELLOW= +DRY_RUN=0 +TOTAL_UPGRADED=0 +TOTAL_SKIPPED=0 +PROJECT_ROOT={str(project)!r} +LOG_FILE={str(tmp_path / "upgrade.log")!r} +log_stage() {{ :; }} +log_info() {{ :; }} +log_skip() {{ :; }} +log_fail() {{ :; }} +log_reconcile() {{ :; }} +apt-get() {{ + if [ "${{1:-}}" = "--version" ]; then + printf 'apt 2.9.0\\n' + fi +}} +sudo() {{ "$@"; }} +command() {{ + if [ "${{1:-}}" = "-v" ]; then + if [ "${{2:-}}" = "apt-get" ]; then + printf '/fake/apt-get\\n' + return 0 + fi + return 1 + fi + builtin command "$@" +}} +eval "$(sed -n '/^get_version()/,/^}}/p' {str(SCRIPT)!r})" +eval "$(sed -n '/^log_success_with_versions()/,/^}}/p' {str(SCRIPT)!r})" +eval "$(sed -n '/^log_success_with_info()/,/^}}/p' {str(SCRIPT)!r})" +eval "$(sed -n '/^stage_2_managers()/,/^}}/p' {str(SCRIPT)!r})" +stage_2_managers +""" + return subprocess.run( + ["bash", "-c", bash_code], + capture_output=True, + text=True, + ) + + +def _run_python_module_pip_stage(tmp_path: Path) -> subprocess.CompletedProcess[str]: + project = tmp_path / "project" + project.mkdir() + + bash_code = f""" +set -euo pipefail +GREEN= +RESET= +BLUE= +YELLOW= +DRY_RUN=0 +TOTAL_UPGRADED=0 +TOTAL_SKIPPED=0 +PROJECT_ROOT={str(project)!r} +LOG_FILE={str(tmp_path / "upgrade.log")!r} +log_stage() {{ :; }} +log_info() {{ :; }} +log_skip() {{ :; }} +log_fail() {{ :; }} +log_reconcile() {{ :; }} +python3() {{ + if [ "${{1:-}} ${{2:-}} ${{3:-}}" = "-m pip --version" ]; then + printf 'pip 24.0 from /fake/site-packages/pip (python 3.14)\\n' + elif [ "${{1:-}} ${{2:-}} ${{3:-}}" = "-m pip install" ]; then + return 0 + else + return 1 + fi +}} +command() {{ + if [ "${{1:-}}" = "-v" ]; then + if [ "${{2:-}}" = "python3" ]; then + printf '/fake/python3\\n' + return 0 + fi + return 1 + fi + builtin command "$@" +}} +eval "$(sed -n '/^get_version()/,/^}}/p' {str(SCRIPT)!r})" +eval "$(sed -n '/^log_success_with_versions()/,/^}}/p' {str(SCRIPT)!r})" +eval "$(sed -n '/^log_success_with_info()/,/^}}/p' {str(SCRIPT)!r})" +eval "$(sed -n '/^stage_2_managers()/,/^}}/p' {str(SCRIPT)!r})" +stage_2_managers +""" + return subprocess.run( + ["bash", "-c", bash_code], + capture_output=True, + text=True, + ) + + +def _run_python_runtime_stage(tmp_path: Path) -> subprocess.CompletedProcess[str]: + project = tmp_path / "project" + scripts = project / "scripts" + scripts.mkdir(parents=True) + version_file = tmp_path / "python-version" + version_file.write_text("3.14.5") + + installer = scripts / "install_python.sh" + installer.write_text("#!/bin/sh\nprintf '3.14.6' > \"$VERSION_FILE\"\n") + installer.chmod(0o755) + + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + python = bin_dir / "python3" + python.write_text('#!/bin/sh\nprintf \'Python %s\\n\' "$(cat "$VERSION_FILE")"\n') + python.chmod(0o755) + + bash_code = f""" +set -euo pipefail +GREEN= +RESET= +BLUE= +YELLOW= +DRY_RUN=0 +TOTAL_UPGRADED=0 +TOTAL_SKIPPED=0 +PROJECT_ROOT={str(project)!r} +LOG_FILE={str(tmp_path / "upgrade.log")!r} +log_stage() {{ :; }} +log_skip() {{ :; }} +log_success() {{ :; }} +eval "$(sed -n '/^get_version()/,/^}}/p' {str(SCRIPT)!r})" +eval "$(sed -n '/^log_success_with_versions()/,/^}}/p' {str(SCRIPT)!r})" +eval "$(sed -n '/^log_success_with_info()/,/^}}/p' {str(SCRIPT)!r})" +eval "$(sed -n '/^stage_3_runtimes()/,/^}}/p' {str(SCRIPT)!r})" +stage_3_runtimes +""" + env = { + **os.environ, + "PATH": f"{bin_dir}:{os.environ['PATH']}", + "VERSION_FILE": str(version_file), + } + return subprocess.run( + ["bash", "-c", bash_code], + capture_output=True, + text=True, + env=env, + ) + + +@skip_on_windows +class TestUpgradeAllVersionReporting: + def test_reports_old_and_new_version_with_real_binary_path(self, tmp_path): + result = _run_reporting_function(tmp_path, "3.14.5", "3.14.6") + + assert result.returncode == 0, result.stderr + assert "Python (3.14.5 → 3.14.6 at " in result.stdout + assert str(tmp_path / "bin" / "python3") in result.stdout + assert "unknown" not in result.stdout + + def test_reports_unchanged_when_update_did_not_change_version(self, tmp_path): + result = _run_reporting_function(tmp_path, "3.14.6", "3.14.6") + + assert result.returncode == 0, result.stderr + assert "Python (3.14.6 unchanged at " in result.stdout + + def test_does_not_call_unavailable_versions_unchanged(self, tmp_path): + result = _run_unavailable_reporting(tmp_path) + + assert result.returncode == 0, result.stderr + assert "version unavailable" in result.stdout + assert "unknown unchanged" not in result.stdout + + def test_runtime_stage_uses_python3_for_version_and_path(self, tmp_path): + result = _run_python_runtime_stage(tmp_path) + + assert result.returncode == 0, result.stderr + assert "Python (3.14.5 → 3.14.6 at " in result.stdout + assert str(tmp_path / "bin" / "python3") in result.stdout + + def test_uv_tool_uses_package_version_and_exposed_binary(self, tmp_path): + result = _run_uv_tool_upgrade(tmp_path) + + assert result.returncode == 0, result.stderr + assert "ansible-core (2.20.0 → 2.21.0 at " in result.stdout + assert str(tmp_path / "bin" / "ansible") in result.stdout + assert "unknown" not in result.stdout + + def test_system_package_manager_reports_version_and_binary_path(self, tmp_path): + result = _run_system_manager_stage(tmp_path) + + assert result.returncode == 0, result.stderr + assert "apt (system) (2.9.0 unchanged at /fake/apt-get)" in result.stdout + + def test_pip_module_does_not_require_separate_pip3_launcher(self, tmp_path): + result = _run_python_module_pip_stage(tmp_path) + + assert result.returncode == 0, result.stderr + assert "pip (python3 -m pip) (24.0 unchanged at /fake/python3)" in result.stdout