Coverage for fastblocks/actions/gather/routes.py: 44%
189 statements
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-09 00:47 -0700
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-09 00:47 -0700
1"""Route gathering functionality to replace scattered route discovery."""
3import typing as t
4from contextlib import suppress
5from importlib import import_module
6from pathlib import Path
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
14from .strategies import GatherStrategy, gather_with_strategy
16RouteType = Route | Router | Mount | Host | WebSocketRoute
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 []
33 @property
34 def total_routes(self) -> int:
35 return len(self.routes)
37 @property
38 def has_errors(self) -> bool:
39 return len(self.errors) > 0
41 def extend_routes(self, additional_routes: list[RouteType]) -> None:
42 self.routes.extend(additional_routes)
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"]
56 if patterns is None:
57 patterns = ["_routes.py", "routes.py"]
59 if strategy is None:
60 strategy = GatherStrategy()
62 result = RouteGatherResult()
64 tasks: list[t.Coroutine[t.Any, t.Any, t.Any]] = []
66 if "adapters" in sources and include_adapters:
67 tasks.append(_gather_adapter_routes(patterns, strategy))
69 if "base_routes" in sources and include_base:
70 tasks.append(_gather_base_routes(patterns))
72 if "custom" in sources:
73 tasks.append(_gather_custom_routes(patterns, strategy))
75 gather_result = await gather_with_strategy(
76 tasks,
77 strategy,
78 cache_key=f"routes:{':'.join(sources)}:{':'.join(patterns)}",
79 )
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)
90 result.errors.extend(gather_result.errors)
92 debug(f"Gathered {result.total_routes} routes from {len(sources)} sources")
94 return result
97async def _gather_adapter_routes(
98 patterns: list[str],
99 strategy: GatherStrategy,
100) -> dict[str, list[RouteType]]:
101 adapter_routes: dict[str, list[RouteType]] = {}
103 for adapter in get_adapters():
104 await _process_adapter_routes(adapter, patterns, strategy, adapter_routes)
106 return adapter_routes
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 = []
119 for pattern in patterns:
120 routes_path = adapter_path / pattern
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
134 if routes:
135 adapter_routes[adapter_name] = routes
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}")
151 return base_routes
154async def _gather_custom_routes(
155 patterns: list[str],
156 strategy: GatherStrategy,
157) -> list[RouteType]:
158 custom_routes = []
160 custom_paths = [
161 root_path / "app" / "routes.py",
162 root_path / "custom" / "routes.py",
163 root_path / "src" / "routes.py",
164 ]
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}")
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
179 return custom_routes
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
193 return []
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")
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 []
212 return _validate_route_objects(module_routes)
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)}")
223 return valid_routes
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 }
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 )
243 path = getattr(route, "path", None)
244 if path is not None:
245 patterns["path_patterns"].append(path)
247 methods = getattr(route, "methods", None)
248 if methods is not None:
249 patterns["methods"].update(methods)
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)
256 patterns["methods"] = list(patterns["methods"])
257 patterns["endpoints"] = list(patterns["endpoints"])
259 return patterns
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 []
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[str] = set()
287 for route in routes:
288 _validate_single_route(route, validation, path_patterns)
290 return validation
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
306 _check_route_path_duplicates(route, validation, path_patterns)
307 _check_route_endpoint(route, validation)
308 validation["valid_routes"].append(route)
310 except Exception as e:
311 validation["invalid_routes"].append({"route": str(route), "error": str(e)})
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)
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")