diff --git a/scripts/update_lib/cmd_todo.py b/scripts/update_lib/cmd_todo.py index 87099aa5422..23aec52d7dc 100644 --- a/scripts/update_lib/cmd_todo.py +++ b/scripts/update_lib/cmd_todo.py @@ -15,6 +15,9 @@ from update_lib.deps import ( count_test_todos, + get_module_diff_stat, + get_module_last_updated, + get_test_last_updated, is_test_tracked, is_test_up_to_date, ) @@ -368,6 +371,18 @@ def compute_test_todo_list( return result +def _format_meta_suffix(item: dict) -> str: + """Format metadata suffix (last updated date and diff count).""" + parts = [] + last_updated = item.get("last_updated") + diff_lines = item.get("diff_lines", 0) + if last_updated: + parts.append(last_updated) + if diff_lines > 0: + parts.append(f"Δ{diff_lines}") + return f" | {' '.join(parts)}" if parts else "" + + def _format_test_suffix(item: dict) -> str: """Format suffix for test item (TODO count or untracked).""" tracked = item.get("tracked", True) @@ -410,13 +425,15 @@ def format_test_todo_list( primary = tests[0] done_mark = "[x]" if primary["up_to_date"] else "[ ]" suffix = _format_test_suffix(primary) - lines.append(f"- {done_mark} {primary['name']}{suffix}") + meta = _format_meta_suffix(primary) + lines.append(f"- {done_mark} {primary['name']}{suffix}{meta}") # Rest are indented for item in tests[1:]: done_mark = "[x]" if item["up_to_date"] else "[ ]" suffix = _format_test_suffix(item) - lines.append(f" - {done_mark} {item['name']}{suffix}") + meta = _format_meta_suffix(item) + lines.append(f" - {done_mark} {item['name']}{suffix}{meta}") return lines @@ -462,7 +479,8 @@ def format_todo_list( if rev_str: parts.append(f"({rev_str})") - lines.append(" ".join(parts)) + line = " ".join(parts) + _format_meta_suffix(item) + lines.append(line) # Show hard_deps: # - Normal mode: only show if lib is up-to-date but hard_deps are not @@ -482,7 +500,8 @@ def format_todo_list( for test_info in test_by_lib[name]: test_done_mark = "[x]" if test_info["up_to_date"] else "[ ]" suffix = _format_test_suffix(test_info) - lines.append(f" - {test_done_mark} {test_info['name']}{suffix}") + meta = _format_meta_suffix(test_info) + lines.append(f" - {test_done_mark} {test_info['name']}{suffix}{meta}") # Verbose mode: show detailed dependency info if verbose: @@ -556,6 +575,29 @@ def format_all_todo( if include_done or lib_not_done or has_pending_test: lib_todo.append(item) + # Add metadata (last updated date and diff stat) to lib items + for item in lib_todo: + item["last_updated"] = get_module_last_updated( + item["name"], cpython_prefix, lib_prefix + ) + item["diff_lines"] = ( + 0 + if item["up_to_date"] + else get_module_diff_stat(item["name"], cpython_prefix, lib_prefix) + ) + + # Add last_updated to displayed test items (verbose only - slow) + if verbose: + for tests in test_by_lib.values(): + for test in tests: + test["last_updated"] = get_test_last_updated( + test["name"], cpython_prefix, lib_prefix + ) + for test in no_lib_tests: + test["last_updated"] = get_test_last_updated( + test["name"], cpython_prefix, lib_prefix + ) + # Format lib todo with embedded tests lines.extend(format_todo_list(lib_todo, test_by_lib, limit, verbose)) diff --git a/scripts/update_lib/deps.py b/scripts/update_lib/deps.py index 7acffe88d0b..e5187a85e7c 100644 --- a/scripts/update_lib/deps.py +++ b/scripts/update_lib/deps.py @@ -8,6 +8,7 @@ """ import ast +import difflib import functools import pathlib import re @@ -1011,6 +1012,119 @@ def is_up_to_date(name: str, cpython_prefix: str, lib_prefix: str) -> bool: return found_any +def _count_file_diff(file_a: pathlib.Path, file_b: pathlib.Path) -> int: + """Count changed lines between two text files using difflib.""" + a_content = safe_read_text(file_a) + b_content = safe_read_text(file_b) + if a_content is None or b_content is None: + return 0 + if a_content == b_content: + return 0 + a_lines = a_content.splitlines() + b_lines = b_content.splitlines() + count = 0 + for line in difflib.unified_diff(a_lines, b_lines, lineterm=""): + if (line.startswith("+") and not line.startswith("+++")) or ( + line.startswith("-") and not line.startswith("---") + ): + count += 1 + return count + + +def _count_path_diff(path_a: pathlib.Path, path_b: pathlib.Path) -> int: + """Count changed lines between two paths (file or directory, *.py only).""" + if path_a.is_file() and path_b.is_file(): + return _count_file_diff(path_a, path_b) + if path_a.is_dir() and path_b.is_dir(): + total = 0 + a_files = {f.relative_to(path_a) for f in path_a.rglob("*.py")} + b_files = {f.relative_to(path_b) for f in path_b.rglob("*.py")} + for rel in a_files & b_files: + total += _count_file_diff(path_a / rel, path_b / rel) + for rel in a_files - b_files: + content = safe_read_text(path_a / rel) + if content: + total += len(content.splitlines()) + for rel in b_files - a_files: + content = safe_read_text(path_b / rel) + if content: + total += len(content.splitlines()) + return total + return 0 + + +def get_module_last_updated( + name: str, cpython_prefix: str, lib_prefix: str +) -> str | None: + """Get the last git commit date for a module's Lib files.""" + local_paths = [] + for cpython_path in get_lib_paths(name, cpython_prefix): + if not cpython_path.exists(): + continue + try: + rel_path = cpython_path.relative_to(cpython_prefix) + local_path = pathlib.Path(lib_prefix) / rel_path.relative_to("Lib") + if local_path.exists(): + local_paths.append(str(local_path)) + except ValueError: + continue + if not local_paths: + return None + try: + result = subprocess.run( + ["git", "log", "-1", "--format=%cd", "--date=short", "--"] + local_paths, + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip() + except Exception: + pass + return None + + +def get_module_diff_stat(name: str, cpython_prefix: str, lib_prefix: str) -> int: + """Count differing lines between cpython and local Lib for a module.""" + total = 0 + for cpython_path in get_lib_paths(name, cpython_prefix): + if not cpython_path.exists(): + continue + try: + rel_path = cpython_path.relative_to(cpython_prefix) + local_path = pathlib.Path(lib_prefix) / rel_path.relative_to("Lib") + except ValueError: + continue + if not local_path.exists(): + continue + total += _count_path_diff(cpython_path, local_path) + return total + + +def get_test_last_updated( + test_name: str, cpython_prefix: str, lib_prefix: str +) -> str | None: + """Get the last git commit date for a test's files.""" + cpython_path = _get_cpython_test_path(test_name, cpython_prefix) + if cpython_path is None: + return None + local_path = _get_local_test_path(cpython_path, lib_prefix) + if not local_path.exists(): + return None + try: + result = subprocess.run( + ["git", "log", "-1", "--format=%cd", "--date=short", "--", str(local_path)], + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip() + except Exception: + pass + return None + + def get_test_dependencies( test_path: pathlib.Path, ) -> dict[str, list[pathlib.Path]]: