diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 720cd53d3bc..83c81c7eaa4 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -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 diff --git a/.github/workflows/lib-deps-check.yaml b/.github/workflows/lib-deps-check.yaml index 27b3dec3620..8749f5fc2e7 100644 --- a/.github/workflows/lib-deps-check.yaml +++ b/.github/workflows/lib-deps-check.yaml @@ -99,19 +99,11 @@ jobs: The following Lib/ modules were modified. Here are their dependencies: -
- Click to expand dependency information - - ``` ${{ steps.deps-check.outputs.deps_output }} - ``` - -
**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 == '' diff --git a/scripts/update_lib/.gitignore b/scripts/update_lib/.gitignore new file mode 100644 index 00000000000..ceddaa37f12 --- /dev/null +++ b/scripts/update_lib/.gitignore @@ -0,0 +1 @@ +.cache/ diff --git a/scripts/update_lib/deps.py b/scripts/update_lib/deps.py index 566f5ae5f0c..089cc143c78 100644 --- a/scripts/update_lib/deps.py +++ b/scripts/update_lib/deps.py @@ -7,10 +7,49 @@ - 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. - - Args: - name: Module name - cpython_prefix: CPython directory prefix +def _extract_top_level_code(content: str) -> str: + """Extract only top-level code from Python content for faster parsing. - 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 + # 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() - tree = safe_parse_ast(content) - if tree is None: - return set() + # Match "import foo.bar" at line start + for match in _IMPORT_RE.finditer(content): + imports.add(match.group(1)) - 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 "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 {} + + 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]]] +] = {} - 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) + +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] diff --git a/scripts/update_lib/show_deps.py b/scripts/update_lib/show_deps.py index b6beacacaab..ae23ced3ead 100644 --- a/scripts/update_lib/show_deps.py +++ b/scripts/update_lib/show_deps.py @@ -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 diff --git a/scripts/update_lib/tests/test_deps.py b/scripts/update_lib/tests/test_deps.py index bc70925348b..4555843e1f8 100644 --- a/scripts/update_lib/tests/test_deps.py +++ b/scripts/update_lib/tests/test_deps.py @@ -5,14 +5,12 @@ 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 @@ def test_default_file(self): 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 @@ def test_import_statement(self): 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 @@ def test_from_import(self): 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."""