Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
show_deps
  • Loading branch information
youknowone committed Jan 20, 2026
commit 23d886f2d379b0bf90ffeeaed639a92606494515
10 changes: 10 additions & 0 deletions scripts/update_lib/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ def main(argv: list[str] | None = None) -> int:
help="Copy library file/directory from CPython (delete existing first)",
add_help=False,
)
subparsers.add_parser(
"deps",
help="Show dependency information for a module",
add_help=False,
)

args, remaining = parser.parse_known_args(argv)

Expand Down Expand Up @@ -77,6 +82,11 @@ def main(argv: list[str] | None = None) -> int:

return auto_mark_main(remaining)

if args.command == "deps":
from update_lib.show_deps import main as show_deps_main

return show_deps_main(remaining)

return 0


Expand Down
71 changes: 71 additions & 0 deletions scripts/update_lib/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,77 @@ def parse_test_imports(content: str) -> set[str]:
return imports


def parse_lib_imports(content: str) -> set[str]:
"""Parse library file and extract all imported module names.

Args:
content: Python file content

Returns:
Set of imported module names (top-level only)
"""
try:
tree = ast.parse(content)
except SyntaxError:
return set()

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])

return imports


def get_soft_deps(name: str, cpython_prefix: str = "cpython") -> set[str]:
"""Get soft dependencies by parsing imports from library file.

Args:
name: Module name
cpython_prefix: CPython directory prefix

Returns:
Set of imported stdlib module names
"""
lib_paths = get_lib_paths(name, cpython_prefix)

all_imports = set()
for lib_path in lib_paths:
if lib_path.exists():
if lib_path.is_file():
try:
content = lib_path.read_text(encoding="utf-8")
all_imports.update(parse_lib_imports(content))
except (OSError, UnicodeDecodeError):
continue
else:
# Directory - parse all .py files
for py_file in lib_path.glob("**/*.py"):
try:
content = py_file.read_text(encoding="utf-8")
all_imports.update(parse_lib_imports(content))
except (OSError, UnicodeDecodeError):
continue

# Filter: only include modules that exist in cpython/Lib/
stdlib_deps = set()
for imp in all_imports:
if imp == name:
continue # Skip self
file_path = pathlib.Path(f"{cpython_prefix}/Lib/{imp}.py")
dir_path = pathlib.Path(f"{cpython_prefix}/Lib/{imp}")
if file_path.exists() or dir_path.exists():
stdlib_deps.add(imp)

return stdlib_deps


def get_test_dependencies(
test_path: pathlib.Path,
) -> dict[str, list[pathlib.Path]]:
Expand Down
78 changes: 78 additions & 0 deletions scripts/update_lib/show_deps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
#!/usr/bin/env python
"""
Show dependency information for a module.

Usage:
python scripts/update_lib deps dis
python scripts/update_lib deps dataclasses
"""

import argparse
import pathlib
import sys

sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))


def show_deps(name: str, cpython_prefix: str = "cpython") -> None:
"""Show all dependency information for a module."""
from update_lib.deps import (
DEPENDENCIES,
get_lib_paths,
get_soft_deps,
get_test_paths,
)

print(f"Module: {name}")

# lib paths
lib_paths = get_lib_paths(name, cpython_prefix)
for p in lib_paths:
exists = "+" if p.exists() else "-"
print(f" [{exists}] lib: {p}")

# test paths
test_paths = get_test_paths(name, cpython_prefix)
for p in test_paths:
exists = "+" if p.exists() else "-"
print(f" [{exists}] test: {p}")

# hard_deps (from DEPENDENCIES table)
dep_info = DEPENDENCIES.get(name, {})
hard_deps = dep_info.get("hard_deps", [])
if hard_deps:
print(f" hard_deps: {hard_deps}")

# soft_deps (auto-detected)
soft_deps = sorted(get_soft_deps(name, cpython_prefix))
if soft_deps:
print(f" soft_deps: {soft_deps}")


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"name",
help="Module name (e.g., dis, dataclasses, datetime)",
)
parser.add_argument(
"--cpython",
default="cpython",
help="CPython directory prefix (default: cpython)",
)

args = parser.parse_args(argv)

try:
show_deps(args.name, args.cpython)
return 0
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
return 1


