Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 46 additions & 4 deletions scripts/update_lib/cmd_todo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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))

Expand Down
114 changes: 114 additions & 0 deletions scripts/update_lib/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"""

import ast
import difflib
import functools
import pathlib
import re
Expand Down Expand Up @@ -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
Comment on lines +1034 to +1053
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Mismatched path types (file vs. directory) silently return 0.

If a module changed from a single file to a package (or vice versa) between CPython versions, _count_path_diff returns 0 instead of reflecting the actual diff. This is an edge case but could hide a meaningful change in the todo output.

Consider counting all lines from both sides when types don't match, similar to how you handle files only present on one side in the directory case.

🤖 Prompt for AI Agents
In `@scripts/update_lib/deps.py` around lines 1034 - 1053, The function
_count_path_diff currently returns 0 when path types differ (file vs directory);
change it to detect mismatched types and count the lines on both sides instead
of returning 0: if one side is a file and the other a dir, sum the file's line
count (use safe_read_text to read + splitlines length) and the total lines of
all *.py files under the directory (iterate dir.rglob("*.py") and use
safe_read_text for each); keep using _count_file_diff for the file-file case and
the existing directory-directory aggregation logic in _count_path_diff.



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]]:
Expand Down
Loading