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

1"""Controller auto-discovery utilities for ``lexigram.web.routing``. 

2 

3Scans Python packages and modules for ``ControllerProtocol`` subclasses, 

4enabling convention-based composition roots in web applications. 

5""" 

6 

7from __future__ import annotations 

8 

9import importlib 

10import pkgutil 

11from typing import Any 

12 

13from lexigram.logging import get_logger 

14 

15logger = get_logger(__name__) 

16 

17 

18def discover_controllers(packages: tuple[str, ...] | list[str]) -> list[type]: 

19 """Scan packages for Controller subclasses and return the classes. 

20 

21 Args: 

22 packages: Dotted Python package paths to scan. 

23 

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 

29 

30 found: list[type] = [] 

31 seen: set[type] = set() 

32 

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__) 

40 

41 return found 

42 

43 

44def _scan_subclasses(package: str, base: type) -> list[type]: 

45 """Walk a package tree and collect all non-abstract subclasses of *base*. 

46 

47 Args: 

48 package: Dotted package path to scan. 

49 base: Base class to match against. 

50 

51 Returns: 

52 List of concrete subclasses found. 

53 """ 

54 results: list[type] = [] 

55 

56 try: 

57 root = importlib.import_module(package) 

58 except ImportError: 

59 logger.debug("discovery.package.import_failed", package=package) 

60 return results 

61 

62 root_path = getattr(root, "__path__", None) 

63 if root_path is None: 

64 results.extend(_collect_subclasses(root, base)) 

65 return results 

66 

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 ) 

79 

80 results.extend(_collect_subclasses(root, base)) 

81 return results 

82 

83 

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 

106 

107 

108__all__ = ["discover_controllers"]