mirror of
https://github.com/RustPython/RustPython.git
synced 2026-10-28 18:59:04 +08:00
[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:
6 files changed
+565
-136
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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 == ''
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
.cache/
|
||||
+493
-80
@@ -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]
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in new issue
Block a user