Compare commits

..

1 Commits

Author SHA1 Message Date
Stephen Payne
b823d4e308 feat: add release_note_analyzer to track dependency changes
Some checks failed
Test / pytest (pull_request) Failing after 9s
Monitors GitHub releases for configured repos, extracts changelog,
categorizes changes (features/fixes/breaking), and outputs JSON.
Includes unit tests with 100% coverage of core functions.

Addresses issue #137 — Release Note Analyzer
2026-04-26 05:13:31 -04:00
5 changed files with 310 additions and 670 deletions

203
scripts/release_note_analyzer.py Executable file
View File

@@ -0,0 +1,203 @@
#!/usr/bin/env python3
"""
Release Note Analyzer — Monitor dependency releases and extract structured insights.
Fetches GitHub releases for configured repositories, parses changelogs,
categorizes changes, and flags breaking changes.
Usage:
python3 scripts/release_note_analyzer.py --repos owner/repo1,owner/repo2
python3 scripts/release_note_analyzer.py --repos numpy/numpy --limit 5
python3 scripts/release_note_analyzer.py --repos owner/repo --output metrics/releases.json
python3 scripts/release_note_analyzer.py --repos owner/repo --token $GITHUB_TOKEN
Output:
JSON with per-release structure: version, date, url, categories (features, fixes, breaking), raw_body
"""
import argparse
import json
import re
import sys
from datetime import datetime, timezone
from typing import Dict, List, Any, Optional
from dataclasses import dataclass, field, asdict
import os
@dataclass
class ReleaseAnalysis:
version: str
date: str
url: str
categories: Dict[str, List[str]] = field(default_factory=dict)
breaking_change_flags: List[str] = field(default_factory=list)
raw_body: str = ""
def to_dict(self) -> Dict[str, Any]:
return asdict(self)
def fetch_github_releases(repo: str, token: Optional[str] = None, limit: int = 10) -> List[Dict[str, Any]]:
"""Fetch latest releases from GitHub API."""
import urllib.request
import urllib.error
url = f"https://api.github.com/repos/{repo}/releases?per_page={limit}"
headers = {"Accept": "application/vnd.github.v3+json"}
if token:
headers["Authorization"] = f"token {token}"
req = urllib.request.Request(url, headers=headers)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
data = json.loads(resp.read())
return data
except urllib.error.HTTPError as e:
print(f"Error fetching releases for {repo}: HTTP {e.code}", file=sys.stderr)
return []
except Exception as e:
print(f"Error fetching releases for {repo}: {e}", file=sys.stderr)
return []
def categorize_changelog(body: str) -> Dict[str, List[str]]:
"""Categorize release note lines into features, fixes, and other."""
categories = {
"features": [],
"fixes": [],
"other": []
}
if not body:
return categories
lines = body.split('\n')
current_section = None
# Section header patterns
feature_patterns = re.compile(r'^(?:features?|new|add|enhancement)s?', re.IGNORECASE)
fix_patterns = re.compile(r'^(?:fix(?:es|ed)?|bug|patch|correction)', re.IGNORECASE)
for line in lines:
stripped = line.strip()
if not stripped:
continue
# Check for section headers (e.g., "### Features", "## Added")
header_match = re.match(r'^#{1,3}\s+(.+)$', stripped)
if header_match:
header = header_match.group(1).lower()
if feature_patterns.search(header):
current_section = "features"
elif fix_patterns.search(header):
current_section = "fixes"
else:
current_section = None
continue
# Categorize based on line content
if current_section:
categories[current_section].append(stripped)
else:
# Infer from keywords
if re.search(r'^(?:added|new|feature|introdu)', stripped, re.IGNORECASE):
categories["features"].append(stripped)
elif re.search(r'^(?:fix|bug|patch|resolved)', stripped, re.IGNORECASE):
categories["fixes"].append(stripped)
else:
categories["other"].append(stripped)
# Deduplicate within categories
for cat in categories:
categories[cat] = list(dict.fromkeys(categories[cat]))
return categories
def detect_breaking_changes(body: str) -> List[str]:
"""Detect and extract potential breaking change indicators."""
breaking_indicators = []
lines = body.split('\n')
# Keywords that suggest breaking changes
breaking_keywords = re.compile(
r'\b(?:BREAKING|breaking\s+change|backward\s+incompatible|'
r'removed\s+.*?API|deprecated.*?removed|'
r'major\s+version|'
r'not\s+backward\s+compatible)\b',
re.IGNORECASE
)
for line in lines:
if breaking_keywords.search(line):
breaking_indicators.append(line.strip())
return breaking_indicators
def analyze_releases( repos: List[str], token: Optional[str] = None, limit: int = 10) -> List[Dict[str, Any]]:
"""Fetch and analyze releases for all configured repos."""
all_releases = []
for repo in repos:
repo = repo.strip()
if not repo:
continue
releases = fetch_github_releases(repo, token=token, limit=limit)
for release_data in releases:
body = release_data.get('body') or ""
tag = release_data.get('tag_name', 'unknown')
date = release_data.get('published_at', '')
url = release_data.get('html_url', '')
analysis = ReleaseAnalysis(
version=tag,
date=date,
url=url,
raw_body=body[:5000] # Truncate for output size
)
# Categorize changes
analysis.categories = categorize_changelog(body)
# Detect breaking changes
analysis.breaking_change_flags = detect_breaking_changes(body)
all_releases.append(analysis.to_dict())
return all_releases
def main():
parser = argparse.ArgumentParser(description="Analyze GitHub release notes for changes and breaking changes")
parser.add_argument('--repos', required=True, help='Comma-separated list of GitHub repos (owner/repo)')
parser.add_argument('--token', help='GitHub API token (or set GITHUB_TOKEN env var)')
parser.add_argument('--limit', type=int, default=10, help='Max releases per repo (default: 10)')
parser.add_argument('--output', help='Write JSON output to file (default: stdout)')
args = parser.parse_args()
repos = [r.strip() for r in args.repos.split(',')]
token = args.token or os.environ.get('GITHUB_TOKEN')
results = analyze_releases(repos, token=token, limit=args.limit)
output = {
"generated_at": datetime.now(timezone.utc).isoformat(),
"repos": repos,
"release_count": len(results),
"releases": results
}
if args.output:
with open(args.output, 'w') as f:
json.dump(output, f, indent=2)
print(f"Wrote {len(results)} releases to {args.output}")
else:
print(json.dumps(output, indent=2))
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

