Coverage for fastblocks/actions/gather/routes.py: 38%

189 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-09-21 04:50 -0700

1"""Route gathering functionality to replace scattered route discovery.""" 

2 

3import typing as t 

4from contextlib import suppress 

5from importlib import import_module 

6from pathlib import Path 

7 

8from acb.adapters import get_adapters, root_path 

9from acb.debug import debug 

10from acb.depends import depends 

11from anyio import Path as AsyncPath 

12from starlette.routing import Host, Mount, Route, Router, WebSocketRoute 

13 

14from .strategies import GatherStrategy, gather_with_strategy 

15 

16RouteType = Route | Router | Mount | Host | WebSocketRoute 

17 

18 

19class RouteGatherResult: 

20 def __init__( 

21 self, 

22 *, 

23 routes: list[RouteType] | None = None, 

24 adapter_routes: dict[str, list[RouteType]] | None = None, 

25 base_routes: list[RouteType] | None = None, 

26 errors: list[Exception] | None = None, 

27 ) -> None: 

28 self.routes = routes if routes is not None else [] 

29 self.adapter_routes = adapter_routes if adapter_routes is not None else {} 

30 self.base_routes = base_routes if base_routes is not None else [] 

31 self.errors = errors if errors is not None else [] 

32 

33 @property 

34 def total_routes(self) -> int: 

35 return len(self.routes) 

36 

37 @property 

38 def has_errors(self) -> bool: 

39 return len(self.errors) > 0 

40 

41 def extend_routes(self, additional_routes: list[RouteType]) -> None: 

42 self.routes.extend(additional_routes) 

43 

44 

45async def gather_routes( 

46 *, 

47 sources: list[str] | None = None, 

48 patterns: list[str] | None = None, 

49 include_base: bool = True, 

50 include_adapters: bool = True, 

51 strategy: GatherStrategy | None = None, 

52) -> RouteGatherResult: 

53 if sources is None: 

54 sources = ["adapters", "base_routes"] 

55 

56 if patterns is None: 

57 patterns = ["_routes.py", "routes.py"] 

58 

59 if strategy is None: 

60 strategy = GatherStrategy() 

61 

62 result = RouteGatherResult() 

63 

64 tasks = [] 

65 

66 if "adapters" in sources and include_adapters: 

67 tasks.append(_gather_adapter_routes(patterns, strategy)) 

68 

69 if "base_routes" in sources and include_base: 

70 tasks.append(_gather_base_routes(patterns)) 

71 

72 if "custom" in sources: 

73 tasks.append(_gather_custom_routes(patterns, strategy)) 

74 

75 gather_result = await gather_with_strategy( 

76 tasks, 

77 strategy, 

78 cache_key=f"routes:{':'.join(sources)}:{':'.join(patterns)}", 

79 ) 

80 

81 for success in gather_result.success: 

82 if isinstance(success, dict): 

83 result.adapter_routes.update(success) 

84 for routes in success.values(): 

85 result.routes.extend(routes) 

86 elif isinstance(success, list): 

87 result.base_routes.extend(success) 

88 result.routes.extend(success) 

89 

90 result.errors.extend(gather_result.errors) 

91 

92 debug(f"Gathered {result.total_routes} routes from {len(sources)} sources") 

93 

94 return result 

95 

96 

97async def _gather_adapter_routes( 

98 patterns: list[str], 

99 strategy: GatherStrategy, 

100) -> dict[str, list[RouteType]]: 

101 adapter_routes = {} 

102 

103 for adapter in get_adapters(): 

104 await _process_adapter_routes(adapter, patterns, strategy, adapter_routes) 

105 

106 return adapter_routes 

107 

108 

109async def _process_adapter_routes( 

110 adapter: t.Any, 

111 patterns: list[str], 

112 strategy: GatherStrategy, 

113 adapter_routes: dict[str, list[RouteType]], 

114) -> None: 

115 adapter_name = adapter.name 

116 adapter_path = adapter.path.parent 

117 routes = [] 

118 

119 for pattern in patterns: 

120 routes_path = adapter_path / pattern 

121 

122 if await AsyncPath(routes_path).exists(): 

123 try: 

124 found_routes = await _extract_routes_from_file(routes_path) 

125 if found_routes: 

126 routes.extend(found_routes) 

127 debug( 

128 f"Found {len(found_routes)} routes in {adapter_name}/{pattern}", 

129 ) 

130 except Exception as e: 

131 debug(f"Error gathering routes from {adapter_name}/{pattern}: {e}") 

132 raise 

133 

134 if routes: 

135 adapter_routes[adapter_name] = routes 

136 

137 

138async def _gather_base_routes(patterns: list[str]) -> list[RouteType]: 

139 base_routes = [] 

140 for pattern in patterns: 

141 routes_path = root_path / pattern 

142 if await AsyncPath(routes_path).exists(): 

143 try: 

144 routes = await _extract_routes_from_file(Path(routes_path)) 

145 if routes: 

146 base_routes.extend(routes) 

147 debug(f"Found {len(routes)} base routes in {pattern}") 

148 except Exception as e: 

149 debug(f"Error gathering base routes from {pattern}: {e}") 

150 

151 return base_routes 

152 

153 

154async def _gather_custom_routes( 

155 patterns: list[str], 

156 strategy: GatherStrategy, 

157) -> list[RouteType]: 

158 custom_routes = [] 

159 

160 custom_paths = [ 

161 root_path / "app" / "routes.py", 

162 root_path / "custom" / "routes.py", 

163 root_path / "src" / "routes.py", 

164 ] 

165 

166 for custom_path in custom_paths: 

167 if await AsyncPath(custom_path).exists(): 

168 try: 

169 routes = await _extract_routes_from_file(Path(custom_path)) 

