From 80f82c9ecd32e81e50632aa3a710c5b13ec5467d Mon Sep 17 00:00:00 2001 From: Alexander Payne Date: Sun, 26 Apr 2026 09:23:39 -0400 Subject: [PATCH] =?UTF-8?q?feat(5.3):=20Add=20Update=20Checker=20=E2=80=94?= =?UTF-8?q?=20compare=20installed=20vs=20latest=20versions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add scripts/update_checker.py — dependency health monitor that checks installed Python packages against PyPI latest, classifies updates by semver (major/minor/patch), flags breaking changes, and outputs a human-readable or JSON report. Acceptance criteria: ✓ Compares installed vs latest via pip list + PyPI JSON API ✓ Reports major/minor/patch updates with severity (high/medium/low) ✓ Flags breaking changes (major version jumps) ✓ Output: formatted text report or --json machine report Also adds comprehensive test suite (11 tests, all passing). Refs: #109 --- scripts/test_update_checker.py | 212 ++++++++++++++++++++++++++++ scripts/update_checker.py | 246 +++++++++++++++++++++++++++++++++ tests/test_update_checker.py | 212 ++++++++++++++++++++++++++++ 3 files changed, 670 insertions(+) create mode 100644 scripts/test_update_checker.py create mode 100644 scripts/update_checker.py create mode 100644 tests/test_update_checker.py diff --git a/scripts/test_update_checker.py b/scripts/test_update_checker.py new file mode 100644 index 0000000..20e7abe --- /dev/null +++ b/scripts/test_update_checker.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +""" +Tests for update_checker.py — 5.3: Update Checker + +Acceptance criteria verified: + ✓ Compares installed vs latest + ✓ Reports major/minor/patch updates + ✓ Flags breaking changes (major) + ✓ Output: update report +""" + +import json +import os +import subprocess +import sys +import tempfile +from datetime import datetime +from pathlib import Path +from unittest.mock import patch, MagicMock + +# Add scripts dir to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts")) + +import update_checker as uc + + +def test_parse_version(): + assert uc.parse_version("1.2.3") == (1, 2, 3) + assert uc.parse_version("2.0.0") == (2, 0, 0) + assert uc.parse_version("0.9.0") == (0, 9, 0) + assert uc.parse_version("1.2") == (1, 2, 0) + assert uc.parse_version("1") == (1, 0, 0) + assert uc.parse_version("invalid") == (0, 0, 0) + print("PASS: parse_version") + + +def test_classify_update_patch(): + result = uc.classify_update("1.2.3", "1.2.4") + assert result is not None + assert result['update_type'] == 'patch' + assert result['breaking_change'] is False + assert result['severity'] == 'low' + print("PASS: classify_update_patch") + + +def test_classify_update_minor(): + result = uc.classify_update("1.2.3", "1.3.0") + assert result is not None + assert result['update_type'] == 'minor' + assert result['breaking_change'] is False + assert result['severity'] == 'medium' + print("PASS: classify_update_minor") + + +def test_classify_update_major(): + result = uc.classify_update("1.2.3", "2.0.0") + assert result is not None + assert result['update_type'] == 'major' + assert result['breaking_change'] is True + assert result['severity'] == 'high' + print("PASS: classify_update_major") + + +def test_classify_update_no_change(): + result = uc.classify_update("1.2.3", "1.2.3") + assert result is None + print("PASS: classify_update_no_change") + + +def test_classify_update_multiple_major(): + result = uc.classify_update("1.0.0", "3.0.0") + assert result is not None + assert result['update_type'] == 'major' + assert result['breaking_change'] is True + print("PASS: classify_update_multiple_major") + + +def test_text_report_format(): + updates = [{ + 'package': 'requests', + 'installed': '2.28.0', + 'latest': '2.31.0', + 'update_type': 'minor', + 'breaking_change': False, + 'severity': 'medium', + }] + report = uc.generate_text_report(updates) + assert 'DEPENDENCY UPDATE REPORT' in report + assert 'requests' in report + assert '2.28.0' in report + assert '2.31.0' in report + assert 'MINOR' in report + assert 'MEDIUM' in report + print("PASS: text_report_format") + + +def test_text_report_shows_breaking(): + updates = [{ + 'package': 'flask', + 'installed': '2.0.0', + 'latest': '3.0.0', + 'update_type': 'major', + 'breaking_change': True, + 'severity': 'high', + }] + report = uc.generate_text_report(updates) + assert 'BREAKING CHANGE' in report.upper() or '⚠' in report + print("PASS: text_report_shows_breaking") + + +def test_json_report_structure(): + updates = [ + { + 'package': 'pytest', + 'installed': '8.0.0', + 'latest': '8.2.0', + 'update_type': 'minor', + 'breaking_change': False, + 'severity': 'medium', + }, + { + 'package': 'flask', + 'installed': '2.0.0', + 'latest': '3.0.0', + 'update_type': 'major', + 'breaking_change': True, + 'severity': 'high', + } + ] + report_json = uc.generate_json_report(updates) + data = json.loads(report_json) + assert 'generated_at' in data + assert data['total_updates'] == 2 + assert 'summary' in data + assert data['summary']['major'] == 1 + assert data['summary']['minor'] == 1 + assert data['summary']['breaking'] == 1 + print("PASS: json_report_structure") + + +def test_no_updates_report(): + report = uc.generate_text_report([]) + assert 'up to date' in report.lower() or 'all packages' in report.lower() + print("PASS: no_updates_report") + + +def test_end_to_end_integration(): + """End-to-end: check_updates with mocked data produces valid report.""" + fake_installed = { + "test-pkg-old": "1.0.0", + "another-pkg": "2.5.3", + } + + def fake_get_latest(pkg): + if pkg == "test-pkg-old": + return "1.2.4" + elif pkg == "another-pkg": + return "3.0.0" + return None + + with patch('update_checker.get_installed_packages', return_value=fake_installed): + with patch('update_checker.get_latest_version', side_effect=fake_get_latest): + updates = uc.check_updates() + + assert len(updates) == 2 + + test_pkg = next(u for u in updates if u['package'] == 'test-pkg-old') + assert test_pkg['update_type'] == 'minor' + assert test_pkg['breaking_change'] is False + + another = next(u for u in updates if u['package'] == 'another-pkg') + assert another['update_type'] == 'major' + assert another['breaking_change'] is True + + report = uc.generate_text_report(updates) + assert 'DEPENDENCY UPDATE REPORT' in report + assert 'MINOR' in report + assert 'BREAKING CHANGE' in report.upper() + + print(f"PASS: end_to_end_integration ({len(updates)} updates)") + + +if __name__ == "__main__": + passed = 0 + failed = 0 + tests = [ + test_parse_version, + test_classify_update_patch, + test_classify_update_minor, + test_classify_update_major, + test_classify_update_no_change, + test_classify_update_multiple_major, + test_text_report_format, + test_text_report_shows_breaking, + test_json_report_structure, + test_no_updates_report, + test_end_to_end_integration, + ] + for test_func in tests: + try: + test_func() + passed += 1 + except AssertionError as e: + print(f"FAIL: {test_func.__name__} — {e}") + failed += 1 + except Exception as e: + print(f"ERROR: {test_func.__name__} — {e}") + import traceback + traceback.print_exc() + failed += 1 + print(f"\n{passed} passed, {failed} failed") + sys.exit(0 if failed == 0 else 1) diff --git a/scripts/update_checker.py b/scripts/update_checker.py new file mode 100644 index 0000000..076983f --- /dev/null +++ b/scripts/update_checker.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +""" +5.3: Update Checker — Compare installed vs latest package versions + +Check if dependencies have newer versions available. Query PyPI for each +installed package, compare versions, and generate an update report with +major/minor/patch classification and breaking change flags. + +Usage: + python3 scripts/update_checker.py + python3 scripts/update_checker.py --json + python3 scripts/update_checker.py --output updates.md + python3 scripts/update_checker.py --package requests,pytest +""" + +import argparse +import json +import subprocess +import sys +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Optional, Tuple +from urllib.request import urlopen +from urllib.error import URLError, HTTPError + + +def get_installed_packages() -> Dict[str, str]: + """Get all installed packages via pip list --format=json.""" + try: + result = subprocess.run( + ['pip', 'list', '--format=json'], + capture_output=True, text=True, timeout=30 + ) + if result.returncode != 0: + print(f"Warning: pip list failed: {result.stderr}", file=sys.stderr) + return {} + packages = json.loads(result.stdout) + return {p['name'].lower(): p['version'] for p in packages} + except (json.JSONDecodeError, subprocess.TimeoutExpired, KeyError) as e: + print(f"Warning: failed to parse pip list: {e}", file=sys.stderr) + return {} + + +def get_latest_version(package_name: str) -> Optional[str]: + """Query PyPI JSON API for the latest version of a package.""" + url = f"https://pypi.org/pypi/{package_name}/json" + try: + with urlopen(url, timeout=10) as resp: + if resp.status == 200: + data = json.loads(resp.read()) + return data.get('info', {}).get('version') + except (URLError, HTTPError, json.JSONDecodeError, TimeoutError): + pass + return None + + +def parse_version(version_str: str) -> Tuple[int, int, int]: + """Parse semantic version string into (major, minor, patch).""" + # Strip any extras like dev, post, rc + cleaned = version_str.split('.')[0:3] + # Pad to 3 parts + while len(cleaned) < 3: + cleaned.append('0') + try: + major = int(cleaned[0]) if cleaned[0].isdigit() else 0 + minor = int(cleaned[1]) if len(cleaned) > 1 and cleaned[1].isdigit() else 0 + patch = int(cleaned[2]) if len(cleaned) > 2 and cleaned[2].isdigit() else 0 + return (major, minor, patch) + except (ValueError, IndexError): + return (0, 0, 0) + + +def classify_update(installed: str, latest: str) -> Optional[Dict]: + """Determine update type between installed and latest versions.""" + if not latest: + return None + + inst_ver = parse_version(installed) + latest_ver = parse_version(latest) + + if inst_ver == latest_ver: + return None # Already up to date + + # Calculate delta + major_diff = latest_ver[0] - inst_ver[0] + minor_diff = latest_ver[1] - inst_ver[1] + patch_diff = latest_ver[2] - inst_ver[2] + + # Determine update type + if major_diff > 0: + update_type = 'major' + breaking = True + severity = 'high' + elif minor_diff > 0: + update_type = 'minor' + breaking = False + severity = 'medium' + elif patch_diff > 0: + update_type = 'patch' + breaking = False + severity = 'low' + else: + # Shouldn't happen but handle weird cases + return None + + return { + 'package': None, # filled by caller + 'installed': installed, + 'latest': latest, + 'update_type': update_type, + 'breaking_change': breaking, + 'severity': severity, + } + + +def check_updates(packages: Dict[str, str] = None, + filter_packages: List[str] = None) -> List[Dict]: + """ + Check all installed packages (or filtered subset) for updates. + + Args: + packages: Dict of {name: version}. If None, queries pip list. + filter_packages: Optional list of package names to check only. + + Returns: + List of update report dicts sorted by severity. + """ + if packages is None: + packages = get_installed_packages() + + if filter_packages: + packages = {k: v for k, v in packages.items() + if k.lower() in [p.lower() for p in filter_packages]} + + updates = [] + print(f"Checking {len(packages)} packages...", file=sys.stderr) + + for pkg_name, installed_ver in packages.items(): + latest_ver = get_latest_version(pkg_name) + if not latest_ver: + continue + + update_info = classify_update(installed_ver, latest_ver) + if update_info: + update_info['package'] = pkg_name + updates.append(update_info) + + # Sort: breaking first, then severity, then package name + updates.sort(key=lambda u: ( + -1 if u['breaking_change'] else 0, + {'high': 0, 'medium': 1, 'low': 2}[u['severity']], + u['package'] + )) + + return updates + + +def generate_text_report(updates: List[Dict]) -> str: + """Generate human-readable text report.""" + lines = [] + lines.append("=" * 60) + lines.append("DEPENDENCY UPDATE REPORT") + lines.append(f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + lines.append("=" * 60) + lines.append("") + + if not updates: + lines.append("✓ All packages are up to date.") + return "\n".join(lines) + + lines.append(f"Found {len(updates)} package(s) with available updates:") + lines.append("") + + for u in updates: + breaking_marker = " ⚠ BREAKING CHANGE" if u['breaking_change'] else "" + lines.append(f" {u['package']}:") + lines.append(f" Installed: {u['installed']}") + lines.append(f" Latest: {u['latest']}") + lines.append(f" Update: {u['update_type'].upper()}{breaking_marker}") + lines.append(f" Severity: {u['severity'].upper()}") + lines.append("") + + lines.append("=" * 60) + lines.append("Recommendation: Review breaking changes carefully before upgrading.") + lines.append("Consider pinning versions or using a virtual environment.") + return "\n".join(lines) + + +def generate_json_report(updates: List[Dict]) -> str: + """Generate JSON report compatible with machine consumption.""" + report = { + 'generated_at': datetime.now().isoformat(), + 'total_updates': len(updates), + 'updates': updates, + 'summary': { + 'major': sum(1 for u in updates if u['update_type'] == 'major'), + 'minor': sum(1 for u in updates if u['update_type'] == 'minor'), + 'patch': sum(1 for u in updates if u['update_type'] == 'patch'), + 'breaking': sum(1 for u in updates if u['breaking_change']), + } + } + return json.dumps(report, indent=2) + + +def main(): + parser = argparse.ArgumentParser( + description="Check dependencies for available updates" + ) + parser.add_argument( + '--json', action='store_true', + help='Output JSON report for machine consumption' + ) + parser.add_argument( + '--output', '-o', type=str, + help='Write report to file instead of stdout' + ) + parser.add_argument( + '--package', '-p', type=str, + help='Comma-separated list of specific packages to check' + ) + args = parser.parse_args() + + # Build filter list if provided + filter_list = None + if args.package: + filter_list = [p.strip() for p in args.package.split(',') if p.strip()] + + # Run checks + updates = check_updates(filter_packages=filter_list) + + # Generate report + if args.json: + report = generate_json_report(updates) + else: + report = generate_text_report(updates) + + # Output + if args.output: + Path(args.output).write_text(report) + print(f"Report written to {args.output}", file=sys.stderr) + else: + print(report) + + +if __name__ == '__main__': + main() diff --git a/tests/test_update_checker.py b/tests/test_update_checker.py new file mode 100644 index 0000000..20e7abe --- /dev/null +++ b/tests/test_update_checker.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +""" +Tests for update_checker.py — 5.3: Update Checker + +Acceptance criteria verified: + ✓ Compares installed vs latest + ✓ Reports major/minor/patch updates + ✓ Flags breaking changes (major) + ✓ Output: update report +""" + +import json +import os +import subprocess +import sys +import tempfile +from datetime import datetime +from pathlib import Path +from unittest.mock import patch, MagicMock + +# Add scripts dir to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts")) + +import update_checker as uc + + +def test_parse_version(): + assert uc.parse_version("1.2.3") == (1, 2, 3) + assert uc.parse_version("2.0.0") == (2, 0, 0) + assert uc.parse_version("0.9.0") == (0, 9, 0) + assert uc.parse_version("1.2") == (1, 2, 0) + assert uc.parse_version("1") == (1, 0, 0) + assert uc.parse_version("invalid") == (0, 0, 0) + print("PASS: parse_version") + + +def test_classify_update_patch(): + result = uc.classify_update("1.2.3", "1.2.4") + assert result is not None + assert result['update_type'] == 'patch' + assert result['breaking_change'] is False + assert result['severity'] == 'low' + print("PASS: classify_update_patch") + + +def test_classify_update_minor(): + result = uc.classify_update("1.2.3", "1.3.0") + assert result is not None + assert result['update_type'] == 'minor' + assert result['breaking_change'] is False + assert result['severity'] == 'medium' + print("PASS: classify_update_minor") + + +def test_classify_update_major(): + result = uc.classify_update("1.2.3", "2.0.0") + assert result is not None + assert result['update_type'] == 'major' + assert result['breaking_change'] is True + assert result['severity'] == 'high' + print("PASS: classify_update_major") + + +def test_classify_update_no_change(): + result = uc.classify_update("1.2.3", "1.2.3") + assert result is None + print("PASS: classify_update_no_change") + + +def test_classify_update_multiple_major(): + result = uc.classify_update("1.0.0", "3.0.0") + assert result is not None + assert result['update_type'] == 'major' + assert result['breaking_change'] is True + print("PASS: classify_update_multiple_major") + + +def test_text_report_format(): + updates = [{ + 'package': 'requests', + 'installed': '2.28.0', + 'latest': '2.31.0', + 'update_type': 'minor', + 'breaking_change': False, + 'severity': 'medium', + }] + report = uc.generate_text_report(updates) + assert 'DEPENDENCY UPDATE REPORT' in report + assert 'requests' in report + assert '2.28.0' in report + assert '2.31.0' in report + assert 'MINOR' in report + assert 'MEDIUM' in report + print("PASS: text_report_format") + + +def test_text_report_shows_breaking(): + updates = [{ + 'package': 'flask', + 'installed': '2.0.0', + 'latest': '3.0.0', + 'update_type': 'major', + 'breaking_change': True, + 'severity': 'high', + }] + report = uc.generate_text_report(updates) + assert 'BREAKING CHANGE' in report.upper() or '⚠' in report + print("PASS: text_report_shows_breaking") + + +def test_json_report_structure(): + updates = [ + { + 'package': 'pytest', + 'installed': '8.0.0', + 'latest': '8.2.0', + 'update_type': 'minor', + 'breaking_change': False, + 'severity': 'medium', + }, + { + 'package': 'flask', + 'installed': '2.0.0', + 'latest': '3.0.0', + 'update_type': 'major', + 'breaking_change': True, + 'severity': 'high', + } + ] + report_json = uc.generate_json_report(updates) + data = json.loads(report_json) + assert 'generated_at' in data + assert data['total_updates'] == 2 + assert 'summary' in data + assert data['summary']['major'] == 1 + assert data['summary']['minor'] == 1 + assert data['summary']['breaking'] == 1 + print("PASS: json_report_structure") + + +def test_no_updates_report(): + report = uc.generate_text_report([]) + assert 'up to date' in report.lower() or 'all packages' in report.lower() + print("PASS: no_updates_report") + + +def test_end_to_end_integration(): + """End-to-end: check_updates with mocked data produces valid report.""" + fake_installed = { + "test-pkg-old": "1.0.0", + "another-pkg": "2.5.3", + } + + def fake_get_latest(pkg): + if pkg == "test-pkg-old": + return "1.2.4" + elif pkg == "another-pkg": + return "3.0.0" + return None + + with patch('update_checker.get_installed_packages', return_value=fake_installed): + with patch('update_checker.get_latest_version', side_effect=fake_get_latest): + updates = uc.check_updates() + + assert len(updates) == 2 + + test_pkg = next(u for u in updates if u['package'] == 'test-pkg-old') + assert test_pkg['update_type'] == 'minor' + assert test_pkg['breaking_change'] is False + + another = next(u for u in updates if u['package'] == 'another-pkg') + assert another['update_type'] == 'major' + assert another['breaking_change'] is True + + report = uc.generate_text_report(updates) + assert 'DEPENDENCY UPDATE REPORT' in report + assert 'MINOR' in report + assert 'BREAKING CHANGE' in report.upper() + + print(f"PASS: end_to_end_integration ({len(updates)} updates)") + + +if __name__ == "__main__": + passed = 0 + failed = 0 + tests = [ + test_parse_version, + test_classify_update_patch, + test_classify_update_minor, + test_classify_update_major, + test_classify_update_no_change, + test_classify_update_multiple_major, + test_text_report_format, + test_text_report_shows_breaking, + test_json_report_structure, + test_no_updates_report, + test_end_to_end_integration, + ] + for test_func in tests: + try: + test_func() + passed += 1 + except AssertionError as e: + print(f"FAIL: {test_func.__name__} — {e}") + failed += 1 + except Exception as e: + print(f"ERROR: {test_func.__name__} — {e}") + import traceback + traceback.print_exc() + failed += 1 + print(f"\n{passed} passed, {failed} failed") + sys.exit(0 if failed == 0 else 1)