#!/usr/bin/env python3 """dependency_freshness.py - Compare installed dependencies against latest PyPI versions. Identify packages that are more than 2 major versions behind. Outputs a human-readable report by default or JSON with --json flag. """ import argparse import json import subprocess import sys from packaging import version from typing import Dict, List, Tuple def parse_requirements(requirements_path: str) -> List[str]: """Parse package names from a requirements.txt file.""" packages = [] try: with open(requirements_path, 'r') as f: for line in f: line = line.strip() if not line or line.startswith('#'): continue pkg_name = line for delim in ['[', '>', '<', '=', '!', ';', '@']: if delim in pkg_name: pkg_name = pkg_name.split(delim)[0] pkg_name = pkg_name.strip() if pkg_name: packages.append(pkg_name.lower()) except FileNotFoundError: print(f"Warning: requirements file not found: {requirements_path}", file=sys.stderr) return packages def get_installed_packages() -> Dict[str, str]: """Get all installed packages via pip list --format=json.""" try: result = subprocess.run( [sys.executable, '-m', 'pip', 'list', '--format=json'], capture_output=True, text=True, check=True ) packages = json.loads(result.stdout) return {pkg['name'].lower(): pkg['version'] for pkg in packages} except subprocess.CalledProcessError as e: print(f"Error running pip list: {e}", file=sys.stderr) sys.exit(1) except json.JSONDecodeError as e: print(f"Error parsing pip output: {e}", file=sys.stderr) sys.exit(1) def get_outdated_packages() -> Dict[str, dict]: """Get outdated packages via pip list --outdated --format=json.""" try: result = subprocess.run( [sys.executable, '-m', 'pip', 'list', '--outdated', '--format=json'], capture_output=True, text=True, check=True ) outdated_list = json.loads(result.stdout) outdated = {} for pkg in outdated_list: name = pkg['name'].lower() outdated[name] = { 'installed': pkg.get('version', ''), 'latest': pkg.get('latest_version', ''), 'latest_filetype': pkg.get('latest_filetype', '') } return outdated except subprocess.CalledProcessError as e: print(f"Error running pip list --outdated: {e}", file=sys.stderr) sys.exit(1) except json.JSONDecodeError as e: print(f"Error parsing pip outdated output: {e}", file=sys.stderr) sys.exit(1) def get_major_version(v: str) -> int: """Extract major version number from a version string.""" try: parsed = version.parse(v) if hasattr(parsed, 'major'): return int(parsed.major) parts = str(v).split('.') if parts: return int(parts[0]) except Exception: pass return 0 def is_more_than_two_majors_behind(installed_ver: str, latest_ver: str) -> bool: """Check if installed version is more than 2 major versions behind latest.""" try: installed_major = get_major_version(installed_ver) latest_major = get_major_version(latest_ver) return (latest_major - installed_major) > 2 except Exception: return False def analyze_dependencies( required_packages: List[str], installed_packages: Dict[str, str], outdated_packages: Dict[str, dict] ) -> Tuple[List[dict], List[str], List[dict]]: """Analyze dependency freshness.""" very_outdated = [] missing = [] outdated_but_not_critical = [] for pkg in required_packages: if pkg not in installed_packages: missing.append(pkg) continue installed_ver = installed_packages[pkg] if pkg not in outdated_packages: continue latest_ver = outdated_packages[pkg]['latest'] if is_more_than_two_majors_behind(installed_ver, latest_ver): very_outdated.append({ 'package': pkg, 'installed': installed_ver, 'latest': latest_ver, 'major_diff': get_major_version(latest_ver) - get_major_version(installed_ver) }) else: outdated_but_not_critical.append({ 'package': pkg, 'installed': installed_ver, 'latest': latest_ver, 'major_diff': get_major_version(latest_ver) - get_major_version(installed_ver) }) return very_outdated, missing, outdated_but_not_critical def generate_human_report( very_outdated: List[dict], missing: List[str], outdated_but_not_critical: List[dict], requirements_path: str ) -> str: """Generate a human-readable staleness report.""" lines = [] lines.append("=" * 60) lines.append("DEPENDENCY FRESHNESS REPORT") lines.append("=" * 60) lines.append(f"Requirements file: {requirements_path}") total = len(very_outdated) + len(missing) + len(outdated_but_not_critical) lines.append(f"Total dependencies checked: {total}") lines.append(f"Very outdated (>2 major versions behind): {len(very_outdated)}") lines.append(f"Outdated but within 2 major versions: {len(outdated_but_not_critical)}") lines.append(f"Missing (not installed): {len(missing)}") lines.append("") if very_outdated: lines.append("!!! VERY OUTDATED PACKAGES (consider updating):") lines.append("-" * 60) for pkg_info in very_outdated: lines.append(f" {pkg_info['package']}") lines.append(f" Installed: {pkg_info['installed']}") lines.append(f" Latest: {pkg_info['latest']}") lines.append(f" Major diff: {pkg_info['major_diff']}") lines.append("") else: lines.append("✓ No packages more than 2 major versions behind.") lines.append("") if outdated_but_not_critical: lines.append(f"Outdated packages (within 2 major versions):") lines.append("-" * 60) for pkg_info in outdated_but_not_critical: lines.append(f" {pkg_info['package']}: {pkg_info['installed']} -> {pkg_info['latest']} (major diff: {pkg_info['major_diff']})") lines.append("") if missing: lines.append(f"Missing packages (not installed):") lines.append("-" * 60) for pkg in missing: lines.append(f" {pkg}") lines.append("") lines.append("=" * 60) lines.append("For full details, run: python3 -m pip list --outdated") lines.append("=" * 60) return "\n".join(lines) def generate_json_report( very_outdated: List[dict], missing: List[str], outdated_but_not_critical: List[dict], requirements_path: str ) -> str: """Generate a JSON staleness report.""" report = { 'requirements_file': requirements_path, 'summary': { 'total_dependencies': len(very_outdated) + len(missing) + len(outdated_but_not_critical), 'very_outdated_count': len(very_outdated), 'outdated_within_threshold_count': len(outdated_but_not_critical), 'missing_count': len(missing) }, 'very_outdated': very_outdated, 'outdated_within_threshold': outdated_but_not_critical, 'missing': missing } return json.dumps(report, indent=2) def main(): parser = argparse.ArgumentParser( description='Check dependency freshness against PyPI latest versions.' ) parser.add_argument( '--requirements', '-r', default='requirements.txt', help='Path to requirements.txt file (default: requirements.txt)' ) parser.add_argument( '--json', action='store_true', help='Output report as JSON instead of human-readable text' ) parser.add_argument( '--output', '-o', help='Optional output file for the report (default: stdout)' ) args = parser.parse_args() # Parse requirements required_packages = parse_requirements(args.requirements) if not required_packages: print("No packages found in requirements file.", file=sys.stderr) sys.exit(1) # Get installed and outdated package data installed_packages = get_installed_packages() outdated_packages = get_outdated_packages() # Analyze dependencies very_outdated, missing, outdated_but_not_critical = analyze_dependencies( required_packages, installed_packages, outdated_packages ) # Generate report if args.json: report = generate_json_report(very_outdated, missing, outdated_but_not_critical, args.requirements) else: report = generate_human_report(very_outdated, missing, outdated_but_not_critical, args.requirements) # Output report if args.output: with open(args.output, 'w') as f: f.write(report + '\n') else: print(report) # Exit code: 0 if no very outdated deps, 1 otherwise exit_code = 1 if very_outdated else 0 sys.exit(exit_code) if __name__ == '__main__': main()