Compare commits

..

1 Commits

Author SHA1 Message Date
Step35
1470b44c3b feat: add codebase genome diff script for structural change detection
Some checks failed
Test / pytest (pull_request) Failing after 9s
Introduces genome_diff.py — a tool for detecting structural changes between
two git refs: file-level changes, function/class signature modifications,
and dependency import changes.

Addresses #132.
2026-04-26 09:46:04 -04:00
4 changed files with 288 additions and 670 deletions

288
scripts/genome_diff.py Executable file
View File

@@ -0,0 +1,288 @@
#!/usr/bin/env python3
"""
Codebase Genome Diff — Detect structural changes between two versions.
Compares two git refs (commits, branches, tags) and produces a human-readable
report of structural changes:
• Added/removed/renamed files
• Changed functions/classes (signature modifications)
• New dependencies (imports, requirements, etc.)
Usage:
python3 scripts/genome_diff.py --ref1 <commit1> --ref2 <commit2>
python3 scripts/genome_diff.py --ref1 main --ref2 feature-branch
python3 scripts/genome_diff.py --ref1 v1.0 --ref2 v2.0 --output report.txt
"""
import argparse
import json
import os
import re
import subprocess
import sys
from dataclasses import dataclass, field
from typing import List, Dict, Any, Optional
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, SCRIPT_DIR)
from diff_analyzer import DiffAnalyzer, ChangeCategory
@dataclass
class FunctionChange:
file: str
name: str
kind: str # 'function' or 'class'
change_type: str # 'added' or 'removed' (simplified)
old_line: Optional[int] = None
new_line: Optional[int] = None
@dataclass
class DependencyChange:
file: str
module: str
change_type: str # 'added' or 'removed' or 'modified'
line: int = 0
@dataclass
class GenomeDiffReport:
ref1: str
ref2: str
file_changes: List[Dict[str, Any]] = field(default_factory=list)
function_changes: List[FunctionChange] = field(default_factory=list)
dependency_changes: List[DependencyChange] = field(default_factory=list)
total_files_changed: int = 0
total_functions_changed: int = 0
total_dependencies_changed: int = 0
def to_dict(self) -> Dict[str, Any]:
return {
"ref1": self.ref1,
"ref2": self.ref2,
"summary": {
"files": self.total_files_changed,
"functions": self.total_functions_changed,
"dependencies": self.total_dependencies_changed,
},
"file_changes": self.file_changes,
"function_changes": [fc.__dict__ for fc in self.function_changes],
"dependency_changes": [dc.__dict__ for dc in self.dependency_changes],
}
def human_report(self) -> str:
lines = []
lines.append(f"Codebase Genome Diff: {self.ref1}{self.ref2}")
lines.append("=" * 60)
lines.append(f" Files changed: {self.total_files_changed}")
lines.append(f" Functions changed: {self.total_functions_changed}")
lines.append(f" Dependencies changed: {self.total_dependencies_changed}")
lines.append("")
for fc in self.file_changes:
kind = []
if fc.get('is_new'):
kind.append("NEW")
if fc.get('is_deleted'):
kind.append("DELETED")
if fc.get('is_renamed'):
kind.append("RENAMED")
if fc.get('is_binary'):
kind.append("BINARY")
kind_str = f" [{', '.join(kind)}]" if kind else ""
lines.append(f" {fc['path']}{kind_str} (+{fc['added_lines']}/-{fc['deleted_lines']})")
lines.append("")
for fc in self.function_changes:
op = {'added': '+', 'removed': '-', 'modified': '~'}.get(fc.change_type, '?')
lines.append(f" [{op}] {fc.file}: {fc.kind} '{fc.name}'")
lines.append("")
for dc in self.dependency_changes:
op = '+' if dc.change_type == 'added' else '-'
lines.append(f" [{op}] {dc.file}: {dc.module}")
lines.append("")
return "\n".join(lines)
def run_git_diff(ref1: str, ref2: str) -> str:
result = subprocess.run(
['git', 'diff', '--unified=0', f'{ref1}...{ref2}'],
capture_output=True, text=True, cwd=SCRIPT_DIR
)
if result.returncode not in (0, 1):
print(f"git diff failed: {result.stderr}", file=sys.stderr)
sys.exit(1)
return result.stdout
def extract_function_changes(diff_text: str) -> List[FunctionChange]:
changes: List[FunctionChange] = []
pattern = re.compile(r'^([+\-])\s*(def|class)\s+(\w+)', re.MULTILINE)
hunk_header_re = re.compile(r'^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@')
current_old_line: Optional[int] = None
current_new_line: Optional[int] = None
for line in diff_text.split('\n'):
hdr = hunk_header_re.match(line)
if hdr:
current_old_line = int(hdr.group(1))
current_new_line = int(hdr.group(3))
continue
m = pattern.match(line)
if m:
op = m.group(1)
kind = m.group(2)
name = m.group(3)
change_type = "added" if op == '+' else "removed"
line_num = current_new_line if change_type == "added" else current_old_line
changes.append(FunctionChange(
file="<unknown>",
name=name,
kind=kind,
change_type=change_type,
new_line=line_num if change_type == "added" else None,
old_line=line_num if change_type == "removed" else None,
))
# Advance line counters heuristically
if op == '-':
if current_old_line is not None:
current_old_line += 1
elif op == '+':
if current_new_line is not None:
current_new_line += 1
elif line.startswith(' '):
if current_old_line is not None:
current_old_line += 1
if current_new_line is not None:
current_new_line += 1
# lines starting with other prefixes (like \\ No newline) ignored
return changes
def extract_dependency_changes(diff_text: str, analyzer: DiffAnalyzer) -> List[DependencyChange]:
changes: List[DependencyChange] = []
import_pattern = re.compile(
r'^([+\-])\s*(?:import\s+([\w\.]+)|from\s+([\w\.]+)\s+import)',
re.MULTILINE
)
file_diffs = analyzer._split_files(diff_text)
for file_diff in file_diffs:
file_match = re.search(r'^diff --git a/.*? b/(.*?)$', file_diff, re.MULTILINE)
if not file_match:
continue
filepath = file_match.group(1)
# Scan each line for import changes
for line in file_diff.split('\n'):
m = import_pattern.match(line)
if m:
change_type = "added" if m.group(1) == '+' else "removed"
module = m.group(2) or m.group(3)
changes.append(DependencyChange(
file=filepath,
module=module,
change_type=change_type,
line=0
))
# Detect if this file is a dependency manifest
req_file_pattern = re.compile(
r'^[\+\-].*?(requirements(.*?)\.txt|pyproject\.toml|setup\.py|Pipfile)'
)
if any(req_file_pattern.match(line) for line in file_diff.split('\n')):
if not any(c.file == filepath and c.module == "<file>" for c in changes):
changes.append(DependencyChange(
file=filepath,
module="<file>",
change_type="modified",
line=0
))
return changes
def correlate_function_changes_with_files(diff_text: str, functions: List[FunctionChange]) -> List[FunctionChange]:
result: List[FunctionChange] = []
# Split diff into per-file sections
file_sections: List[tuple[str, str]] = []
current_file: Optional[str] = None
current_lines: List[str] = []
for line in diff_text.split('\n'):
if line.startswith('diff --git'):
if current_file is not None:
file_sections.append((current_file, '\n'.join(current_lines)))
m = re.match(r'^diff --git a/.*? b/(.*?)$', line)
current_file = m.group(1) if m else "unknown"
current_lines = [line]
else:
current_lines.append(line)
if current_file is not None:
file_sections.append((current_file, '\n'.join(current_lines)))
pattern = re.compile(r'^([+\-])\s*(def|class)\s+(\w+)', re.MULTILINE)
for filepath, section in file_sections:
for m in pattern.finditer(section):
op = m.group(1)
kind = m.group(2)
name = m.group(3)
change_type = "added" if op == '+' else "removed"
result.append(FunctionChange(
file=filepath,
name=name,
kind=kind,
change_type=change_type
))
return result
def main():
parser = argparse.ArgumentParser(description="Codebase Genome Diff — structural changes between versions")
parser.add_argument("--ref1", required=True, help="First git ref (commit, branch, tag)")
parser.add_argument("--ref2", required=True, help="Second git ref")
parser.add_argument("--output", help="Write report to file")
parser.add_argument("--json", action="store_true", help="Output JSON instead of human report")
args = parser.parse_args()
try:
diff_text = run_git_diff(args.ref1, args.ref2)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if not diff_text.strip():
print(f"No differences between {args.ref1} and {args.ref2}.")
sys.exit(0)
analyzer = DiffAnalyzer()
summary = analyzer.analyze(diff_text)
file_changes = [fc.to_dict() for fc in summary.files]
func_changes = extract_function_changes(diff_text)
func_changes = correlate_function_changes_with_files(diff_text, func_changes)
dep_changes = extract_dependency_changes(diff_text, analyzer)
report = GenomeDiffReport(
ref1=args.ref1,
ref2=args.ref2,
file_changes=file_changes,
function_changes=func_changes,
dependency_changes=dep_changes,
total_files_changed=len(file_changes),
total_functions_changed=len(func_changes),
total_dependencies_changed=len(dep_changes),
)
output = json.dumps(report.to_dict(), indent=2) if args.json else report.human_report()
if args.output:
with open(args.output, 'w') as f:
f.write(output + '\n')
print(f"Report written to {args.output}")
else:
print(output)
if __name__ == '__main__':
main()

View File

@@ -1,212 +0,0 @@
#!/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)

View File

@@ -1,246 +0,0 @@
#!/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()

View File

@@ -1,212 +0,0 @@
#!/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)