Coverage for src/lexigram/web/routing/discovery.py: 20%
49 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
1"""Controller auto-discovery utilities for ``lexigram.web.routing``.
3Scans Python packages and modules for ``ControllerProtocol`` subclasses,
4enabling convention-based composition roots in web applications.
5"""
7from __future__ import annotations
9import importlib
10import pkgutil
11from typing import Any
13from lexigram.logging import get_logger
15logger = get_logger(__name__)
18def discover_controllers(packages: tuple[str, ...] | list[str]) -> list[type]:
19 """Scan packages for Controller subclasses and return the classes.
21 Args:
22 packages: Dotted Python package paths to scan.
24 Returns:
25 List of Controller subclasses (not instantiated — the DI container
26 constructs them during boot).
27 """
28 from lexigram.contracts.web.controller import ControllerProtocol
30 found: list[type] = []
31 seen: set[type] = set()
33 for pkg in packages:
34 for cls in _scan_subclasses(pkg, ControllerProtocol):
35 if cls in seen:
36 continue
37 seen.add(cls)
38 found.append(cls)
39 logger.debug("discovery.controller.found", controller=cls.__name__)
41 return found
44def _scan_subclasses(package: str, base: type) -> list[type]:
45 """Walk a package tree and collect all non-abstract subclasses of *base*.
47 Args:
48 package: Dotted package path to scan.
49 base: Base class to match against.
51 Returns:
52 List of concrete subclasses found.
53 """
54 results: list[type] = []
56 try:
57 root = importlib.import_module(package)
58 except ImportError:
59 logger.debug("discovery.package.import_failed", package=package)
60 return results
62 root_path = getattr(root, "__path__", None)
63 if root_path is None:
64 results.extend(_collect_subclasses(root, base))
65 return results
67 for _finder, modname, _ispkg in pkgutil.walk_packages(
68 root_path,
69 prefix=package + ".",
70 onerror=lambda name: logger.debug("discovery.walk.error", module=name),
71 ):
72 try:
73 module = importlib.import_module(modname)
74 results.extend(_collect_subclasses(module, base))
75 except (ImportError, ModuleNotFoundError, AttributeError) as error:
76 logger.debug(
77 "discovery.module.import_failed", module=modname, error=str(error)
78 )
80 results.extend(_collect_subclasses(root, base))
81 return results
84def _collect_subclasses(module: Any, base: type) -> list[type]:
85 """Collect all concrete subclasses of *base* defined in *module*."""
86 results: list[type] = []
87 for attr_name in dir(module):
88 try:
89 obj = getattr(module, attr_name)
90 except (AttributeError, TypeError) as error:
91 logger.debug(
92 "discovery.getattr_failed",
93 module=module.__name__,
94 attr=attr_name,
95 error=str(error),
96 )
97 continue
98 if (
99 isinstance(obj, type)
100 and issubclass(obj, base)
101 and obj is not base
102 and obj.__module__ == module.__name__
103 ):
104 results.append(obj)
105 return results
108__all__ = ["discover_controllers"]