[update_lib] show dependent tests in deps subcommand (#6828)

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Jeong YunWon <jeong@youknowone.org>
This commit is contained in:
authored and GitHub committed 2026-01-23 08:13:16 +09:00
1 parent 125ade5f55
commit 9b56aa5b60
6 files changed
+565 -136

No files matched your search

+6 -5
View File
@@ -173,6 +173,12 @@ jobs:
cargo run --manifest-path example_projects/frozen_stdlib/Cargo.toml
if: runner.os == 'Linux'
- name: run update_lib tests
run: cargo run -- -m unittest discover -s scripts/update_lib/tests -v
env:
PYTHONPATH: scripts
if: runner.os == 'Linux'
- name: prepare Intel MacOS build
uses: dtolnay/rust-toolchain@stable
with:
@@ -463,11 +469,6 @@ jobs:
- run: ruff format --check
- name: run update_lib tests
run: cargo run -- -m unittest discover -s scripts/update_lib/tests -v
env:
PYTHONPATH: scripts
- name: install prettier
run: yarn global add prettier && echo "$(yarn global bin)" >>$GITHUB_PATH
-8
View File
@@ -99,19 +99,11 @@ jobs:
The following Lib/ modules were modified. Here are their dependencies:
<details>
<summary>Click to expand dependency information</summary>
```
${{ steps.deps-check.outputs.deps_output }}
```
</details>
**Legend:**
- `[+]` path exists in CPython
- `[x]` up-to-date, `[ ]` outdated
- `native:` Rust/C extension modules
- name: Remove comment if no Lib changes
if: steps.changed-files.outputs.modules == ''
+1
View File
@@ -0,0 +1 @@
.cache/
+493 -80
View File
@@ -7,10 +7,49 @@ Handles:
- Test dependencies (auto-detected from 'from test import ...')
"""
import ast
import functools
import pathlib
import re
import shelve
import subprocess
from update_lib.io_utils import read_python_files, safe_parse_ast, safe_read_text
# === Cross-process cache using shelve ===
def _get_cpython_version(cpython_prefix: str = "cpython") -> str:
"""Get CPython version from git tag for cache namespace."""
try:
result = subprocess.run(
["git", "describe", "--tags", "--abbrev=0"],
cwd=cpython_prefix,
capture_output=True,
text=True,
)
if result.returncode == 0:
return result.stdout.strip()
except Exception:
pass
return "unknown"
def _get_cache_path() -> str:
"""Get cache file path (without extension - shelve adds its own)."""
cache_dir = pathlib.Path(__file__).parent / ".cache"
cache_dir.mkdir(parents=True, exist_ok=True)
return str(cache_dir / "import_graph_cache")
def clear_import_graph_caches() -> None:
"""Clear in-process import graph caches (for testing)."""
if "_test_import_graph_cache" in globals():
globals()["_test_import_graph_cache"].clear()
if "_lib_import_graph_cache" in globals():
globals()["_lib_import_graph_cache"].clear()
from update_lib.path import construct_lib_path, resolve_module_path
# Manual dependency table for irregular cases
@@ -197,87 +236,87 @@ def get_test_paths(
return (resolve_module_path(f"test/test_{name}", cpython_prefix, prefer="dir"),)
@functools.cache
def get_data_paths(
name: str, cpython_prefix: str = "cpython"
) -> tuple[pathlib.Path, ...]:
"""Get additional data paths for a module.
def _extract_top_level_code(content: str) -> str:
"""Extract only top-level code from Python content for faster parsing.
Args:
name: Module name
cpython_prefix: CPython directory prefix
Returns:
Tuple of data paths (may be empty)
Cuts at first function/class definition since imports come before them.
"""
if name in DEPENDENCIES and "data" in DEPENDENCIES[name]:
return tuple(
construct_lib_path(cpython_prefix, p) for p in DEPENDENCIES[name]["data"]
)
return ()
# Find first function or class definition
def_idx = content.find("\ndef ")
class_idx = content.find("\nclass ")
# Use the earlier of the two (if found)
indices = [i for i in (def_idx, class_idx) if i != -1]
if indices:
content = content[: min(indices)]
return content.rstrip("\n")
_FROM_TEST_IMPORT_RE = re.compile(r"^from test import (.+)", re.MULTILINE)
_FROM_TEST_DOT_RE = re.compile(r"^from test\.(\w+)", re.MULTILINE)
def parse_test_imports(content: str) -> set[str]:
"""Parse test file content and extract 'from test import ...' dependencies.
Uses regex for speed - only matches top-level imports.
Args:
content: Python file content
Returns:
Set of module names imported from test package
"""
import ast
tree = safe_parse_ast(content)
if tree is None:
return set()
content = _extract_top_level_code(content)
imports = set()
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom):
if node.module == "test":
# from test import foo, bar
for alias in node.names:
name = alias.name
# Skip support modules and common imports
if name not in ("support", "__init__"):
imports.add(name)
elif node.module and node.module.startswith("test."):
# from test.foo import bar -> depends on foo
parts = node.module.split(".")
if len(parts) >= 2:
dep = parts[1]
if dep not in ("support", "__init__"):
imports.add(dep)
# Match "from test import foo, bar, baz"
for match in _FROM_TEST_IMPORT_RE.finditer(content):
import_list = match.group(1)
# Parse "foo, bar as b, baz" -> ["foo", "bar", "baz"]
for part in import_list.split(","):
name = part.split()[0].strip() # Handle "foo as f"
if name and name not in ("support", "__init__"):
imports.add(name)
# Match "from test.foo import ..." -> depends on foo
for match in _FROM_TEST_DOT_RE.finditer(content):
dep = match.group(1)
if dep not in ("support", "__init__"):
imports.add(dep)
return imports
# Match "import foo.bar" - module name must start with word char (not dot)
_IMPORT_RE = re.compile(r"^import\s+(\w[\w.]*)", re.MULTILINE)
# Match "from foo.bar import" - exclude relative imports (from . or from ..)
_FROM_IMPORT_RE = re.compile(r"^from\s+(\w[\w.]*)\s+import", re.MULTILINE)
def parse_lib_imports(content: str) -> set[str]:
"""Parse library file and extract all imported module names.
Uses regex for speed - only matches top-level imports (no leading whitespace).
Returns full module paths (e.g., "collections.abc" not just "collections").
Args:
content: Python file content
Returns:
Set of imported module names (top-level only)
Set of imported module names (full paths)
"""
import ast
tree = safe_parse_ast(content)
if tree is None:
return set()
# Note: Don't truncate content here - some stdlib files have imports after
# the first def/class (e.g., _pydecimal.py has `import contextvars` at line 343)
imports = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
# import foo, bar
for alias in node.names:
imports.add(alias.name.split(".")[0])
elif isinstance(node, ast.ImportFrom):
# from foo import bar
if node.module:
imports.add(node.module.split(".")[0])
# Match "import foo.bar" at line start
for match in _IMPORT_RE.finditer(content):
imports.add(match.group(1))
# Match "from foo.bar import ..." at line start
for match in _FROM_IMPORT_RE.finditer(content):
imports.add(match.group(1))
return imports
@@ -470,37 +509,411 @@ def get_test_dependencies(
return result
def resolve_all_paths(
name: str,
cpython_prefix: str = "cpython",
include_deps: bool = True,
) -> dict[str, list[pathlib.Path]]:
"""Resolve all paths for a module update.
def _parse_test_submodule_imports(content: str) -> dict[str, set[str]]:
"""Parse 'from test.X import Y' to get submodule imports.
Args:
name: Module name
cpython_prefix: CPython directory prefix
include_deps: Whether to include auto-detected dependencies
content: Python file content
Returns:
Dict with "lib", "test", "data", "test_deps" keys
Dict mapping submodule (e.g., "test_bar") -> set of imported names (e.g., {"helper"})
"""
result = {
"lib": list(get_lib_paths(name, cpython_prefix)),
"test": list(get_test_paths(name, cpython_prefix)),
"data": list(get_data_paths(name, cpython_prefix)),
"test_deps": [],
}
tree = safe_parse_ast(content)
if tree is None:
return {}
if include_deps:
# Auto-detect test dependencies
for test_path in result["test"]:
deps = get_test_dependencies(test_path)
for dep_path in deps["hard_deps"]:
if dep_path not in result["test_deps"]:
result["test_deps"].append(dep_path)
for data_path in deps["data"]:
if data_path not in result["data"]:
result["data"].append(data_path)
result: dict[str, set[str]] = {}
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom):
if node.module and node.module.startswith("test."):
# from test.test_bar import helper -> test_bar: {helper}
parts = node.module.split(".")
if len(parts) >= 2:
submodule = parts[1]
if submodule not in ("support", "__init__"):
if submodule not in result:
result[submodule] = set()
for alias in node.names:
result[submodule].add(alias.name)
return result
_test_import_graph_cache: dict[
str, tuple[dict[str, set[str]], dict[str, set[str]]]
] = {}
def _is_standard_lib_path(path: str) -> bool:
"""Check if path is the standard Lib directory (not a temp dir)."""
if "/tmp" in path.lower() or "/var/folders" in path.lower():
return False
return (
path == "Lib/test"
or path.endswith("/Lib/test")
or path == "Lib"
or path.endswith("/Lib")
)
def _build_test_import_graph(
test_dir: pathlib.Path,
) -> tuple[dict[str, set[str]], dict[str, set[str]]]:
"""Build import graphs for files within test directory (recursive).
Uses cross-process shelve cache based on CPython version.
Args:
test_dir: Path to Lib/test/ directory
Returns:
Tuple of:
- Dict mapping relative path (without .py) -> set of test modules it imports
- Dict mapping relative path (without .py) -> set of all lib imports
"""
# In-process cache
cache_key = str(test_dir)
if cache_key in _test_import_graph_cache:
return _test_import_graph_cache[cache_key]
# Cross-process cache (only for standard Lib/test directory)
use_file_cache = _is_standard_lib_path(cache_key)
if use_file_cache:
version = _get_cpython_version()
shelve_key = f"test_import_graph:{version}"
try:
with shelve.open(_get_cache_path()) as db:
if shelve_key in db:
import_graph, lib_imports_graph = db[shelve_key]
_test_import_graph_cache[cache_key] = (
import_graph,
lib_imports_graph,
)
return import_graph, lib_imports_graph
except Exception:
pass
# Build from scratch
import_graph: dict[str, set[str]] = {}
lib_imports_graph: dict[str, set[str]] = {}
for py_file in test_dir.glob("**/*.py"):
content = safe_read_text(py_file)
if content is None:
continue
imports = set()
imports.update(parse_test_imports(content))
all_imports = parse_lib_imports(content)
for imp in all_imports:
if (py_file.parent / f"{imp}.py").exists():
imports.add(imp)
if (test_dir / f"{imp}.py").exists():
imports.add(imp)
submodule_imports = _parse_test_submodule_imports(content)
for submodule, imported_names in submodule_imports.items():
submodule_dir = test_dir / submodule
if submodule_dir.is_dir():
for name in imported_names:
if (submodule_dir / f"{name}.py").exists():
imports.add(name)
rel_path = py_file.relative_to(test_dir)
key = str(rel_path.with_suffix(""))
import_graph[key] = imports
lib_imports_graph[key] = all_imports
# Save to cross-process cache
if use_file_cache:
try:
with shelve.open(_get_cache_path()) as db:
db[shelve_key] = (import_graph, lib_imports_graph)
except Exception:
pass
_test_import_graph_cache[cache_key] = (import_graph, lib_imports_graph)
return import_graph, lib_imports_graph
_lib_import_graph_cache: dict[str, dict[str, set[str]]] = {}
def _build_lib_import_graph(lib_prefix: str = "Lib") -> dict[str, set[str]]:
"""Build import graph for Lib modules (full module paths like urllib.request).
Uses cross-process shelve cache based on CPython version.
Args:
lib_prefix: RustPython Lib directory
Returns:
Dict mapping full_module_path -> set of modules it imports
"""
# In-process cache
if lib_prefix in _lib_import_graph_cache:
return _lib_import_graph_cache[lib_prefix]
# Cross-process cache (only for standard Lib directory)
use_file_cache = _is_standard_lib_path(lib_prefix)
if use_file_cache:
version = _get_cpython_version()
shelve_key = f"lib_import_graph:{version}"
try:
with shelve.open(_get_cache_path()) as db:
if shelve_key in db:
import_graph = db[shelve_key]
_lib_import_graph_cache[lib_prefix] = import_graph
return import_graph
except Exception:
pass
# Build from scratch
lib_dir = pathlib.Path(lib_prefix)
if not lib_dir.exists():
return {}
import_graph: dict[str, set[str]] = {}
for entry in lib_dir.iterdir():
if entry.name.startswith(("_", ".")):
continue
if entry.name == "test":
continue
if entry.is_file() and entry.suffix == ".py":
content = safe_read_text(entry)
if content:
imports = parse_lib_imports(content)
imports.discard(entry.stem)
import_graph[entry.stem] = imports
elif entry.is_dir() and (entry / "__init__.py").exists():
for py_file in entry.glob("**/*.py"):
content = safe_read_text(py_file)
if content:
imports = parse_lib_imports(content)
rel_path = py_file.relative_to(lib_dir)
if rel_path.name == "__init__.py":
full_name = str(rel_path.parent).replace("/", ".")
else:
full_name = str(rel_path.with_suffix("")).replace("/", ".")
imports.discard(full_name.split(".")[0])
import_graph[full_name] = imports
# Save to cross-process cache
if use_file_cache:
try:
with shelve.open(_get_cache_path()) as db:
db[shelve_key] = import_graph
except Exception:
pass
_lib_import_graph_cache[lib_prefix] = import_graph
return import_graph
def _get_lib_modules_importing(
module_name: str, lib_import_graph: dict[str, set[str]]
) -> set[str]:
"""Find Lib modules (full paths) that import module_name or any of its submodules."""
importers: set[str] = set()
target_top = module_name.split(".")[0]
for full_path, imports in lib_import_graph.items():
if full_path.split(".")[0] == target_top:
continue # Skip same package
# Match if module imports target OR any submodule of target
# e.g., for "xml": match imports of "xml", "xml.parsers", "xml.etree.ElementTree"
matches = any(
imp == module_name or imp.startswith(module_name + ".") for imp in imports
)
if matches:
importers.add(full_path)
return importers
def _consolidate_submodules(
modules: set[str], threshold: int = 3
) -> dict[str, set[str]]:
"""Consolidate submodules if count exceeds threshold.
Args:
modules: Set of full module paths (e.g., {"urllib.request", "urllib.parse", "xml.dom", "xml.sax"})
threshold: If submodules > threshold, consolidate to parent
Returns:
Dict mapping display_name -> set of original module paths
e.g., {"urllib.request": {"urllib.request"}, "xml": {"xml.dom", "xml.sax", "xml.etree", "xml.parsers"}}
"""
# Group by top-level package
by_package: dict[str, set[str]] = {}
for mod in modules:
parts = mod.split(".")
top = parts[0]
if top not in by_package:
by_package[top] = set()
by_package[top].add(mod)
result: dict[str, set[str]] = {}
for top, submods in by_package.items():
if len(submods) > threshold:
# Consolidate to top-level
result[top] = submods
else:
# Keep individual
for mod in submods:
result[mod] = {mod}
return result
# Modules that are used everywhere - show but don't expand their dependents
_BLOCKLIST_MODULES = frozenset(
{
"unittest",
"test.support",
"support",
"doctest",
"typing",
"abc",
"collections.abc",
"functools",
"itertools",
"operator",
"contextlib",
"warnings",
"types",
"enum",
"re",
"io",
"os",
"sys",
}
)
def find_dependent_tests_tree(
module_name: str,
lib_prefix: str = "Lib",
max_depth: int = 1,
_depth: int = 0,
_visited_tests: set[str] | None = None,
_visited_modules: set[str] | None = None,
) -> dict:
"""Find dependent tests in a tree structure.
Args:
module_name: Module to search for (e.g., "ftplib")
lib_prefix: RustPython Lib directory
max_depth: Maximum depth to recurse (default 1 = show direct + 1 level of Lib deps)
Returns:
Dict with structure:
{
"module": "ftplib",
"tests": ["test_ftplib", "test_urllib2"], # Direct importers
"children": [
{"module": "urllib.request", "tests": [...], "children": []},
...
]
}
"""
lib_dir = pathlib.Path(lib_prefix)
test_dir = lib_dir / "test"
if _visited_tests is None:
_visited_tests = set()
if _visited_modules is None:
_visited_modules = set()
# Build graphs
test_import_graph, test_lib_imports = _build_test_import_graph(test_dir)
lib_import_graph = _build_lib_import_graph(lib_prefix)
# Find tests that directly import this module
target_top = module_name.split(".")[0]
direct_tests: set[str] = set()
for file_key, imports in test_lib_imports.items():
if file_key in _visited_tests:
continue
# Match exact module OR any child submodule
# e.g., "xml" matches imports of "xml", "xml.parsers", "xml.etree.ElementTree"
# but "collections._defaultdict" only matches "collections._defaultdict" (no children)
matches = any(
imp == module_name or imp.startswith(module_name + ".") for imp in imports
)
if matches:
# Check if it's a test file
if pathlib.Path(file_key).name.startswith("test_"):
direct_tests.add(file_key)
_visited_tests.add(file_key)
# Consolidate test names (test_sqlite3/test_dbapi -> test_sqlite3)
consolidated_tests = {_consolidate_file_key(t) for t in direct_tests}
# Mark this module as visited (cycle detection)
_visited_modules.add(module_name)
_visited_modules.add(target_top)
children = []
# Check blocklist and depth limit
should_expand = (
_depth < max_depth
and module_name not in _BLOCKLIST_MODULES
and target_top not in _BLOCKLIST_MODULES
)
if should_expand:
# Find Lib modules that import this module
lib_importers = _get_lib_modules_importing(module_name, lib_import_graph)
# Skip already visited modules (cycle detection) and blocklisted modules
lib_importers = {
m
for m in lib_importers
if m not in _visited_modules
and m.split(".")[0] not in _visited_modules
and m not in _BLOCKLIST_MODULES
and m.split(".")[0] not in _BLOCKLIST_MODULES
}
# Consolidate submodules (xml.dom, xml.sax, xml.etree -> xml if > 3)
consolidated_libs = _consolidate_submodules(lib_importers, threshold=3)
# Build children
for display_name, original_mods in sorted(consolidated_libs.items()):
child = find_dependent_tests_tree(
display_name,
lib_prefix,
max_depth,
_depth + 1,
_visited_tests,
_visited_modules,
)
if child["tests"] or child["children"]:
children.append(child)
return {
"module": module_name,
"tests": sorted(consolidated_tests),
"children": children,
}
def _consolidate_file_key(file_key: str) -> str:
"""Consolidate file_key to test name.
Args:
file_key: Relative path without .py (e.g., "test_foo", "test_bar/test_sub")
Returns:
Consolidated test name:
- "test_foo" for "test_foo"
- "test_sqlite3" for "test_sqlite3/test_dbapi"
"""
parts = pathlib.Path(file_key).parts
if len(parts) == 1:
return parts[0]
return parts[0]
+63 -2
View File
@@ -160,6 +160,7 @@ def format_deps(
"""
from update_lib.deps import (
DEPENDENCIES,
find_dependent_tests_tree,
get_lib_paths,
get_test_paths,
)
@@ -185,15 +186,75 @@ def format_deps(
dep_info = DEPENDENCIES.get(name, {})
hard_deps = dep_info.get("hard_deps", [])
if hard_deps:
lines.append(f"hard_deps: {hard_deps}")
lines.append(f"packages: {hard_deps}")
lines.append("soft_deps:")
lines.append("dependencies:")
lines.extend(
format_deps_tree(
cpython_prefix, lib_prefix, max_depth, soft_deps={name}, _visited=_visited
)
)
# Show dependent tests as tree (depth 2: module + direct importers + their importers)
tree = find_dependent_tests_tree(name, lib_prefix=lib_prefix, max_depth=2)
lines.extend(_format_dependent_tests_tree(tree, cpython_prefix, lib_prefix))
return lines
def _format_dependent_tests_tree(
tree: dict,
cpython_prefix: str = "cpython",
lib_prefix: str = "Lib",
indent: str = "",
) -> list[str]:
"""Format dependent tests tree for display."""
from update_lib.deps import is_up_to_date
lines = []
module = tree["module"]
tests = tree["tests"]
children = tree["children"]
if indent == "":
# Root level
# Count total tests in tree
def count_tests(t: dict) -> int:
total = len(t.get("tests", []))
for c in t.get("children", []):
total += count_tests(c)
return total
total = count_tests(tree)
if total == 0 and not children:
lines.append(f"dependent tests: (no tests depend on {module})")
return lines
lines.append(f"dependent tests: ({total} tests)")
# Check if module is up-to-date
synced = is_up_to_date(module.split(".")[0], cpython_prefix, lib_prefix)
marker = "[x]" if synced else "[ ]"
# Format this node
if tests:
test_str = " ".join(tests)
if indent == "":
lines.append(f"- {marker} {module}: {test_str}")
else:
lines.append(f"{indent}- {marker} {module}: {test_str}")
elif indent != "" and children:
# Has children but no direct tests
lines.append(f"{indent}- {marker} {module}:")
# Format children
child_indent = indent + " " if indent else " "
for child in children:
lines.extend(
_format_dependent_tests_tree(
child, cpython_prefix, lib_prefix, child_indent
)
)
return lines
+2 -41
View File
@@ -5,14 +5,12 @@ import tempfile
import unittest
from update_lib.deps import (
get_data_paths,
get_lib_paths,
get_soft_deps,
get_test_dependencies,
get_test_paths,
parse_lib_imports,
parse_test_imports,
resolve_all_paths,
)
@@ -128,21 +126,6 @@ class TestGetTestPaths(unittest.TestCase):
self.assertEqual(paths, (tmpdir / "Lib" / "test" / "test_foo.py",))
class TestGetDataPaths(unittest.TestCase):
"""Tests for get_data_paths function."""
def test_known_data(self):
"""Test module with known data paths."""
paths = get_data_paths("regrtest", "cpython")
self.assertEqual(len(paths), 1)
self.assertEqual(paths[0], pathlib.Path("cpython/Lib/test/regrtestdata"))
def test_no_data(self):
"""Test module without data paths."""
paths = get_data_paths("datetime", "cpython")
self.assertEqual(paths, ())
class TestGetTestDependencies(unittest.TestCase):
"""Tests for get_test_dependencies function."""
@@ -211,28 +194,6 @@ class TestKR:
self.assertEqual(result["data"][0], test_dir / "cjkencodings")
class TestResolveAllPaths(unittest.TestCase):
"""Tests for resolve_all_paths function."""
def test_datetime(self):
"""Test resolving datetime module."""
result = resolve_all_paths("datetime", include_deps=False)
self.assertEqual(len(result["lib"]), 2)
self.assertIn(pathlib.Path("cpython/Lib/datetime.py"), result["lib"])
self.assertIn(pathlib.Path("cpython/Lib/_pydatetime.py"), result["lib"])
def test_regrtest(self):
"""Test resolving regrtest module."""
result = resolve_all_paths("regrtest", include_deps=False)
self.assertEqual(result["lib"], [pathlib.Path("cpython/Lib/test/libregrtest")])
self.assertEqual(
result["test"], [pathlib.Path("cpython/Lib/test/test_regrtest")]
)
self.assertEqual(
result["data"], [pathlib.Path("cpython/Lib/test/regrtestdata")]
)
class TestParseLibImports(unittest.TestCase):
"""Tests for parse_lib_imports function."""
@@ -244,7 +205,7 @@ import sys
import collections.abc
"""
imports = parse_lib_imports(code)
self.assertEqual(imports, {"os", "sys", "collections"})
self.assertEqual(imports, {"os", "sys", "collections.abc"})
def test_from_import(self):
"""Test parsing 'from foo import bar'."""
@@ -254,7 +215,7 @@ from collections.abc import Mapping
from typing import Optional
"""
imports = parse_lib_imports(code)
self.assertEqual(imports, {"os", "collections", "typing"})
self.assertEqual(imports, {"os", "collections.abc", "typing"})
def test_mixed_imports(self):
"""Test mixed import styles."""