|
| 1 | +"""Artifact diff report generation for porting outputs.""" |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +import hashlib |
| 6 | +import json |
| 7 | +import logging |
| 8 | +import re |
| 9 | +import subprocess |
| 10 | +from pathlib import Path |
| 11 | +from typing import Any |
| 12 | + |
| 13 | + |
| 14 | +def _sha256(path: Path) -> str: |
| 15 | + digest = hashlib.sha256() |
| 16 | + with open(path, "rb") as handle: |
| 17 | + for chunk in iter(lambda: handle.read(1024 * 1024), b""): |
| 18 | + digest.update(chunk) |
| 19 | + return digest.hexdigest() |
| 20 | + |
| 21 | + |
| 22 | +def _collect_files(root: Path) -> dict[str, dict[str, Any]]: |
| 23 | + files: dict[str, dict[str, Any]] = {} |
| 24 | + for path in root.rglob("*"): |
| 25 | + if not path.is_file(): |
| 26 | + continue |
| 27 | + rel = str(path.relative_to(root)) |
| 28 | + files[rel] = { |
| 29 | + "size": path.stat().st_size, |
| 30 | + "sha256": _sha256(path), |
| 31 | + } |
| 32 | + return files |
| 33 | + |
| 34 | + |
| 35 | +def _parse_prop_file(path: Path) -> dict[str, str]: |
| 36 | + props: dict[str, str] = {} |
| 37 | + with open(path, "r", encoding="utf-8", errors="ignore") as handle: |
| 38 | + for raw in handle: |
| 39 | + line = raw.strip() |
| 40 | + if not line or line.startswith("#") or "=" not in line: |
| 41 | + continue |
| 42 | + key, value = line.split("=", 1) |
| 43 | + props[key.strip()] = value.strip() |
| 44 | + return props |
| 45 | + |
| 46 | + |
| 47 | +def _collect_build_props(root: Path) -> dict[str, dict[str, str]]: |
| 48 | + result: dict[str, dict[str, str]] = {} |
| 49 | + for prop_file in root.rglob("build.prop"): |
| 50 | + if not prop_file.is_file(): |
| 51 | + continue |
| 52 | + rel = str(prop_file.relative_to(root)) |
| 53 | + result[rel] = _parse_prop_file(prop_file) |
| 54 | + return result |
| 55 | + |
| 56 | + |
| 57 | +def _extract_apk_metadata(apk_path: Path) -> dict[str, Any]: |
| 58 | + metadata: dict[str, Any] = { |
| 59 | + "size": apk_path.stat().st_size, |
| 60 | + "sha256": _sha256(apk_path), |
| 61 | + "package": None, |
| 62 | + "version_name": None, |
| 63 | + "version_code": None, |
| 64 | + } |
| 65 | + |
| 66 | + for tool in ("aapt2", "aapt"): |
| 67 | + try: |
| 68 | + proc = subprocess.run( |
| 69 | + [tool, "dump", "badging", str(apk_path)], |
| 70 | + check=False, |
| 71 | + capture_output=True, |
| 72 | + text=True, |
| 73 | + ) |
| 74 | + except FileNotFoundError: |
| 75 | + continue |
| 76 | + |
| 77 | + if proc.returncode != 0 or not proc.stdout: |
| 78 | + continue |
| 79 | + |
| 80 | + match = re.search( |
| 81 | + r"package: name='([^']+)'.*versionCode='([^']+)'.*versionName='([^']*)'", |
| 82 | + proc.stdout, |
| 83 | + ) |
| 84 | + if match: |
| 85 | + metadata["package"] = match.group(1) |
| 86 | + metadata["version_code"] = match.group(2) |
| 87 | + metadata["version_name"] = match.group(3) |
| 88 | + break |
| 89 | + |
| 90 | + return metadata |
| 91 | + |
| 92 | + |
| 93 | +def _collect_apks(root: Path) -> dict[str, dict[str, Any]]: |
| 94 | + result: dict[str, dict[str, Any]] = {} |
| 95 | + for apk in root.rglob("*.apk"): |
| 96 | + if not apk.is_file(): |
| 97 | + continue |
| 98 | + rel = str(apk.relative_to(root)) |
| 99 | + result[rel] = _extract_apk_metadata(apk) |
| 100 | + return result |
| 101 | + |
| 102 | + |
| 103 | +def collect_artifact_state(root: str | Path, logger: logging.Logger) -> dict[str, Any]: |
| 104 | + """Collect file, build.prop, and APK metadata state for diffing.""" |
| 105 | + target = Path(root).resolve() |
| 106 | + if not target.exists(): |
| 107 | + logger.warning("Artifact state target does not exist: %s", target) |
| 108 | + return {"root": str(target), "files": {}, "build_props": {}, "apks": {}} |
| 109 | + |
| 110 | + logger.info("Collecting artifact state from: %s", target) |
| 111 | + return { |
| 112 | + "root": str(target), |
| 113 | + "files": _collect_files(target), |
| 114 | + "build_props": _collect_build_props(target), |
| 115 | + "apks": _collect_apks(target), |
| 116 | + } |
| 117 | + |
| 118 | + |
| 119 | +def _diff_map(before: dict[str, Any], after: dict[str, Any]) -> dict[str, list[str]]: |
| 120 | + before_keys = set(before.keys()) |
| 121 | + after_keys = set(after.keys()) |
| 122 | + return { |
| 123 | + "added": sorted(after_keys - before_keys), |
| 124 | + "removed": sorted(before_keys - after_keys), |
| 125 | + "common": sorted(before_keys & after_keys), |
| 126 | + } |
| 127 | + |
| 128 | + |
| 129 | +def generate_diff_report(before: dict[str, Any], after: dict[str, Any]) -> dict[str, Any]: |
| 130 | + """Generate a structured artifact diff report.""" |
| 131 | + file_scope = _diff_map(before.get("files", {}), after.get("files", {})) |
| 132 | + modified_files = [ |
| 133 | + path |
| 134 | + for path in file_scope["common"] |
| 135 | + if before["files"][path] != after["files"][path] |
| 136 | + ] |
| 137 | + |
| 138 | + prop_changes: list[dict[str, Any]] = [] |
| 139 | + prop_scope = _diff_map(before.get("build_props", {}), after.get("build_props", {})) |
| 140 | + for prop_path in prop_scope["common"]: |
| 141 | + before_props = before["build_props"][prop_path] |
| 142 | + after_props = after["build_props"][prop_path] |
| 143 | + keys = set(before_props.keys()) | set(after_props.keys()) |
| 144 | + for key in sorted(keys): |
| 145 | + if before_props.get(key) != after_props.get(key): |
| 146 | + prop_changes.append( |
| 147 | + { |
| 148 | + "path": prop_path, |
| 149 | + "key": key, |
| 150 | + "before": before_props.get(key), |
| 151 | + "after": after_props.get(key), |
| 152 | + } |
| 153 | + ) |
| 154 | + |
| 155 | + apk_changes: list[dict[str, Any]] = [] |
| 156 | + apk_scope = _diff_map(before.get("apks", {}), after.get("apks", {})) |
| 157 | + for path in apk_scope["added"]: |
| 158 | + apk_changes.append({"path": path, "change": "added", "before": None, "after": after["apks"][path]}) |
| 159 | + for path in apk_scope["removed"]: |
| 160 | + apk_changes.append({"path": path, "change": "removed", "before": before["apks"][path], "after": None}) |
| 161 | + for path in apk_scope["common"]: |
| 162 | + if before["apks"][path] != after["apks"][path]: |
| 163 | + apk_changes.append( |
| 164 | + { |
| 165 | + "path": path, |
| 166 | + "change": "modified", |
| 167 | + "before": before["apks"][path], |
| 168 | + "after": after["apks"][path], |
| 169 | + } |
| 170 | + ) |
| 171 | + |
| 172 | + return { |
| 173 | + "summary": { |
| 174 | + "files_added": len(file_scope["added"]), |
| 175 | + "files_removed": len(file_scope["removed"]), |
| 176 | + "files_modified": len(modified_files), |
| 177 | + "prop_changes": len(prop_changes), |
| 178 | + "apk_changes": len(apk_changes), |
| 179 | + }, |
| 180 | + "files": { |
| 181 | + "added": file_scope["added"], |
| 182 | + "removed": file_scope["removed"], |
| 183 | + "modified": modified_files, |
| 184 | + }, |
| 185 | + "build_props": { |
| 186 | + "added_files": prop_scope["added"], |
| 187 | + "removed_files": prop_scope["removed"], |
| 188 | + "changes": prop_changes, |
| 189 | + }, |
| 190 | + "apks": apk_changes, |
| 191 | + } |
| 192 | + |
| 193 | + |
| 194 | +def save_diff_report(report: dict[str, Any], output_path: str | Path) -> Path: |
| 195 | + """Persist the generated diff report to JSON.""" |
| 196 | + path = Path(output_path).resolve() |
| 197 | + path.parent.mkdir(parents=True, exist_ok=True) |
| 198 | + path.write_text(json.dumps(report, indent=2), encoding="utf-8") |
| 199 | + return path |
0 commit comments