170 if routes: 

171 custom_routes.extend(routes) 

172 debug(f"Found {len(routes)} custom routes in {custom_path}") 

173 

174 except Exception as e: 

175 debug(f"Error gathering custom routes from {custom_path}: {e}") 

176 if strategy.error_strategy.value == "fail_fast": 

177 raise 

178 

179 return custom_routes 

180 

181 

182async def _extract_routes_from_file(file_path: Path) -> list[RouteType]: 

183 module_path = _get_module_path_from_file_path(file_path) 

184 debug(f"Extracting routes from {file_path} -> {module_path}") 

185 try: 

186 with suppress(ModuleNotFoundError, ImportError): 

187 module = import_module(module_path) 

188 return _extract_routes_from_module(module, module_path) 

189 except Exception as e: 

190 debug(f"Error extracting routes from {file_path}: {e}") 

191 raise 

192 

193 return [] 

194 

195 

196def _get_module_path_from_file_path(file_path: Path) -> str: 

197 depth = -2 

198 if "adapters" in file_path.parts: 

199 depth = -4 

200 return ".".join(file_path.parts[depth:]).removesuffix(".py") 

201 

202 

203def _extract_routes_from_module(module: t.Any, module_path: str) -> list[RouteType]: 

204 if not hasattr(module, "routes"): 

205 debug(f"No routes attribute found in {module_path}") 

206 return [] 

207 module_routes = module.routes 

208 if not isinstance(module_routes, list): 

209 debug(f"Routes attribute in {module_path} is not a list: {type(module_routes)}") 

210 return [] 

211 

212 return _validate_route_objects(module_routes) 

213 

214 

215def _validate_route_objects(module_routes: list[t.Any]) -> list[RouteType]: 

216 valid_routes = [] 

217 for route in module_routes: 

218 if isinstance(route, Route | Router | Mount | Host | WebSocketRoute): 

219 valid_routes.append(route) 

220 else: 

221 debug(f"Skipping invalid route object: {type(route)}") 

222 

223 return valid_routes 

224 

225 

226async def gather_route_patterns( 

227 route_objects: list[RouteType], 

228) -> dict[str, t.Any]: 

229 patterns: dict[str, t.Any] = { 

230 "total_routes": len(route_objects), 

231 "route_types": {}, 

232 "path_patterns": [], 

233 "methods": set(), 

234 "endpoints": set(), 

235 } 

236 

237 for route in route_objects: 

238 route_type = type(route).__name__ 

239 patterns["route_types"][route_type] = ( 

240 patterns["route_types"].get(route_type, 0) + 1 

241 ) 

242 

243 path = getattr(route, "path", None) 

244 if path is not None: 

245 patterns["path_patterns"].append(path) 

246 

247 methods = getattr(route, "methods", None) 

248 if methods is not None: 

249 patterns["methods"].update(methods) 

250 

251 endpoint = getattr(route, "endpoint", None) 

252 if endpoint is not None: 

253 endpoint_name = getattr(endpoint, "__name__", str(endpoint)) 

254 patterns["endpoints"].add(endpoint_name) 

255 

256 patterns["methods"] = list(patterns["methods"]) 

257 patterns["endpoints"] = list(patterns["endpoints"]) 

258 

259 return patterns 

260 

261 

262def create_default_routes() -> list[RouteType]: 

263 try: 

264 routes_module = __import__( 

265 "fastblocks.adapters.routes.default", 

266 fromlist=["Routes"], 

267 ) 

268 Routes = routes_module.Routes 

269 routes_instance = depends.get("routes") or Routes() 

270 return [ 

271 Route("/favicon.ico", endpoint=routes_instance.favicon, methods=["GET"]), 

272 Route("/robots.txt", endpoint=routes_instance.robots, methods=["GET"]), 

273 ] 

274 except (ImportError, AttributeError) as e: 

275 debug(f"Error loading default routes: {e}") 

276 return [] 

277 

278 

279async def validate_routes(routes: list[RouteType]) -> dict[str, t.Any]: 

280 validation: dict[str, t.Any] = { 

281 "valid_routes": [], 

282 "invalid_routes": [], 

283 "warnings": [], 

284 "total_checked": len(routes), 

285 } 

286 path_patterns = set() 

287 for route in routes: 

288 _validate_single_route(route, validation, path_patterns) 

289 

290 return validation 

291 

292 

293def _validate_single_route( 

294 route: RouteType, 

295 validation: dict[str, t.Any], 

296 path_patterns: set[str], 

297) -> None: 

298 try: 

299 path = getattr(route, "path", None) 

300 if path is None: 

301 validation["invalid_routes"].append( 

302 {"route": str(route), "error": "Missing path attribute"}, 

303 ) 

304 return 

305 

306 _check_route_path_duplicates(route, validation, path_patterns) 

307 _check_route_endpoint(route, validation) 

308 validation["valid_routes"].append(route) 

309 

310 except Exception as e: 

311 validation["invalid_routes"].append({"route": str(route), "error": str(e)}) 

312 

313 

314def _check_route_path_duplicates( 

315 route: RouteType, 

316 validation: dict[str, t.Any], 

317 path_patterns: set[str], 

318) -> None: 

319 path = getattr(route, "path", None) 

320 if path is not None: 

321 if path in path_patterns: 

322 validation["warnings"].append(f"Duplicate path: {path}") 

323 path_patterns.add(path) 

324 

325 

326def _check_route_endpoint(route: RouteType, validation: dict[str, t.Any]) -> None: 

327 endpoint = getattr(route, "endpoint", None) 

328 path = getattr(route, "path", "unknown") 

329 if hasattr(route, "endpoint") and endpoint is None: 

330 validation["warnings"].append(f"Route {path} has no endpoint")