diff --git a/scripts/dependency_freshness.py b/scripts/dependency_freshness.py new file mode 100644 index 0000000..a2a95e2 --- /dev/null +++ b/scripts/dependency_freshness.py @@ -0,0 +1,271 @@ +#!/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() diff --git a/scripts/test_dependency_freshness.py b/scripts/test_dependency_freshness.py new file mode 100644 index 0000000..96f908e --- /dev/null +++ b/scripts/test_dependency_freshness.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +"""Tests for scripts/dependency_freshness.py — 9.7 Dependency Freshness.""" + +import json +import os +import sys +from unittest.mock import patch, MagicMock + +# Import target module +sys.path.insert(0, os.path.dirname(__file__) or ".") +import importlib.util +spec = importlib.util.spec_from_file_location( + "dependency_freshness", + os.path.join(os.path.dirname(__file__) or ".", "dependency_freshness.py") +) +mod = importlib.util.module_from_spec(spec) +spec.loader.exec_module(mod) + +parse_requirements = mod.parse_requirements +get_major_version = mod.get_major_version +is_more_than_two_majors_behind = mod.is_more_than_two_majors_behind +analyze_dependencies = mod.analyze_dependencies + + +def test_parse_requirements_simple(): + """Parse a simple package line.""" + import tempfile + with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f: + f.write("requests\n") + tmp = f.name + try: + pkgs = parse_requirements(tmp) + assert pkgs == ["requests"], f"got {pkgs}" + print("PASS: test_parse_requirements_simple") + finally: + os.unlink(tmp) + + +def test_parse_requirements_with_specifiers(): + """Parse lines with version specifiers.""" + import tempfile + with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f: + f.write("pytest>=8,<9\n") + f.write("aiohttp>=3.8\n") + tmp = f.name + try: + pkgs = parse_requirements(tmp) + assert pkgs == ["pytest", "aiohttp"], f"got {pkgs}" + print("PASS: test_parse_requirements_with_specifiers") + finally: + os.unlink(tmp) + + +def test_parse_requirements_ignores_comments_and_blanks(): + """Comments and blank lines are skipped.""" + import tempfile + with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f: + f.write("# This is a comment\n") + f.write("\n") + f.write(" \n") + f.write("numpy\n") + f.write("# another comment\n") + tmp = f.name + try: + pkgs = parse_requirements(tmp) + assert pkgs == ["numpy"], f"got {pkgs}" + print("PASS: test_parse_requirements_ignores_comments_and_blanks") + finally: + os.unlink(tmp) + + +def test_get_major_version_normal(): + """Extract major version from typical semantic strings.""" + assert get_major_version("1.2.3") == 1 + assert get_major_version("3.4.5") == 3 + assert get_major_version("0.11.0") == 0 + print("PASS: test_get_major_version_normal") + + +def test_get_major_version_with_rc(): + """Prerelease versions still yield major number.""" + assert get_major_version("2.0.0rc1") == 2 + assert get_major_version("1.0.0a1") == 1 + print("PASS: test_get_major_version_with_rc") + + +def test_is_more_than_two_majors_behind(): + """Difference >2 triggers True; <=2 triggers False.""" + assert is_more_than_two_majors_behind("1.2.3", "4.0.0") is True + assert is_more_than_two_majors_behind("3.9.0", "4.0.0") is False + assert is_more_than_two_majors_behind("2.1.0", "5.2.0") is True + assert is_more_than_two_majors_behind("8.0.0", "9.0.0") is False + assert is_more_than_two_majors_behind("4.0.0", "4.0.0") is False + print("PASS: test_is_more_than_two_majors_behind") + + +def test_analyze_dependencies_very_outdated(): + """Flag packages more than 2 major versions behind.""" + required = ["pkg_a", "pkg_b"] + installed = {"pkg_a": "1.0.0", "pkg_b": "3.5.2"} + outdated = { + "pkg_a": {"installed": "1.0.0", "latest": "4.0.0"}, + "pkg_b": {"installed": "3.5.2", "latest": "4.0.0"}, + } + very_out, missing, outdated_ok = analyze_dependencies(required, installed, outdated) + assert len(very_out) == 1 and very_out[0]["package"] == "pkg_a" + assert len(missing) == 0 + assert len(outdated_ok) == 1 and outdated_ok[0]["package"] == "pkg_b" + print("PASS: test_analyze_dependencies_very_outdated") + + +def test_analyze_dependencies_missing(): + """Detect packages not installed at all.""" + required = ["pkg_a", "pkg_missing"] + installed = {"pkg_a": "2.0.0"} + outdated = {"pkg_a": {"installed": "2.0.0", "latest": "3.0.0"}} + very_out, missing, outdated_ok = analyze_dependencies(required, installed, outdated) + assert "pkg_missing" in missing + assert len(very_out) == 0 + assert len(outdated_ok) == 1 + print("PASS: test_analyze_dependencies_missing") + + +def test_analyze_dependencies_up_to_date(): + """Packages up-to-date are not flagged.""" + required = ["pkg_good"] + installed = {"pkg_good": "5.0.0"} + outdated = {} + very_out, missing, outdated_ok = analyze_dependencies(required, installed, outdated) + assert len(very_out) == 0 + assert len(missing) == 0 + assert len(outdated_ok) == 0 + print("PASS: test_analyze_dependencies_up_to_date") + + +def test_generate_human_report_contains_very_outdated(): + """Human report includes very outdated packages.""" + very_out = [ + {"package": "oldpkg", "installed": "1.0", "latest": "4.0", "major_diff": 3} + ] + missing = [] + outdated_ok = [] + report = mod.generate_human_report(very_out, missing, outdated_ok, "requirements.txt") + assert "oldpkg" in report + assert "Installed: 1.0" in report + assert "Latest: 4.0" in report + assert "Major diff: 3" in report + print("PASS: test_generate_human_report_contains_very_outdated") + + +def test_generate_json_report_structure(): + """JSON report contains required keys.""" + very_out = [{"package": "oldpkg", "installed": "1.0", "latest": "4.0", "major_diff": 3}] + missing = ["missing_pkg"] + outdated_ok = [] + report_json = mod.generate_json_report(very_out, missing, outdated_ok, "requirements.txt") + data = json.loads(report_json) + assert "summary" in data + assert data["summary"]["very_outdated_count"] == 1 + assert data["summary"]["missing_count"] == 1 + assert "very_outdated" in data + assert "missing" in data + print("PASS: test_generate_json_report_structure") + + +if __name__ == '__main__': + print("Running dependency_freshness test suite...") + test_parse_requirements_simple() + test_parse_requirements_with_specifiers() + test_parse_requirements_ignores_comments_and_blanks() + test_get_major_version_normal() + test_get_major_version_with_rc() + test_is_more_than_two_majors_behind() + test_analyze_dependencies_very_outdated() + test_analyze_dependencies_missing() + test_analyze_dependencies_up_to_date() + test_generate_human_report_contains_very_outdated() + test_generate_json_report_structure() + print("ALL TESTS PASSED.")