#!/usr/bin/env python3 ############################################################################## # MIT License # # Copyright (c) 2025 Advanced Micro Devices, Inc. All Rights Reserved. # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. ############################################################################## """ Hash manager for tracking configuration file changes. Can be used standalone or imported by the master workflow. Usage: python hash_manager.py --compute-all [hash_file] python hash_manager.py --detect-changes [hash_file] python hash_manager.py --update [hash_file] """ from __future__ import annotations import argparse import hashlib import json import sys from pathlib import Path from typing import Optional DEFAULT_HASH_DB = "src/utils/.config_hashes.json" def compute_file_hash(filepath: Path) -> str: """Compute MD5 hash of a file.""" md5 = hashlib.md5() with open(filepath, "rb") as f: for chunk in iter(lambda: f.read(4096), b""): md5.update(chunk) return md5.hexdigest() def compute_arch_hashes(arch_dir: Path) -> dict: """ Compute hashes for all YAML files in an arch directory. Returns dict: {"files": {filename: hash}, "delta_hash": } """ arch_path = Path(arch_dir) if not arch_path.is_dir(): return {"files": {}, "delta_hash": None} file_hashes: dict[str, str] = {} for yaml_file in sorted(arch_path.glob("*.yaml")): file_hashes[yaml_file.name] = compute_file_hash(yaml_file) # Check for delta file (assume exactly one *_diff.yaml) delta_dir = arch_path / "config_delta" delta_hash: Optional[str] = None if delta_dir.is_dir(): delta_files = list(delta_dir.glob("*_diff.yaml")) if delta_files: delta_hash = compute_file_hash(delta_files[0]) return {"files": file_hashes, "delta_hash": delta_hash} def load_hash_db(hash_file: Path) -> dict: """Load hash database from file (or initialize).""" hash_path = Path(hash_file) if not hash_path.exists(): return {"archs": {}} with open(hash_path) as f: return json.load(f) def save_hash_db(hash_file: Path, data: dict) -> None: """Save hash database to file.""" hash_path = Path(hash_file) hash_path.parent.mkdir(parents=True, exist_ok=True) with open(hash_path, "w") as f: json.dump(data, f, indent=2, sort_keys=True) def detect_changes(configs_dir: Path, hash_file: Path) -> dict: """ Detect changes in architecture configs. Returns dict with keys: - new_archs: list[str] - modified_archs: dict[str, list[str]] - delta_files: dict[str, str] # arch -> delta file path - deleted_archs: list[str] """ configs_path = Path(configs_dir) hash_db = load_hash_db(hash_file) current_archs = { d.name for d in configs_path.iterdir() if d.is_dir() and d.name.startswith("gfx") } stored_archs = set(hash_db.get("archs", {}).keys()) changes = { "new_archs": sorted(current_archs - stored_archs), "modified_archs": {}, "delta_files": {}, "deleted_archs": sorted(stored_archs - current_archs), } # Compare existing archs for arch in sorted(current_archs & stored_archs): arch_dir = configs_path / arch current_hashes = compute_arch_hashes(arch_dir) stored_hashes = hash_db["archs"].get(arch, {"files": {}, "delta_hash": None}) modified_files: list[str] = [] current_files = set(current_hashes["files"].keys()) stored_files = set(stored_hashes.get("files", {}).keys()) # New files for f in sorted(current_files - stored_files): modified_files.append(f) # Modified files for f in sorted(current_files & stored_files): if current_hashes["files"][f] != stored_hashes["files"][f]: modified_files.append(f) # Deleted files (mark as "[DELETED] ") for f in sorted(stored_files - current_files): modified_files.append(f"[DELETED] {f}") if modified_files: changes["modified_archs"][arch] = modified_files # Delta changes delta_dir = arch_dir / "config_delta" if delta_dir.is_dir(): delta_files = list(delta_dir.glob("*_diff.yaml")) if delta_files: current_delta_hash = compute_file_hash(delta_files[0]) stored_delta_hash = stored_hashes.get("delta_hash") if current_delta_hash != stored_delta_hash: changes["delta_files"][arch] = str(delta_files[0]) return changes def update_hashes(arch_name: str, configs_dir: Path, hash_file: Path) -> bool: """Update hashes for a specific architecture.""" hash_db = load_hash_db(hash_file) arch_dir = Path(configs_dir) / arch_name if not arch_dir.is_dir(): print(f"Error: {arch_dir} is not a directory") return False arch_hashes = compute_arch_hashes(arch_dir) hash_db.setdefault("archs", {})[arch_name] = arch_hashes save_hash_db(hash_file, hash_db) print(f"Updated hashes for {arch_name}") return True def compute_all_hashes(configs_dir: Path, hash_file: Path) -> bool: """Compute and store hashes for all architectures under configs_dir.""" configs_path = Path(configs_dir) if not configs_path.is_dir(): print(f"Error: {configs_dir} is not a directory") return False hash_db = {"archs": {}} for arch_dir in sorted(configs_path.iterdir()): if arch_dir.is_dir() and arch_dir.name.startswith("gfx"): arch_name = arch_dir.name hash_db["archs"][arch_name] = compute_arch_hashes(arch_dir) print(f"Computed hashes for {arch_name}") save_hash_db(hash_file, hash_db) print(f"\nHash database saved to {hash_file}") return True def _print_change_summary(changes: dict) -> None: print("Change Detection Results") print("=" * 80) if changes["new_archs"]: print("\nNew Architectures") for arch in changes["new_archs"]: print(f" • {arch}") if changes["modified_archs"]: print("\nModified Architectures") for arch, files in changes["modified_archs"].items(): print(f" • {arch}") for f in files: print(f" - {f}") if changes["delta_files"]: print("\nDelta Files Detected") for arch, delta_file in changes["delta_files"].items(): print(f" • {arch}: {delta_file}") if changes["deleted_archs"]: print("\nDeleted Architectures") for arch in changes["deleted_archs"]: print(f" • {arch}") if not any([ changes["new_archs"], changes["modified_archs"], changes["delta_files"], changes["deleted_archs"], ]): print("\nNo changes detected") def main() -> int: parser = argparse.ArgumentParser( description="Manage configuration file hashes for change detection" ) parser.add_argument( "--compute-all", action="store_true", help="Compute hashes for all architectures", ) parser.add_argument( "--detect-changes", action="store_true", help="Detect changes in configurations" ) parser.add_argument( "--update", metavar="ARCH", help="Update hashes for specific architecture" ) parser.add_argument("configs_dir", help="Path to analysis_configs directory") parser.add_argument( "hash_file", nargs="?", default=DEFAULT_HASH_DB, help="Path to hash database file", ) args = parser.parse_args() configs_dir = Path(args.configs_dir) hash_file = Path(args.hash_file) if args.compute_all: success = compute_all_hashes(configs_dir, hash_file) return 0 if success else 1 if args.detect_changes: changes = detect_changes(configs_dir, hash_file) _print_change_summary(changes) return 0 if args.update: success = update_hashes(args.update, configs_dir, hash_file) return 0 if success else 1 parser.print_help() return 1 if __name__ == "__main__": sys.exit(main())