if __name__ == "__main__":
sys.exit(main())
111 changes: 111 additions & 0 deletions scripts/update_lib/tests/test_deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
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,
Comment on lines +10 to 14
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Test logic changes violate repo test-editing rules.

New test logic/assertions were added in a **/*test*.py file, which conflicts with the rule that only expectedFailure annotations may be changed. Please confirm an exception or move these checks to a non-test module. As per coding guidelines, ...

Also applies to: 236-343

🤖 Prompt for AI Agents
In `@scripts/update_lib/tests/test_deps.py` around lines 10 - 14, The new
assertions/logic added to the test file violate the rule that test files under
**/*test*.py may only change expectedFailure annotations; revert the behavioral
changes in tests like get_soft_deps, get_test_dependencies, get_test_paths,
parse_lib_imports, and parse_test_imports and either (a) move any new validation
logic into a non-test helper/module (e.g., scripts/update_lib/helpers.py) and
import it from the tests, or (b) if the test must change behavior because of a
known failing condition, mark the test with `@pytest.mark.xfail` (or
expectedFailure) instead of changing assertions; ensure only annotation changes
remain in the test file.

resolve_all_paths,
)
Expand Down Expand Up @@ -231,5 +233,114 @@ def test_regrtest(self):
)


class TestParseLibImports(unittest.TestCase):
"""Tests for parse_lib_imports function."""

def test_import_statement(self):
"""Test parsing 'import foo'."""
code = """
import os
import sys
import collections.abc
"""
imports = parse_lib_imports(code)
self.assertEqual(imports, {"os", "sys", "collections"})

def test_from_import(self):
"""Test parsing 'from foo import bar'."""
code = """
from os import path
from collections.abc import Mapping
from typing import Optional
"""
imports = parse_lib_imports(code)
self.assertEqual(imports, {"os", "collections", "typing"})

def test_mixed_imports(self):
"""Test mixed import styles."""
code = """
import sys
from os import path
from collections import defaultdict
import functools
"""
imports = parse_lib_imports(code)
self.assertEqual(imports, {"sys", "os", "collections", "functools"})

def test_syntax_error_returns_empty(self):
"""Test that syntax errors return empty set."""
code = "this is not valid python {"
imports = parse_lib_imports(code)
self.assertEqual(imports, set())

def test_relative_import_skipped(self):
"""Test that relative imports (no module) are skipped."""
code = """
from . import foo
from .. import bar
"""
imports = parse_lib_imports(code)
self.assertEqual(imports, set())


class TestGetSoftDeps(unittest.TestCase):
"""Tests for get_soft_deps function."""

def test_with_temp_files(self):
"""Test soft deps detection with temp files."""
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir = pathlib.Path(tmpdir)
lib_dir = tmpdir / "Lib"
lib_dir.mkdir()

# Create a module that imports another module
(lib_dir / "foo.py").write_text("""
import bar
from baz import something
""")
# Create the imported modules
(lib_dir / "bar.py").write_text("# bar module")
(lib_dir / "baz.py").write_text("# baz module")

soft_deps = get_soft_deps("foo", str(tmpdir))
self.assertEqual(soft_deps, {"bar", "baz"})

def test_skips_self(self):
"""Test that module doesn't include itself in soft_deps."""
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir = pathlib.Path(tmpdir)
lib_dir = tmpdir / "Lib"
lib_dir.mkdir()

# Create a module that imports itself (circular)
(lib_dir / "foo.py").write_text("""
import foo
import bar
""")
(lib_dir / "bar.py").write_text("# bar module")

soft_deps = get_soft_deps("foo", str(tmpdir))
self.assertNotIn("foo", soft_deps)
self.assertIn("bar", soft_deps)

def test_filters_nonexistent(self):
"""Test that nonexistent modules are filtered out."""
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir = pathlib.Path(tmpdir)
lib_dir = tmpdir / "Lib"
lib_dir.mkdir()

# Create a module that imports nonexistent module
(lib_dir / "foo.py").write_text("""
import bar
import nonexistent
""")
(lib_dir / "bar.py").write_text("# bar module")
# nonexistent.py is NOT created

soft_deps = get_soft_deps("foo", str(tmpdir))
self.assertEqual(soft_deps, {"bar"})


if __name__ == "__main__":
unittest.main()