Compare commits

..

1 Commits

Author SHA1 Message Date
Alexander Payne
2f57c2b653 feat(10.8): add Progress Tracker — track test/doc coverage, issue close rate, dep freshness
Some checks failed
Test / pytest (pull_request) Failing after 8s
Implements 10.8: Progress Tracker.

- Script: scripts/progress_tracker.py
- Stores weekly snapshots in metrics/snapshots/ (auto-created)
- Generates trends table in metrics/TRENDS.md
- Tracks: test-to-source ratio, doc coverage via AST, issue close rate (Gitea API), dep freshness (pip)
- Outputs stdout summary for weekly review

Usage:
    python3 scripts/progress_tracker.py
    python3 scripts/progress_tracker.py --json
    python3 scripts/progress_tracker.py --output metrics/TRENDS.md

Weekly cron: 0 9 * * 1 cd /path && python3 scripts/progress_tracker.py

Closes #173
2026-04-26 05:15:01 -04:00
4 changed files with 477 additions and 670 deletions

477
scripts/progress_tracker.py Normal file
View File

@@ -0,0 +1,477 @@
#!/usr/bin/env python3
"""
Progress Tracker — Pipeline 10.8
Track improvement metrics over time. Are we getting better?
Metrics tracked:
1. Test coverage — % of Python functions with associated tests (test:source file ratio + line coverage if available)
2. Doc coverage — % of Python callables with docstrings (AST-based)
3. Issue close rate — closed / (opened + closed) per week (Gitea API)
4. Dep freshness — % of requirements pinned vs outdated (pip list --outdated)
Output:
- metrics/snapshots/YYYY-MM-DD.json — one snapshot per run
- metrics/TRENDS.md — cumulative markdown table
- stdout summary
Usage:
python3 scripts/progress_tracker.py
python3 scripts/progress_tracker.py --json
python3 scripts/progress_tracker.py --output metrics/TRENDS.md
Weekly cron:
0 9 * * 1 cd /path/to/compounding-intelligence && python3 scripts/progress_tracker.py
"""
import argparse
import json
import os
import re
import subprocess
import sys
from collections import defaultdict
from datetime import datetime, timezone, timedelta
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
# ── Configuration ──────────────────────────────────────────────────────────
SCRIPT_DIR = Path(__file__).resolve().parent
REPO_ROOT = SCRIPT_DIR.parent
METRICS_DIR = REPO_ROOT / "metrics"
SNAPSHOTS_DIR = METRICS_DIR / "snapshots"
TOKEN_PATH = Path.home() / ".config" / "gitea" / "token"
GITEA_API_BASE = "https://forge.alexanderwhitestone.com/api/v1"
ORG = "Timmy_Foundation"
# Ensure paths exist
SNAPSHOTS_DIR.mkdir(parents=True, exist_ok=True)
# ── Helpers ─────────────────────────────────────────────────────────────────
def run_cmd(cmd: List[str], cwd: Path = REPO_ROOT) -> str:
"""Run a shell command and return stdout (stderr merged)."""
result = subprocess.run(
cmd, capture_output=True, text=True, cwd=cwd, timeout=30
)
if result.returncode != 0:
return ""
return result.stdout.strip()
def slugify_date(dt: datetime) -> str:
return dt.strftime("%Y-%m-%d")
def snapshot_path(dt: datetime) -> Path:
return SNAPSHOTS_DIR / f"{slugify_date(dt)}.json"
def load_snapshots() -> List[Dict[str, Any]]:
"""Load all existing snapshots sorted by date."""
snapshots = []
for f in sorted(SNAPSHOTS_DIR.glob("*.json")):
try:
with open(f) as fp:
snapshots.append(json.load(fp))
except Exception:
continue
return snapshots
# ── Metric 1: Test Coverage ─────────────────────────────────────────────────
def collect_test_coverage() -> Dict[str, Any]:
"""
Compute test coverage metrics.
Counts test_*.py and *_test.py files vs non-test .py source files.
Also attempts to read .coverage if present.
"""
all_py = list(REPO_ROOT.rglob("*.py"))
source_files = []
test_files = []
for p in all_py:
try:
rel_parts = p.relative_to(REPO_ROOT).parts
except ValueError:
continue
# Skip hidden/cache/temp dirs (check only relative parts)
if any(part.startswith('.') or part.startswith('__') for part in rel_parts):
continue
if any(part in ('node_modules', 'venv', '.venv', 'env', '.pytest_cache') for part in rel_parts):
continue
if p.name.startswith("test_") or p.name.endswith("_test.py"):
test_files.append(p)
else:
source_files.append(p)
# Try to get line coverage from .coverage
coverage_percent = None
coverage_tool = None
coverage_file = REPO_ROOT / ".coverage"
if coverage_file.exists():
try:
import coverage # type: ignore
# Use coverage API if available
cov = coverage.Coverage(data_file=str(coverage_file))
cov.load()
total = cov.report()
coverage_percent = total if isinstance(total, float) else None
coverage_tool = "coverage"
except Exception:
# Fallback: parse `coverage report` output
out = run_cmd(["coverage", "report", "--skip-empty"])
if out:
for line in out.splitlines():
if "TOTAL" in line:
parts = line.split()
if len(parts) >= 2:
try:
coverage_percent = float(parts[-1].rstrip('%'))
coverage_tool = "coverage"
break
except ValueError:
pass
return {
"test_files": len(test_files),
"source_files": len(source_files),
"test_to_source_ratio": round(len(test_files) / len(source_files), 4) if source_files else 0.0,
"coverage_tool": coverage_tool,
"coverage_percent": coverage_percent,
}
# ── Metric 2: Doc Coverage ──────────────────────────────────────────────────
def collect_doc_coverage() -> Dict[str, Any]:
"""
Check AST of Python files for docstrings.
Returns: callables_total, callables_with_doc, doc_coverage_percent
"""
import ast
all_py = list(REPO_ROOT.rglob("*.py"))
source_files = []
test_files = []
for p in all_py:
try:
rel_parts = p.relative_to(REPO_ROOT).parts
except ValueError:
continue
if any(part.startswith('.') or part.startswith('__') for part in rel_parts):
continue
if any(part in ('node_modules', 'venv', '.venv', 'env', '.pytest_cache') for part in rel_parts):
continue
if p.name.startswith("test_") or p.name.endswith("_test.py"):
test_files.append(p)
else:
source_files.append(p)
total_callables = 0
with_doc = 0
for p in source_files + test_files:
try:
with open(p) as f:
tree = ast.parse(f.read(), filename=str(p))
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
total_callables += 1
doc = ast.get_docstring(node)
if doc and doc.strip():
with_doc += 1
except Exception:
continue
return {
"callables_total": total_callables,
"callables_with_doc": with_doc,
"doc_coverage_percent": round((with_doc / total_callables * 100) if total_callables else 0.0, 2),
}
# ── Metric 3: Issue Close Rate ──────────────────────────────────────────────
def collect_issue_metrics() -> Dict[str, Any]:
"""
Use Gitea API to get issue open/close stats for the last 7 days.
Returns counts and close rate.
"""
token = ""
if TOKEN_PATH.exists():
token = TOKEN_PATH.read_text().strip()
if not token:
return {
"opened_last_7d": None,
"closed_last_7d": None,
"close_rate": None,
"total_open": None,
"note": "Gitea token not available"
}
try:
from urllib.request import Request, urlopen
from urllib.error import HTTPError, URLError
except ImportError:
return {"error": "urllib not available"}
now = datetime.now(timezone.utc)
week_ago = now - timedelta(days=7)
since = week_ago.strftime("%Y-%m-%d")
headers = {"Authorization": f"token {token}"}
base_url = f"{GITEA_API_BASE}/repos/{ORG}/compounding-intelligence/issues"
try:
# Get issues from last 7 days
url = f"{base_url}?state=all&since={since}&per_page=100"
req = Request(url, headers=headers)
with urlopen(req, timeout=15) as resp:
issues = json.loads(resp.read())
opened = 0
closed = 0
for issue in issues:
created = datetime.fromisoformat(issue["created_at"].replace("Z", "+00:00"))
if created >= week_ago:
opened += 1
if issue.get("state") == "closed":
closed_at_str = issue.get("closed_at")
if closed_at_str:
closed_at = datetime.fromisoformat(closed_at_str.replace("Z", "+00:00"))
if closed_at >= week_ago:
closed += 1
# Total open issues
req2 = Request(f"{base_url}?state=open&per_page=1", headers=headers)
with urlopen(req2, timeout=15) as resp:
total_open = int(resp.headers.get("X-Total-Count", "0"))
total = opened + closed
close_rate = closed / total if total > 0 else 0.0
return {
"opened_last_7d": opened,
"closed_last_7d": closed,
"close_rate": round(close_rate, 4),
"total_open": total_open,
}
except Exception as e:
return {
"opened_last_7d": None,
"closed_last_7d": None,
"close_rate": None,
"total_open": None,
"error": str(e)[:100],
"note": "Gitea API unavailable"
}
# ── Metric 4: Dependency Freshness ─────────────────────────────────────────
def collect_dep_freshness() -> Dict[str, Any]:
"""
Check requirements.txt for outdated dependencies using pip list --outdated.
Returns freshness percentage and outdated list.
"""
req_file = REPO_ROOT / "requirements.txt"
if not req_file.exists():
return {
"total_deps": 0,
"outdated_deps": 0,
"freshness_percent": 100.0,
"outdated_list": [],
"note": "requirements.txt not found"
}
# Parse requirements (very simple: take name before comparison op)
reqs = []
with open(req_file) as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
m = re.match(r"^([a-zA-Z0-9_.-]+)", line)
if m:
reqs.append(m.group(1))
if not reqs:
return {"total_deps": 0, "outdated_deps": 0, "freshness_percent": 100.0, "outdated_list": []}
# Query pip for outdated packages (may fail if pip not available)
outdated_names = set()
try:
out = run_cmd(["pip", "list", "--outdated", "--format=json"])
if out:
data = json.loads(out)
outdated_names = {item["name"].lower() for item in data}
except Exception:
pass
outdated = [p for p in reqs if p.lower() in outdated_names]
total = len(reqs)
outdated_count = len(outdated)
freshness = round(((total - outdated_count) / total * 100) if total else 100.0, 1)
return {
"total_deps": total,
"outdated_deps": outdated_count,
"freshness_percent": freshness,
"outdated_list": outdated,
}
# ── Snapshot & Trends ───────────────────────────────────────────────────────
def take_snapshot() -> Dict[str, Any]:
"""Collect all metrics and return a snapshot dict."""
now = datetime.now(timezone.utc)
test_cov = collect_test_coverage()
doc_cov = collect_doc_coverage()
issues = collect_issue_metrics()
deps = collect_dep_freshness()
return {
"timestamp": now.isoformat(),
"date": slugify_date(now),
"metrics": {
"test_coverage": test_cov,
"doc_coverage": doc_cov,
"issues": issues,
"dependencies": deps,
}
}
def save_snapshot(snapshot: Dict[str, Any]) -> Path:
path = snapshot_path(datetime.fromisoformat(snapshot["timestamp"]))
with open(path, "w") as f:
json.dump(snapshot, f, indent=2)
return path
def generate_trends(snapshots: List[Dict[str, Any]], output_path: Optional[Path] = None) -> str:
"""Generate markdown trends table; optionally write to file."""
if not snapshots:
msg = "# Progress Tracker — Trends\n\nNo snapshots yet. Run `progress_tracker.py` to create the first snapshot."
if output_path:
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(msg)
return msg
lines = [
"# Progress Tracker — Trends",
f"\nLast updated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}",
f"\nSnapshots: {len(snapshots)}\n",
"| Date | Test Files → Source | Doc Coverage | Issues Closed/Opened (7d) | Dep Freshness |",
"|------|---------------------|--------------|---------------------------|---------------|",
]
for snap in reversed(snapshots): # chronological
date = snap["date"]
m = snap["metrics"]
tc = m["test_coverage"]
test_str = f"{tc['test_files']}/{tc['source_files']} ({tc['test_to_source_ratio']:.2f})"
doc_str = f"{m['doc_coverage']['doc_coverage_percent']:.1f}%"
issues_str = f"{m['issues'].get('closed_last_7d','-')}/{m['issues'].get('opened_last_7d','-')}"
dep_str = f"{m['dependencies'].get('freshness_percent','?')}%"
lines.append(f"| {date} | {test_str} | {doc_str} | {issues_str} | {dep_str} |")
# Current snapshot summary
cur = snapshots[-1]
cm = cur["metrics"]
lines.append(f"\n## Current Snapshot ({cur['date']})\n")
tc = cm["test_coverage"]
cov_line = f"- Test coverage: {tc['coverage_percent']:.1f}% (via {tc['coverage_tool']})\n" if tc["coverage_percent"] else "- Test coverage: (pytest-cov not configured)\n"
lines.append(cov_line)
lines.append(f"- Doc coverage: {cm['doc_coverage']['doc_coverage_percent']:.1f}%")
im = cm["issues"]
if im.get("close_rate") is not None:
lines.append(f"- Issue close rate (7d): {im['close_rate']*100:.1f}% ({im['closed_last_7d']} closed, {im['opened_last_7d']} opened)")
else:
lines.append(f"- Issue metrics: {im.get('note','unavailable')}")
dd = cm["dependencies"]
lines.append(f"- Dep freshness: {dd.get('freshness_percent','?')}% outdated ({dd.get('outdated_deps',0)}/{dd.get('total_deps',0)} deps)")
if dd.get('outdated_list'):
lines.append(f" Outdated: {', '.join(dd['outdated_list'][:5])}")
content = "\n".join(lines) + "\n"
if output_path:
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(content)
return content
# ── Main ─────────────────────────────────────────────────────────────────────
def main() -> int:
parser = argparse.ArgumentParser(description="Progress Tracker — 10.8")
parser.add_argument("--json", action="store_true", help="Emit snapshot as JSON only")
parser.add_argument("--output", type=Path, default=METRICS_DIR / "TRENDS.md",
help="Write trends markdown to this file")
args = parser.parse_args()
snapshot = take_snapshot()
all_snapshots = load_snapshots()
path_written = save_snapshot(snapshot)
if args.json:
print(json.dumps(snapshot, indent=2))
return 0
trends = generate_trends(all_snapshots + [snapshot], output_path=args.output)
# Print current snapshot summary
print(f"Snapshot saved: {path_written}\n")
print(f"Progress Tracker — {snapshot['date']}")
print("=" * 50)
m = snapshot["metrics"]
tc = m["test_coverage"]
print(f"Test files: {tc['test_files']} | Source files: {tc['source_files']} | Ratio: {tc['test_to_source_ratio']:.3f}")
if tc["coverage_percent"] is not None:
print(f"Line coverage: {tc['coverage_percent']:.1f}% (via {tc['coverage_tool']})")
else:
print("Line coverage: (not available — run `pytest --cov`)")
print()
dc = m["doc_coverage"]
print(f"Callables with docstrings: {dc['callables_with_doc']}/{dc['callables_total']} ({dc['doc_coverage_percent']:.1f}%)")
print()
im = m["issues"]
if im.get("close_rate") is not None:
print(f"Issues (7d): {im['closed_last_7d']} closed / {im['opened_last_7d']} opened → close rate: {im['close_rate']*100:.1f}%")
print(f"Total open: {im['total_open']}")
else:
print(f"Issues: {im.get('note','unavailable')}")
print()
dd = m["dependencies"]
print(f"Dependencies: {dd.get('total_deps',0)} total, {dd.get('outdated_deps',0)} outdated")
if dd.get('outdated_list'):
shown = dd['outdated_list'][:5]
print(f"Outdated: {', '.join(shown)}" + ("..." if len(dd['outdated_list']) > 5 else ""))
print(f"\nTrends written to: {args.output}")
return 0
if __name__ == "__main__":
sys.exit(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)