Coverage for src/lexigram/admin/di/sub_providers/contributor.py: 90%

136 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-21 14:56 +0800

1"""Admin contributor sub-provider — discovers and manages admin contributors.""" 

2 

3from __future__ import annotations 

4 

5import importlib.metadata 

6from typing import TYPE_CHECKING, Any 

7 

8from lexigram.admin.contributors.core import CoreAdminContributor 

9from lexigram.admin.contributors.registry import ContributorRegistry 

10from lexigram.admin.contributors.resource_collector import ResourceCollector 

11from lexigram.admin.dashboard.naming_policy import NamingPolicy 

12from lexigram.contracts.admin.dependencies import sort_contributors 

13from lexigram.contracts.admin.protocols import AdminContributorRegistryProtocol 

14from lexigram.contracts.core.health import HealthCheckResult, HealthStatus 

15from lexigram.logging import get_logger 

16 

17if TYPE_CHECKING: 

18 from lexigram.admin.config import AdminConfig 

19 from lexigram.contracts.admin.protocols import AdminContributorProtocol 

20 from lexigram.contracts.core.di import ( 

21 ContainerRegistrarProtocol, 

22 ContainerResolverProtocol, 

23 ) 

24 

25logger = get_logger(__name__) 

26 

27ENTRY_POINT_GROUP = "lexigram.admin.contributors" 

28 

29 

30def _validate_widget_endpoints( 

31 contributors: list[Any], 

32 known_paths: set[str], 

33) -> None: 

34 """Log a warning for each widget whose render_endpoint has no matching route.""" 

35 for c in contributors: 

36 try: 

37 widgets = c.get_dashboard_widgets() 

38 except Exception: # noqa: BLE001 

39 logger.warning( 

40 "admin.widget_validation_get_widgets_failed", 

41 contributor=getattr(c, "name", repr(c)), 

42 exc_info=True, 

43 ) 

44 continue 

45 for widget in widgets: 

46 ep = getattr(widget, "render_endpoint", None) 

47 if ep and ep not in known_paths: 

48 logger.warning( 

49 "admin.widget_endpoint_not_registered", 

50 contributor=getattr(c, "name", repr(c)), 

51 widget=getattr(widget, "name", repr(widget)), 

52 render_endpoint=ep, 

53 ) 

54 

55 

56class AdminContributorSubProvider: 

57 """Discovers and manages admin contributors via entry points. 

58 

59 Discovery order: 

60 1. Built-in CoreAdminContributor 

61 2. Entry points from ``lexigram.admin.contributors`` group 

62 3. Boot all enabled contributors 

63 

64 When constructed with ``contributors``, the sub-provider operates in 

65 *direct mode*: ``boot_all()`` iterates those contributors, tracks any 

66 failures in ``_boot_failures``, and ``health_check()`` reflects that state. 

67 """ 

68 

69 def __init__( 

70 self, 

71 config: AdminConfig | None = None, 

72 contributors: list[AdminContributorProtocol] | None = None, 

73 ) -> None: 

74 self._config = config 

75 self._registry = ContributorRegistry() 

76 if config is not None: 

77 self._registry.register(CoreAdminContributor()) 

78 self._direct_contributors: list[AdminContributorProtocol] = [ 

79 c() if isinstance(c, type) else c for c in (contributors or []) 

80 ] 

81 self._boot_failures: dict[str, str] = {} 

82 self._entry_point_failures: dict[str, str] = {} 

83 self._collected_resources: list[type] = [] 

84 self._collected_routes: list[Any] = [] 

85 # Register direct contributors in the registry before entry-point 

86 # discovery so that entry points with the same name are skipped. 

87 for dc in self._direct_contributors: 

88 self._registry.register(dc) 

89 # Discover entry-point contributors here so that sub-providers 

90 # consuming the registry (e.g. AdminDashboardSubProvider) see 

91 # the full set during their own register() phase. 

92 self._discover_from_entry_points() 

93 

94 @property 

95 def config(self) -> AdminConfig | None: 

96 """Return current admin config.""" 

