sunee3 / core /diff_utils.py
sagarmythos
DevAI Studio HCBH v3.1 - clean deploy, no binaries
df6cd5e
Raw
History Blame Contribute Delete
13.4 kB
"""
core/diff_utils.py — Visual Before/After diff generator (v4.7).
Purpose
=======
Before the user hits **Download ZIP**, we want to show them *exactly* what
the AI changed. This module:
* Captures a **before snapshot** of every file the AI is about to touch,
keyed by ``relative_path -> str``. The pipeline calls this **before**
``apply_ai_changes`` writes anything to disk.
* After the patch is applied, compares before/after and produces:
* A **unified diff** (Python's ``difflib.unified_diff``) — the raw text
the UI can drop into a monospaced viewer.
* An **HTML side-by-side diff** styled with the studio's pink/purple
palette (drop into ``gr.HTML``).
* A **summary** dict with added / removed / modified line counts so the
Confidence Badge can factor it in later.
We deliberately avoid installing the ``diff-match-patch`` package here —
Python's ``difflib`` gives us character/line/word-level diffs already,
and skipping the extra native dep keeps the Hugging Face Space build fast.
The output shape (``{"diffs": [...], "summary": {...}}``) is 100% compatible
with what a diff-match-patch viewer expects, so a UI upgrade later is
still a drop-in change.
"""
from __future__ import annotations
import difflib
import html
import os
from typing import Any, Dict, List, Optional, Sequence, Tuple
MAX_BEFORE_CHARS = 400_000 # per-file cap on snapshot text (safety guard)
MAX_DIFF_LINES = 4_000 # per-file cap on rendered diff lines
MAX_FILES_IN_HTML = 40 # cap the HTML view so the UI stays fast
# ---------------------------------------------------------------------------
# Snapshot helpers
# ---------------------------------------------------------------------------
def _read_text(path: str, max_chars: int = MAX_BEFORE_CHARS) -> str:
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
return f.read(max_chars)
except FileNotFoundError:
return ""
except Exception:
return ""
def capture_before_snapshot(
project_root: str,
candidate_files: Sequence[str],
) -> Dict[str, str]:
"""
Read every ``candidate_files`` (relative paths) *before* patching so we
can compute a diff after. Missing files are recorded as empty strings,
which naturally shows up in the diff as pure additions.
"""
if not project_root or not candidate_files:
return {}
snapshot: Dict[str, str] = {}
for rel in candidate_files:
rel = str(rel or "").strip().lstrip("/")
if not rel:
continue
abs_path = os.path.join(project_root, rel)
snapshot[rel] = _read_text(abs_path)
return snapshot
def compute_after_snapshot(
project_root: str,
files: Sequence[str],
) -> Dict[str, str]:
"""Same as ``capture_before_snapshot`` but for the post-patch state."""
return capture_before_snapshot(project_root, files)
# ---------------------------------------------------------------------------
# Diff computation
# ---------------------------------------------------------------------------
def _unified_lines(before: str, after: str, path: str) -> List[str]:
"""Wrap ``difflib.unified_diff`` with a bounded line cap."""
a = (before or "").splitlines(keepends=False)
b = (after or "").splitlines(keepends=False)
diff_iter = difflib.unified_diff(
a,
b,
fromfile=f"a/{path}",
tofile=f"b/{path}",
lineterm="",
n=3,
)
out: List[str] = []
for i, line in enumerate(diff_iter):
if i >= MAX_DIFF_LINES:
out.append(f"... (diff truncated at {MAX_DIFF_LINES} lines)")
break
out.append(line)
return out
def _summarise_diff(diff_lines: Sequence[str]) -> Dict[str, int]:
added = removed = hunks = 0
for line in diff_lines:
if not line:
continue
if line.startswith("+++") or line.startswith("---"):
continue
if line.startswith("+"):
added += 1
elif line.startswith("-"):
removed += 1
elif line.startswith("@@"):
hunks += 1
return {"added_lines": added, "removed_lines": removed, "hunk_count": hunks}
def build_diffs(
before: Dict[str, str],
after: Dict[str, str],
) -> Dict[str, Any]:
"""
Produce a per-file list of diffs + an aggregate summary.
Returns::
{
"diffs": [
{
"path": "core/foo.py",
"status": "modified" | "added" | "deleted" | "unchanged",
"unified_diff": "..." (str),
"added_lines": 12, "removed_lines": 3, "hunk_count": 2,
},
...
],
"summary": {
"file_count": 5,
"modified": 3, "added": 1, "deleted": 0, "unchanged": 1,
"total_added_lines": 42,
"total_removed_lines": 11,
},
}
"""
diffs: List[Dict[str, Any]] = []
paths = sorted(set(before.keys()) | set(after.keys()))
totals = {
"modified": 0, "added": 0, "deleted": 0, "unchanged": 0,
"total_added_lines": 0, "total_removed_lines": 0,
}
for path in paths:
b_txt = before.get(path, "")
a_txt = after.get(path, "")
if b_txt == a_txt:
status = "unchanged"
totals["unchanged"] += 1
diffs.append({
"path": path,
"status": status,
"unified_diff": "",
"added_lines": 0,
"removed_lines": 0,
"hunk_count": 0,
})
continue
if not b_txt and a_txt:
status = "added"
totals["added"] += 1
elif b_txt and not a_txt:
status = "deleted"
totals["deleted"] += 1
else:
status = "modified"
totals["modified"] += 1
lines = _unified_lines(b_txt, a_txt, path)
summary = _summarise_diff(lines)
totals["total_added_lines"] += summary["added_lines"]
totals["total_removed_lines"] += summary["removed_lines"]
diffs.append({
"path": path,
"status": status,
"unified_diff": "\n".join(lines),
"added_lines": summary["added_lines"],
"removed_lines": summary["removed_lines"],
"hunk_count": summary["hunk_count"],
})
summary = {
"file_count": len(paths),
**totals,
}
return {"diffs": diffs, "summary": summary}
# ---------------------------------------------------------------------------
# HTML rendering (pink + purple, matches Studio theme)
# ---------------------------------------------------------------------------
def _render_line_html(line: str) -> str:
"""Style a single unified-diff line as coloured HTML."""
esc = html.escape(line)
if line.startswith("+++") or line.startswith("---"):
return f'<div class="devai-diff-file-line">{esc}</div>'
if line.startswith("@@"):
return f'<div class="devai-diff-hunk">{esc}</div>'
if line.startswith("+"):
return f'<div class="devai-diff-add">{esc}</div>'
if line.startswith("-"):
return f'<div class="devai-diff-del">{esc}</div>'
return f'<div class="devai-diff-ctx">{esc}</div>'
DIFF_CSS = """
<style>
.devai-diff-root {
font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12.5px;
color: #4a3a5c;
line-height: 1.5;
}
.devai-diff-summary {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 14px;
}
.devai-diff-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 999px;
background: linear-gradient(135deg, #ffd1e8 0%, #e6d4ff 100%);
color: #8a5cf0;
font-weight: 600;
font-size: 0.85rem;
border: 1px solid rgba(197, 168, 255, 0.4);
font-family: 'Inter', system-ui, sans-serif;
}
.devai-diff-file-card {
background: rgba(255, 255, 255, 0.85);
border: 1px solid rgba(197, 168, 255, 0.35);
border-radius: 14px;
box-shadow: 0 8px 20px rgba(169, 124, 255, 0.12);
margin-bottom: 16px;
overflow: hidden;
}
.devai-diff-file-header {
background: linear-gradient(135deg, #ff8fc4 0%, #b28cff 100%);
color: white;
padding: 10px 16px;
font-weight: 600;
font-family: 'Inter', system-ui, sans-serif;
font-size: 0.95rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.devai-diff-file-status {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.6px;
background: rgba(255, 255, 255, 0.25);
padding: 3px 10px;
border-radius: 999px;
}
.devai-diff-body {
padding: 6px 0;
max-height: 520px;
overflow: auto;
background: #fff5fa;
}
.devai-diff-body > div {
padding: 2px 16px;
white-space: pre;
}
.devai-diff-add { background: rgba(150, 220, 170, 0.20); color: #1f6b3b; }
.devai-diff-del { background: rgba(255, 160, 190, 0.25); color: #9a2145; }
.devai-diff-hunk { background: #ece1ff; color: #7541d9; font-weight: 600; }
.devai-diff-file-line { color: #8a5cf0; font-weight: 600; }
.devai-diff-ctx { color: #6b5b7d; }
.devai-diff-empty {
padding: 22px;
text-align: center;
color: #8a7ba0;
font-family: 'Inter', system-ui, sans-serif;
}
</style>
"""
def render_diff_html(bundle: Dict[str, Any]) -> str:
"""Render the ``build_diffs`` output as a full HTML block."""
if not bundle or not bundle.get("diffs"):
return DIFF_CSS + '<div class="devai-diff-root devai-diff-empty">No file changes detected yet — run a patch to see the Before/After diff.</div>'
summary = bundle.get("summary") or {}
parts: List[str] = [DIFF_CSS, '<div class="devai-diff-root">']
# Top summary pills
parts.append('<div class="devai-diff-summary">')
parts.append(f'<span class="devai-diff-pill">📁 {summary.get("file_count", 0)} files</span>')
parts.append(f'<span class="devai-diff-pill">✏️ {summary.get("modified", 0)} modified</span>')
parts.append(f'<span class="devai-diff-pill">➕ {summary.get("added", 0)} added</span>')
parts.append(f'<span class="devai-diff-pill">🗑️ {summary.get("deleted", 0)} deleted</span>')
parts.append(f'<span class="devai-diff-pill">+{summary.get("total_added_lines", 0)} / -{summary.get("total_removed_lines", 0)} lines</span>')
parts.append('</div>')
diffs = bundle["diffs"]
# Only show files that actually changed, up to MAX_FILES_IN_HTML.
changed = [d for d in diffs if d.get("status") != "unchanged"]
truncated = False
if len(changed) > MAX_FILES_IN_HTML:
truncated = True
changed = changed[:MAX_FILES_IN_HTML]
for entry in changed:
path = entry.get("path", "?")
status = entry.get("status", "modified")
adds = entry.get("added_lines", 0)
dels = entry.get("removed_lines", 0)
diff_text = entry.get("unified_diff") or ""
parts.append('<div class="devai-diff-file-card">')
parts.append(
'<div class="devai-diff-file-header">'
f'<span>📄 {html.escape(path)}</span>'
f'<span class="devai-diff-file-status">{html.escape(status)} · +{adds} / -{dels}</span>'
'</div>'
)
parts.append('<div class="devai-diff-body">')
if diff_text:
for line in diff_text.splitlines():
parts.append(_render_line_html(line))
else:
parts.append('<div class="devai-diff-ctx">(no textual changes)</div>')
parts.append('</div>')
parts.append('</div>')
if truncated:
parts.append(
'<div class="devai-diff-empty">'
f'... {len(diffs) - MAX_FILES_IN_HTML} additional changed file(s) hidden — '
'download the ZIP for the complete patch.'
'</div>'
)
parts.append('</div>')
return "".join(parts)
# ---------------------------------------------------------------------------
# One-shot helper the pipeline can call.
# ---------------------------------------------------------------------------
def build_before_after_diff(
project_root: str,
before_snapshot: Dict[str, str],
files: Optional[Sequence[str]] = None,
) -> Dict[str, Any]:
"""
High-level helper — the pipeline captures ``before_snapshot`` via
:func:`capture_before_snapshot` *before* patching. After patching, it
calls this to produce the full bundle::
{
"diffs": [...],
"summary": {...},
"html": "<style>...</style><div ...>...</div>",
}
"""
tracked_paths = list(before_snapshot.keys())
if files:
for f in files:
f = str(f or "").strip().lstrip("/")
if f and f not in tracked_paths:
tracked_paths.append(f)
after = compute_after_snapshot(project_root, tracked_paths)
bundle = build_diffs(before_snapshot, after)
bundle["html"] = render_diff_html(bundle)
return bundle
__all__ = [
"capture_before_snapshot",
"compute_after_snapshot",
"build_diffs",
"render_diff_html",
"build_before_after_diff",
"DIFF_CSS",
]