Files
2026-07-13 13:17:40 +08:00

394 lines
13 KiB
Python

import argparse
import ast
import sys
from pathlib import Path
from typing import Dict, List, Optional, Set, Tuple
TRAIN_PACKAGES = set()
def find_train_packages(base_train_dir: Path, patch_train_dir: Path) -> None:
"""
Find and initialize the global TRAIN_PACKAGES with all train package names from base_train_dir
and patch_train_dir combined.
"""
global TRAIN_PACKAGES
TRAIN_PACKAGES = set()
# Collect all packages under both base and patch train dirs
package_files = list(base_train_dir.rglob("__init__.py")) + list(
patch_train_dir.rglob("__init__.py")
)
base_dir = get_base_dir()
for init_file in package_files:
relative_path = init_file.relative_to(base_dir)
dotted_module = str(relative_path.parent).replace("/", ".")
TRAIN_PACKAGES.add(dotted_module)
def is_train_package(module_str: str) -> bool:
return module_str in TRAIN_PACKAGES
def get_base_dir() -> Path:
"""Return the filesystem path to the ray python directory."""
current_file_path = Path(__file__).resolve()
package_dir = current_file_path.parents[3]
return package_dir
def get_base_train_dir() -> Path:
"""Return the filesystem path to the ray train directory."""
return get_base_dir() / "ray/train"
def does_overlap(main_module: str, module: str) -> bool:
"""Checks if the init file of module is on the import path of main_module"""
return main_module.startswith(f"{module}.") or main_module == module
class Import:
"""
Represents an import statement.
For example, 'from X import A, B' has module 'X' and names ['A', 'B'].
Also supports 'import X'.
"""
def __init__(
self, module: str, names: List[str] = None, is_package: bool = False
) -> None:
self.is_package = is_package
self.module = module
self.names = names if names else []
class ImportCollector(ast.NodeVisitor):
"""
An AST node visitor that collects all module-level imports from a Python source file.
It traverses the AST and records module-level import statements (`import ...` and `from ... import ...`) that are not
inside function or class definitions, and that are not guarded by `if TYPE_CHECKING` or `if typing.TYPE_CHECKING`
blocks.
"""
def __init__(self, module_name: str, is_package: bool) -> None:
self._module_name = module_name
self._is_package = is_package
self.imports: Set[Import] = set()
self.type_checking_imported = False
# --- private helpers ---
def _is_type_checking_test(self, expr: ast.AST) -> bool:
"""Return True for `if TYPE_CHECKING` or `if typing.TYPE_CHECKING`."""
if (
self.type_checking_imported
and isinstance(expr, ast.Name)
and expr.id == "TYPE_CHECKING"
):
return True
elif (
isinstance(expr, ast.Attribute)
and isinstance(expr.value, ast.Name)
and expr.value.id == "typing"
and expr.attr == "TYPE_CHECKING"
):
return True
return False
def _get_package_parts(self) -> List[str]:
parts = self._module_name.split(".")
return parts if self._is_package else parts[:-1]
def _to_absolute_module(
self, level: int, module_str: Optional[str]
) -> Optional[str]:
"""Construct the absolute module string from a relative import."""
# Absolute import
if level == 0:
return module_str
package_parts = self._get_package_parts()
# If the relative import is out of bounds
if level - 1 > len(package_parts):
return None
# Base parts based on the level
base_module_parts = (
package_parts if level == 1 else package_parts[: -(level - 1)]
)
# Construct absolute module string
abs_module_parts = (
base_module_parts + module_str.split(".")
if module_str
else base_module_parts
)
return ".".join(abs_module_parts)
# --- parsing functions ---
def visit_If(self, node: ast.If) -> None:
# If the test is not TYPE_CHECKING, visit statement body
if not self._is_type_checking_test(node.test):
for stmt in node.body:
self.visit(stmt)
# Also visit conditional branches
for stmt in node.orelse:
self.visit(stmt)
def visit_Import(self, node: ast.Import) -> None:
for alias in node.names:
if alias.name:
self.imports.add(
Import(module=alias.name, is_package=is_train_package(alias.name))
)
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
import_str = self._to_absolute_module(node.level or 0, node.module)
if not import_str:
return
names = [alias.name for alias in node.names]
self.imports.add(
Import(
module=import_str, is_package=is_train_package(import_str), names=names
)
)
if "TYPE_CHECKING" in names and import_str == "typing":
self.type_checking_imported = True
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
# Skip function contents
return
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
# Skip function contents
return
def visit_ClassDef(self, node: ast.ClassDef) -> None:
# Skip class contents
return
def collect_imports(
module_name: str, is_package: bool, source_text: str
) -> Set[Import]:
try:
tree = ast.parse(source_text)
except SyntaxError:
print(f"Warning: Failed to parse {module_name} for circular imports")
return set()
collector = ImportCollector(module_name, is_package)
collector.visit(tree)
return collector.imports
def to_module_name_and_is_package(py_file: Path) -> Tuple[str, bool]:
"""
Convert a Python file path to its corresponding module name and determine if it is a package.
Args:
py_file: The path to the Python file.
Returns:
Tuple[str, bool]: A tuple containing the module name as a string and a boolean indicating
whether the module is a package (True if it is an __init__.py file).
"""
file_path = py_file.relative_to(get_base_dir())
module_path = file_path.with_suffix("")
module_parts = module_path.parts
is_package = module_parts[-1] == "__init__"
if is_package:
module_parts = module_parts[:-1]
module_str = ".".join(module_parts)
return module_str, is_package
def get_file_module_imports(
files: List[Path], module_match_string: Optional[str] = None
) -> Dict[str, List[Import]]:
"""
Collect and return the module-level imports for a list of Python files.
Args:
files: A list of Path objects representing Python files to analyze.
module_match_string: An optional string to filter imports. Only imports
containing this string will be included in the result.
Returns:
A dictionary mapping module names to a list of their import statements.
The module names are derived from the file paths, and the import statements
are filtered based on the optional module_match_string.
"""
module_imports: Dict[str, List[Import]] = {}
# Collect the imports for each python file
for py_file in files:
try:
module_name, is_package = to_module_name_and_is_package(py_file)
src = py_file.read_text(encoding="utf-8", errors="ignore")
imports = collect_imports(module_name, is_package, src)
module_imports[module_name] = [
stmt
for stmt in imports
if module_match_string is None or module_match_string in stmt.module
]
except Exception:
continue
return module_imports
def convert_to_file_paths(imports: List[Import]) -> List[Path]:
"""
Convert a list of import strings to a list of file paths.
Args:
imports: A list of Import objects
Returns:
A list of file paths.
"""
base_dir = get_base_dir()
file_paths = []
for imp in imports:
if imp.is_package:
relative_path = imp.module.replace(".", "/") + "/__init__.py"
else:
relative_path = imp.module.replace(".", "/") + ".py"
file_paths.append(base_dir / relative_path)
return file_paths
def expand_to_include_reexports(import_map: Dict[str, List[Import]]) -> None:
"""
Expands the set of imports for a given import map to include the modules resulting from reexports.
So if in the base train module, there is "from x import a, b" and x is a package, then this function
will explore the __init__.py of x and include the modules a and b were reexported from in the import map.
"""
for module, base_imports in import_map.items():
# Get only the package imports
packages = [imp for imp in base_imports if imp.is_package]
package_files = convert_to_file_paths(packages)
reexports = get_file_module_imports(package_files)
agg_reexports = []
# Filter patch init file imports to those that only contain the right names
for base_import in base_imports:
if base_import.module in reexports:
import_list = reexports[base_import.module]
target_reexports = [
imp
for imp in import_list
if set(imp.names) & set(base_import.names)
]
agg_reexports.extend(target_reexports)
# Expand modules to include reexported modules
import_map[module].extend(agg_reexports)
def check_violations(
base_train_patching_imports: Dict[str, List[Import]], patch_dir: Path
) -> List[str]:
"""
Check for circular import violations between base and patch train modules.
Args:
base_train_patching_imports: A dictionary mapping base train module names to their imports.
patch_dir: The directory path containing patch train modules.
Returns:
A list of strings describing any circular import violations found.
"""
violations: List[str] = []
# Get the imports from the patch train init files
patch_train_init_files = list(patch_dir.rglob("__init__.py"))
patch_train_init_imports = get_file_module_imports(
patch_train_init_files, module_match_string="ray.train"
)
# Expand the imports to include reexports
expand_to_include_reexports(base_train_patching_imports)
# Process each patch train init module for violations
for base_train_init_module, imports in base_train_patching_imports.items():
# Get the imports from the patch train files
patch_train_files = convert_to_file_paths(imports)
patch_train_file_imports = get_file_module_imports(
patch_train_files, module_match_string="ray.train"
)
for patch_module, imports in patch_train_file_imports.items():
# Skip if the base train init module is in the import path of the patch module
if does_overlap(patch_module, base_train_init_module):
continue
# Skip if the patch train module init file imports the base train init module
patch_init_module = (
".".join(patch_module.split(".")[:-1])
if not is_train_package(patch_module)
else patch_module
)
patch_init_imports = patch_train_init_imports.get(patch_init_module, [])
if any(
does_overlap(imp.module, base_train_init_module)
for imp in patch_init_imports
):
continue
for patch_import in imports:
# If any of those v1 imports go through the init file, then it is a violation
if does_overlap(patch_import.module, base_train_init_module):
violations.append(
f"circular-import-train: Circular import between {base_train_init_module} (importing {patch_module}) and {patch_module} (importing {patch_import.module}). Resolve by importing {base_train_init_module} in the __init__.py of {patch_init_module}."
)
return violations
def main():
parser = argparse.ArgumentParser()
parser.add_argument(
"--patch_dir",
default="ray/train/v2",
help="Path to the directory containing patching contents",
)
args = parser.parse_args()
# Get train directory paths
base_dir = get_base_dir()
base_train_dir = get_base_train_dir()
patch_train_dir = base_dir / Path(args.patch_dir)
# Find and save all train packages in global TRAIN_PACKAGES for reference
find_train_packages(base_train_dir, patch_train_dir)
# Collect all base train init files
base_train_init_files = [
f
for f in base_train_dir.rglob("__init__.py")
if not f.is_relative_to(patch_train_dir)
]
# Get the patching imports in the base train init files
dotted_module_prefix = str(patch_train_dir.relative_to(base_dir)).replace("/", ".")
patching_imports = get_file_module_imports(
base_train_init_files, module_match_string=dotted_module_prefix
)
# Collect all violations based off the patching imports
violations = check_violations(patching_imports, patch_train_dir)
if violations:
print("\n".join(violations))
sys.exit(1)
if __name__ == "__main__":
main()