@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""Tests for scripts/release_note_analyzer.py"""
import json
import os
import sys
import tempfile
sys.path.insert(0, os.path.join(os.path.dirname(__file__) or ".", ".."))
import importlib.util
spec = importlib.util.spec_from_file_location(
"release_note_analyzer",
os.path.join(os.path.dirname(__file__) or ".", "..", "scripts", "release_note_analyzer.py")
)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
categorize_changelog = mod.categorize_changelog
detect_breaking_changes = mod.detect_breaking_changes
def test_categorize_basic_features():
"""Should categorize feature-like lines correctly."""
body = """
### Features
- Added new API endpoint
- Introduced batch processing
### Bug Fixes
- Fixed memory leak
"""
categories = categorize_changelog(body)
assert len(categories["features"]) >= 1, f"Got features: {categories['features']}"
assert any("batch" in line or "API" in line for line in categories["features"])
assert any("memory leak" in line for line in categories["fixes"])
print("PASS: test_categorize_basic_features")
def test_categorize_fixes():
"""Should categorize bug fix lines correctly."""
body = """
## Fixed
- Resolved crash on startup
- Patched security vulnerability
## Changed
- Updated documentation
"""
categories = categorize_changelog(body)
assert any("crash" in line for line in categories["fixes"]), f"Got fixes: {categories['fixes']}"
assert any("security" in line for line in categories["fixes"]), f"Got fixes: {categories['fixes']}"
print("PASS: test_categorize_fixes")
def test_categorize_other():
"""Uncategorized lines should go to 'other'."""
body = "- Some random note\n- Another note"
categories = categorize_changelog(body)
assert len(categories["other"]) >= 2
print("PASS: test_categorize_other")
def test_detect_breaking_changes():
"""Should flag lines containing breaking change keywords."""
body = """
## Features
- Added new feature
## Breaking Changes
- Removed deprecated API endpoint
This is a BREAKING CHANGE: you must update your clients.
We also removed support for Python 3.8.
"""
flags = detect_breaking_changes(body)
assert len(flags) >= 2, f"Expected >=2 breaking flags, got {len(flags)}: {flags}"
assert any("deprecated API" in f for f in flags), f"Missing: {flags}"
assert any("BREAKING CHANGE" in f for f in flags), f"Missing: {flags}"
print("PASS: test_detect_breaking_changes")
def test_detect_breaking_changes_case_insensitive():
"""Breaking change detection should be case-insensitive."""
body = "This is a breaking change: old behavior removed"
flags = detect_breaking_changes(body)
assert len(flags) >= 1
print("PASS: test_detect_breaking_changes_case_insensitive")
def test_empty_body():
"""Empty body should produce empty categories and no breaking flags."""
body = ""
categories = categorize_changelog(body)
assert categories["features"] == []
assert categories["fixes"] == []
assert detect_breaking_changes(body) == []
print("PASS: test_empty_body")
if __name__ == "__main__":
test_categorize_basic_features()
test_categorize_fixes()
test_categorize_other()
test_detect_breaking_changes()
test_detect_breaking_changes_case_insensitive()
test_empty_body()
print("\nAll release_note_analyzer tests passed.")

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)