litellm/scripts/check_layer_imports.py
nate-berri ef39e56b16
fix(lint): read the base layer allowlist at the merge base
make lint and the pre-commit hook pass the base branch tip, so an allowlist entry removed on main after this branch split off was reported as newly added. The checker now resolves the merge base itself with the same helper the other gates use
2026-10-10 18:56:45 +00:00

206 lines
7.3 KiB
Python

import argparse
import ast
import sys
from collections.abc import Callable, Iterable, Iterator, Sequence
from dataclasses import dataclass
from itertools import chain, product
from pathlib import Path
from types import MappingProxyType
from typing import Final, Literal
from lint_base_counts import REPO_GIT, Git, resolve_base_point
PACKAGE_ROOT: Final = Path("litellm")
ALLOWLIST: Final = Path("scripts/layer_imports_allowlist.txt")
LEAF_MODULES: Final = frozenset(
{
"litellm.litellm_core_utils.completion_timeout",
"litellm.litellm_core_utils.core_helpers",
"litellm.litellm_core_utils.duration_parser",
"litellm.litellm_core_utils.llm_response_utils.get_headers",
"litellm.litellm_core_utils.provider_affinity",
}
)
TYPES_LAYER: Final = (
frozenset({"litellm.types", "litellm.models", "litellm.constants", "litellm._logging", "litellm._uuid"})
| LEAF_MODULES
)
L2_FORBIDDEN: Final = ("litellm.proxy", "litellm.main", "litellm.router")
Context = Literal["module", "function"]
@dataclass(frozen=True, slots=True)
class Rule:
name: str
applies_to: Callable[[str], bool]
forbids: Callable[[str], bool]
@dataclass(frozen=True, slots=True)
class Edge:
path: str
importer: str
imported: str
context: Context
line: int
@dataclass(frozen=True, slots=True)
class Violation:
rule: str
edge: Edge
@property
def key(self) -> str:
return f"{self.rule} {self.edge.context} {self.edge.path} {self.edge.imported}"
def is_under(module: str, package: str) -> bool:
return module == package or module.startswith(f"{package}.")
def escapes_types_layer(module: str) -> bool:
return is_under(module, "litellm") and not any(is_under(module, allowed) for allowed in TYPES_LAYER)
RULES: Final = (
Rule(
"L1",
lambda module: is_under(module, "litellm") and not is_under(module, "litellm.proxy"),
lambda target: is_under(target, "litellm.proxy"),
),
Rule(
"L2",
lambda module: is_under(module, "litellm.llms"),
lambda target: any(is_under(target, forbidden) for forbidden in L2_FORBIDDEN),
),
Rule("L3", lambda module: is_under(module, "litellm.types"), escapes_types_layer),
Rule("LEAF", lambda module: module in LEAF_MODULES, escapes_types_layer),
)
def module_name(path: Path) -> str:
parts: Final = path.with_suffix("").parts
return ".".join(parts[:-1] if parts[-1] == "__init__" else parts)
def is_type_checking(test: ast.expr) -> bool:
return (isinstance(test, ast.Name) and test.id == "TYPE_CHECKING") or (
isinstance(test, ast.Attribute) and test.attr == "TYPE_CHECKING"
)
def import_nodes(node: ast.AST, context: Context) -> Iterator[tuple[ast.Import | ast.ImportFrom, Context]]:
if isinstance(node, ast.Import | ast.ImportFrom):
yield node, context
return
children: Final[Iterable[ast.AST]] = (
node.orelse if isinstance(node, ast.If) and is_type_checking(node.test) else ast.iter_child_nodes(node)
)
child_context: Final[Context] = (
"function" if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef | ast.Lambda) else context
)
for child in children:
yield from import_nodes(child, child_context)
def imported_modules(
node: ast.Import | ast.ImportFrom, importer: str, is_package: bool, known: frozenset[str]
) -> tuple[str, ...]:
if isinstance(node, ast.Import):
return tuple(alias.name for alias in node.names)
package: Final = importer.split(".") if is_package else importer.split(".")[:-1]
anchor: Final = tuple(package[: len(package) - node.level + 1]) if node.level else ()
base: Final = ".".join((*anchor, *((node.module,) if node.module else ())))
return tuple(f"{base}.{alias.name}" if f"{base}.{alias.name}" in known else base for alias in node.names)
def package_prefixes(module: str) -> Iterator[str]:
parts: Final = module.split(".")
return (".".join(parts[:end]) for end in range(1, len(parts) + 1))
def known_modules(paths: Iterable[Path]) -> frozenset[str]:
return frozenset(chain.from_iterable(package_prefixes(module_name(path)) for path in paths))
def file_edges(path: Path, known: frozenset[str]) -> Iterator[Edge]:
importer: Final = module_name(path)
tree: Final = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
for node, context in import_nodes(tree, "module"):
for imported in imported_modules(node, importer, path.name == "__init__.py", known):
yield Edge(path.as_posix(), importer, imported, context, node.lineno)
def violations(paths: tuple[Path, ...]) -> tuple[Violation, ...]:
known: Final = known_modules(paths)
edges: Final = tuple(chain.from_iterable(file_edges(path, known) for path in paths))
return tuple(
Violation(rule.name, edge)
for rule, edge in product(RULES, edges)
if rule.applies_to(edge.importer) and rule.forbids(edge.imported)
)
def parse_allowlist(text: str) -> frozenset[str]:
lines: Final = (" ".join(line.split()) for line in text.splitlines())
return frozenset(line for line in lines if line and not line.startswith("#"))
def report(
found: tuple[Violation, ...], allowed: frozenset[str], base_allowed: frozenset[str] | None
) -> tuple[str, ...]:
first_seen: Final = MappingProxyType({violation.key: violation for violation in reversed(found)})
new: Final = tuple(
f"{violation.edge.path}:{violation.edge.line}: {violation.rule} forbids "
f"{violation.edge.importer} importing {violation.edge.imported} ({violation.edge.context} level)"
for key, violation in sorted(first_seen.items())
if key not in allowed
)
stale: Final = tuple(
f"{ALLOWLIST}: stale entry '{key}' no longer matches an import, delete it"
for key in sorted(allowed - first_seen.keys())
)
added: Final = (
tuple(
f"{ALLOWLIST}: entry '{key}' is not in the base allowlist, fix the import instead of allowlisting it"
for key in sorted(allowed - base_allowed)
)
if base_allowed is not None
else ()
)
return new + stale + added
def git_base_allowlist(ref: str, git: Git = REPO_GIT) -> frozenset[str] | None:
base_point: Final = resolve_base_point(ref, git)
tracked: Final = git(["ls-tree", "--name-only", base_point, "--", ALLOWLIST.as_posix()]).strip()
return parse_allowlist(git(["show", f"{base_point}:{ALLOWLIST.as_posix()}"])) if tracked else None
class Arguments(argparse.Namespace):
base: str | None = None
def main(
argv: Sequence[str] | None = None,
base_allowlist: Callable[[str], frozenset[str] | None] = git_base_allowlist,
) -> int:
parser: Final = argparse.ArgumentParser(description="Check SDK/proxy layer import rules")
parser.add_argument(
"--base", help="Also reject allowlist entries missing from the allowlist at the merge base with this ref"
)
args: Final = parser.parse_args(argv, namespace=Arguments())
paths: Final = tuple(sorted(PACKAGE_ROOT.rglob("*.py")))
base_allowed: Final = base_allowlist(args.base) if args.base else None
problems: Final = report(violations(paths), parse_allowlist(ALLOWLIST.read_text(encoding="utf-8")), base_allowed)
if problems:
print("\n".join(problems), file=sys.stderr)
return 1
print("Layer imports: passed")
return 0
if __name__ == "__main__":
sys.exit(main())