97 return self._config 

98 

99 @property 

100 def registry(self) -> ContributorRegistry: 

101 """Return the contributor registry.""" 

102 return self._registry 

103 

104 @property 

105 def collected_resources(self) -> list[type]: 

106 """Return Resource classes collected from all contributors.""" 

107 return list(self._collected_resources) 

108 

109 @property 

110 def collected_routes(self) -> list[Any]: 

111 """Return AdminRouteSpec instances collected from all contributors at boot.""" 

112 return list(self._collected_routes) 

113 

114 async def register(self, container: ContainerRegistrarProtocol) -> None: 

115 """Register the contributor registry in the DI container.""" 

116 container.singleton(AdminContributorRegistryProtocol, self._registry) 

117 container.singleton(ContributorRegistry, self._registry) 

118 

119 async def boot(self, container: ContainerResolverProtocol) -> None: 

120 """Boot all discovered contributors, sorted by dependency order.""" 

121 contributors = sort_contributors(self._registry.get_all()) # type: ignore[type-var] 

122 for contributor in contributors: 

123 if self._is_enabled(contributor.name): 

124 try: 

125 await contributor.on_admin_boot(container) 

126 except Exception as exc: # noqa: BLE001 — continue booting other contributors 

127 logger.warning( 

128 "admin.contributor_on_boot_failed", 

129 contributor=contributor.name, 

130 error=str(exc), 

131 exc_info=True, 

132 ) 

133 self._boot_failures[contributor.name] = str(exc) 

134 

135 async def boot_all(self) -> None: 

136 """Boot directly-supplied contributors, tracking failures. 

137 

138 Iterates every contributor passed to the constructor, calls 

139 ``await contributor.boot()``, and records the ``contributor_id`` 

140 of any contributor whose boot raises. Remaining contributors are 

141 always attempted — failures do not abort the loop. 

142 

143 After booting, collects Resource classes from all contributors 

144 via ``ResourceCollector`` and stores them in ``_collected_resources``. 

145 """ 

146 for contributor in self._direct_contributors: 

147 try: 

148 await contributor.on_admin_boot(None) # type: ignore[arg-type] 

149 except Exception as exc: # noqa: BLE001 — track failure and continue 

150 logger.error( 

151 "admin.contributor_boot_failed", 

152 contributor_id=contributor.contributor_id, 

153 error=str(exc), 

154 ) 

155 self._boot_failures[contributor.contributor_id] = str(exc) 

156 

157 # Collect resources from all contributors (both direct and entry-point) 

158 try: 

159 all_contributors: list[AdminContributorProtocol] = list( 

160 self._registry.get_all() 

161 ) 

162 all_contributors.extend(self._direct_contributors) 

163 

164 naming = NamingPolicy(mode="warn") 

165 collector = ResourceCollector(naming_policy=naming) 

166 self._collected_resources = collector.collect(all_contributors) 

167 except Exception: # noqa: BLE001 — resource collection is non-fatal 

168 logger.warning( 

169 "admin.contributor_resource_collection_failed", exc_info=True 

170 ) 

171 

172 # Collect routes from all contributors 

173 try: 

174 all_c: list[AdminContributorProtocol] = list(self._registry.get_all()) 

175 all_c.extend(self._direct_contributors) 

176 for c in all_c: 

177 try: 

178 for spec in c.get_routes(): 

179 self._collected_routes.append(spec) 

180 except Exception: # noqa: BLE001 — skip broken contributors 

181 logger.warning( 

182 "admin.contributor_get_routes_failed", 

183 contributor=getattr(c, "name", repr(c)), 

184 exc_info=True, 

185 ) 

186 except Exception: # noqa: BLE001 — route collection is non-fatal 

187 logger.warning("admin.contributor_route_collection_failed", exc_info=True) 

188 

189 # Validate widget render_endpoint against collected routes 

190 try: 

191 known_paths = {spec.path for spec in self._collected_routes} 

192 all_contributors_for_validation: list = list(self._registry.get_all()) 

