Files
2026-07-13 12:24:33 +08:00

220 lines
8.9 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Generic helper for plugin-style auto-discovery of concrete subclasses.
This module centralises the boilerplate that several packages used to
duplicate (CLI commands, controller-benchmark handlers, lookup-client
record strategies, health-monitor checks, remote connector adapters,
...). Each of those packages walks its own submodules with
``pkgutil.iter_modules``, imports them, then iterates classes via
``inspect.getmembers`` to locate concrete subclasses of a given base
class. ``discover_subclasses`` captures that pattern in a single
well-tested place so callers stay tiny and behave consistently.
"""
# Standard
from types import ModuleType
from typing import Callable, Iterator, Optional, Sequence, TypeVar, Union
import importlib
import inspect
import pkgutil
# First Party
from lmcache.logging import init_logger
logger = init_logger(__name__)
T = TypeVar("T")
# Sentinel meaning "scan recursively without any depth limit".
_UNLIMITED = 0
def _resolve_package(package: Union[ModuleType, str]) -> ModuleType:
if isinstance(package, str):
return importlib.import_module(package)
return package
def _normalize_levels(levels: Optional[Sequence[int]]) -> tuple[int, int]:
"""Normalize the ``levels`` argument to a ``(min_depth, max_depth)``
pair. ``max_depth == 0`` is the sentinel for "no upper bound".
Accepted forms:
* ``None`` -> ``(1, 1)`` (legacy behavior).
* ``[]`` or ``[0, 0]`` -> ``(1, 0)`` -> unlimited depth.
* ``[lo, hi]`` with ``1 <= lo <= hi`` -> ``(lo, hi)``.
Any other shape raises ``ValueError`` so misconfigurations surface
immediately at the call site instead of silently scanning the wrong
set of modules.
"""
if levels is None:
return 1, 1
if len(levels) == 0:
return 1, _UNLIMITED
if len(levels) != 2:
raise ValueError(
"levels must be either None, an empty sequence, or a "
"two-element sequence [min, max]; got %r" % (list(levels),)
)
lo, hi = int(levels[0]), int(levels[1])
if lo == 0 and hi == 0:
return 1, _UNLIMITED
if lo < 1 or hi < 1 or lo > hi:
raise ValueError(
"levels must satisfy 1 <= min <= max (or be [0, 0] for "
"unlimited); got %r" % (list(levels),)
)
return lo, hi
def discover_subclasses(
package: Union[ModuleType, str],
base_class: type[T],
*,
module_filter: Optional[Callable[[str], bool]] = None,
include_abstract: bool = False,
require_defined_in_module: bool = True,
on_import_error: Optional[Callable[[str, Exception], None]] = None,
levels: Optional[Sequence[int]] = None,
) -> Iterator[type[T]]:
"""Yield concrete subclasses of *base_class* found by walking the
submodules of *package*.
Each subclass is yielded **at most once**, even when re-exported
from several modules.
Args:
package: The package to scan, either as a module object or its
fully-qualified dotted name.
base_class: The base class whose concrete subclasses to collect.
module_filter: Optional predicate over the *short* module name
(i.e. without the package prefix). Modules for which the
predicate returns ``False`` are skipped. Sub-packages are
still descended into so a deep scan can keep filtering by
leaf module name. Defaults to ``None`` which keeps every
module.
include_abstract: When ``False`` (default) classes with
unimplemented abstract methods are skipped.
require_defined_in_module: When ``True`` (default) classes that
were merely imported (re-exported) into a module are
ignored; only classes whose ``__module__`` matches the
module being scanned are yielded. Set to ``False`` to keep
the historical behaviour of accepting re-exported classes.
on_import_error: Optional callback invoked as
``on_import_error(full_module_name, exc)`` when a submodule
fails to import. When omitted the error is logged at
``warning`` level and discovery proceeds with the next
module.
levels: Optional ``[min_depth, max_depth]`` window controlling
which levels of submodules contribute classes. *package*
itself is depth ``0``; its direct submodules are depth
``1``; etc. ``None`` (default) and ``[1, 1]`` are
equivalent and only inspect direct children -- the
historical behavior. ``[1, 2]`` inspects direct children
and grand-children, ``[2, 2]`` only the grand-children, and
``[]`` / ``[0, 0]`` recurse without any depth limit.
A sub-package's ``__init__.py`` is scanned at the depth
where the sub-package itself sits (e.g. ``bench/__init__.py``
is depth 1), matching the pre-levels behavior where
sub-packages were treated as ordinary modules.
Raises:
TypeError: If *package* is not a real package (no ``__path__``).
ValueError: If *levels* is malformed; see :func:`_normalize_levels`.
"""
pkg = _resolve_package(package)
pkg_path = getattr(pkg, "__path__", None)
if pkg_path is None:
raise TypeError(
"discover_subclasses requires a package (with __path__), got %r" % (pkg,)
)
min_depth, max_depth = _normalize_levels(levels)
seen: set[type] = set()
def _walk(
current_pkg: ModuleType,
current_path: Sequence[str],
depth: int,
) -> Iterator[type[T]]:
# Stop descending once we are past the requested upper bound.
if max_depth != _UNLIMITED and depth > max_depth:
return
def _scan_module(module: ModuleType) -> Iterator[type[T]]:
"""Yield classes from *module* that match all filters."""
for _, obj in inspect.getmembers(module, inspect.isclass):
try:
if not issubclass(obj, base_class) or obj is base_class:
continue
except TypeError:
# typing.GenericAlias (e.g. ``list[Foo]``) passes
# inspect.isclass on Python < 3.12 but issubclass
# raises on ABCs; skip silently.
continue
if not include_abstract and inspect.isabstract(obj):
continue
if require_defined_in_module and (obj.__module__ != module.__name__):
continue
if obj in seen:
continue
seen.add(obj)
yield obj
for _, short_name, is_pkg in pkgutil.iter_modules(current_path):
full_name = "%s.%s" % (current_pkg.__name__, short_name)
if is_pkg:
# Recurse into sub-packages so their leaf modules can
# still be reached. The sub-package ``__init__.py`` is
# also scanned at the current depth -- this preserves
# the pre-levels behaviour where sub-package init files
# were treated just like ordinary leaf modules.
can_scan_init = depth >= min_depth and (
max_depth == _UNLIMITED or depth <= max_depth
)
can_recurse = max_depth == _UNLIMITED or depth + 1 <= max_depth
if not can_scan_init and not can_recurse:
continue
try:
sub_pkg = importlib.import_module(full_name)
except Exception as exc:
_report_import_error(full_name, exc)
continue
sub_path = getattr(sub_pkg, "__path__", None)
if sub_path is None:
continue
if can_scan_init:
if module_filter is None or module_filter(short_name):
yield from _scan_module(sub_pkg)
if can_recurse:
yield from _walk(sub_pkg, sub_path, depth + 1)
continue
# Leaf module: apply depth window + caller's name filter.
if depth < min_depth:
continue
if max_depth != _UNLIMITED and depth > max_depth:
continue
if module_filter is not None and not module_filter(short_name):
continue
try:
module = importlib.import_module(full_name)
except Exception as exc:
_report_import_error(full_name, exc)
continue
yield from _scan_module(module)
def _report_import_error(full_name: str, exc: Exception) -> None:
if on_import_error is not None:
on_import_error(full_name, exc)
else:
logger.warning(
"Failed to import module %s during subclass discovery: %s",
full_name,
exc,
)
yield from _walk(pkg, pkg_path, depth=1)