193 all_contributors_for_validation.extend(self._direct_contributors) 

194 _validate_widget_endpoints(all_contributors_for_validation, known_paths) 

195 except Exception: # noqa: BLE001 — validation is non-fatal 

196 logger.warning("admin.widget_endpoint_validation_failed", exc_info=True) 

197 

198 async def shutdown(self) -> None: 

199 """Shut down all contributors in reverse priority order.""" 

200 for contributor in reversed(list(self._registry.get_all())): 

201 try: 

202 await contributor.on_admin_shutdown() 

203 except Exception: # noqa: BLE001 — continue shutting down other contributors 

204 logger.warning( 

205 "admin.contributor_shutdown_failed", 

206 contributor=contributor.name, 

207 exc_info=True, 

208 ) 

209 

210 def health_check(self, timeout: float = 5.0) -> HealthCheckResult: # noqa: ARG002 

211 """Return contributor health, reflecting any boot/entry-point failures. 

212 

213 Args: 

214 timeout: Accepted for interface compatibility; unused by this 

215 synchronous implementation. 

216 

217 Returns: 

218 ``DEGRADED`` with a failure summary when one or more contributors 

219 failed to boot; ``HEALTHY`` otherwise. 

220 """ 

221 details: dict[str, object] = {} 

222 if self._entry_point_failures: 

223 details["entry_point_failures"] = dict(self._entry_point_failures) 

224 if self._boot_failures: 

225 details["boot_failures"] = dict(self._boot_failures) 

226 if self._entry_point_failures or self._boot_failures: 

227 return HealthCheckResult( 

228 component="admin_contributors", 

229 status=HealthStatus.DEGRADED, 

230 message=f"Boot failures: {self._boot_failures}, entry point failures: {self._entry_point_failures}", 

231 details=details, 

232 ) 

233 count = len(list(self._registry.get_all())) + len(self._direct_contributors) 

234 return HealthCheckResult( 

235 component="admin_contributors", 

236 status=HealthStatus.HEALTHY, 

237 message=f"{count} contributor(s) registered", 

238 details={"count": count}, 

239 ) 

240 

241 def _discover_from_entry_points(self) -> None: 

242 """Load contributors from the entry point group. 

243 

244 Skips any contributor class that is already registered 

245 in the registry (i.e., was set up by a provider's factory). 

246 Only performs direct instantiation for zero-argument contributors 

247 that have no provider-based registration. 

248 """ 

249 for ep in importlib.metadata.entry_points(group=ENTRY_POINT_GROUP): 

250 try: 

251 contributor_cls = ep.load() 

252 contributor = contributor_cls() 

253 # Skip if this contributor is already registered by a provider. 

254 # The provider's factory already constructed and registered it. 

255 if self._registry.get(contributor.name) is not None: 

256 logger.debug( 

257 "admin.contributor_already_registered", 

258 name=contributor.name, 

259 ) 

260 continue 

261 if self._is_enabled(contributor.name): 

262 self._registry.register(contributor) 

263 logger.info("contributor_discovered", name=contributor.name) 

264 except Exception as exc: # noqa: BLE001 — skip bad entry points; continue discovery 

265 logger.warning( 

266 "admin.entry_point_load_failed", 

267 name=ep.name, 

268 error=str(exc), 

269 exc_info=True, 

270 ) 

271 self._entry_point_failures[ep.name] = str(exc) 

272 

273 def _is_enabled(self, name: str) -> bool: 

274 """Check if a contributor is enabled via config. 

275 

276 Always returns ``True`` when no config was supplied (direct mode). 

277 """ 

278 if self._config is None: 

279 return True 

280 contributors_config = getattr(self._config, "contributors", {}) 

281 if isinstance(contributors_config, dict) and name in contributors_config: 

282 contrib_cfg = contributors_config[name] 

283 if isinstance(contrib_cfg, dict): 

284 return contrib_cfg.get("enabled", True) 

285 return getattr(contrib_cfg, "enabled", True) 

286 return True 

287 

288 

289__all__ = ["AdminContributorSubProvider"]