Coverage for fastblocks / actions / gather / routes.py: 50%
188 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-26 03:58 -0800
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-26 03:58 -0800
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 anyio import Path as AsyncPath
11from starlette.routing import Host, Mount, Route, Router, WebSocketRoute
13from .strategies import GatherStrategy, gather_with_strategy
15RouteType = Route | Router | Mount | Host | WebSocketRoute
18class RouteGatherResult:
19 def __init__(
20 self,
21 *,
22 routes: list[RouteType] | None = None,
23 adapter_routes: dict[str, list[RouteType]] | None = None,
24 base_routes: list[RouteType] | None = None,
25 errors: list[Exception] | None = None,
26 ) -> None:
27 self.routes = routes if routes is not None else []
28 self.adapter_routes = adapter_routes if adapter_routes is not None else {}
29 self.base_routes = base_routes if base_routes is not None else []
30 self.errors = errors if errors is not None else []
32 @property
33 def total_routes(self) -> int:
34 return len(self.routes)
36 @property
37 def has_errors(self) -> bool:
38 return len(self.errors) > 0
40 def extend_routes(self, additional_routes: list[RouteType]) -> None:
41 self.routes.extend(additional_routes)
44async def gather_routes(
45 *,
46 sources: list[str] | None = None,
47 patterns: list[str] | None = None,
48 include_base: bool = True,
49 include_adapters: bool = True,
50 strategy: GatherStrategy | None = None,
51) -> RouteGatherResult:
52 if sources is None:
53 sources = ["adapters", "base_routes"]
55 if patterns is None:
56 patterns = ["_routes.py", "routes.py"]
58 if strategy is None:
59 strategy = GatherStrategy()
61 result = RouteGatherResult()
63 tasks: list[t.Coroutine[t.Any, t.Any, t.Any]] = []
65 if "adapters" in sources and include_adapters:
66 tasks.append(_gather_adapter_routes(patterns, strategy))
68 if "base_routes" in sources and include_base:
69 tasks.append(_gather_base_routes(patterns))
71 if "custom" in sources:
72 tasks.append(_gather_custom_routes(patterns, strategy))
74 gather_result = await gather_with_strategy(
75 tasks,
76 strategy,
77 cache_key=f"routes:{':'.join(sources)}:{':'.join(patterns)}",
78 )
80 for success in gather_result.success:
81 if isinstance(success, dict):
82 result.adapter_routes.update(success)
83 for routes in success.values():
84 result.routes.extend(routes)
85 elif isinstance(success, list):
86 result.base_routes.extend(success)
87 result.routes.extend(success)
89 result.errors.extend(gather_result.errors)
91 debug(f"Gathered {result.total_routes} routes from {len(sources)} sources")
93 return result
96async def _gather_adapter_routes(
97 patterns: list[str],
98 strategy: GatherStrategy,
99) -> dict[str, list[RouteType]]:
100 adapter_routes: dict[str, list[RouteType]] = {}
102 for adapter in get_adapters():
103 await _process_adapter_routes(adapter, patterns, strategy, adapter_routes)
105 return adapter_routes
108async def _process_adapter_routes(
109 adapter: t.Any,
110 patterns: list[str],
111 strategy: GatherStrategy,
112 adapter_routes: dict[str, list[RouteType]],
113) -> None:
114 adapter_name = adapter.name
115 adapter_path = adapter.path.parent
116 routes = []
118 for pattern in patterns:
119 routes_path = adapter_path / pattern
121 if await AsyncPath(routes_path).exists():
122 try:
123 found_routes = await _extract_routes_from_file(routes_path)
124 if found_routes:
125 routes.extend(found_routes)
126 debug(
127 f"Found {len(found_routes)} routes in {adapter_name}/{pattern}",
128 )
129 except Exception as e:
130 debug(f"Error gathering routes from {adapter_name}/{pattern}: {e}")
131 raise
133 if routes:
134 adapter_routes[adapter_name] = routes
137async def _gather_base_routes(patterns: list[str]) -> list[RouteType]:
138 base_routes = []
139 for pattern in patterns:
140 routes_path = root_path / pattern
141 if await AsyncPath(routes_path).exists():
142 try:
143 routes = await _extract_routes_from_file(Path(routes_path))
144 if routes:
145 base_routes.extend(routes)
146 debug(f"Found {len(routes)} base routes in {pattern}")
147 except Exception as e:
148 debug(f"Error gathering base routes from {pattern}: {e}")
150 return base_routes
153async def _gather_custom_routes(
154 patterns: list[str],
155 strategy: GatherStrategy,
156) -> list[RouteType]:
157 custom_routes = []
159 custom_paths = [
160 root_path / "app" / "routes.py",
161 root_path / "custom" / "routes.py",
162 root_path / "src" / "routes.py",
163 ]
165 for custom_path in custom_paths:
166 if await AsyncPath(custom_path).exists():
167 try:
168 routes = await _extract_routes_from_file(Path(custom_path))
169 if routes:
170 custom_routes.extend(routes)
171 debug(f"Found {len(routes)} custom routes in {custom_path}")
173 except Exception as e:
174 debug(f"Error gathering custom routes from {custom_path}: {e}")
175 if strategy.error_strategy.value == "fail_fast":
176 raise
178 return custom_routes
181async def _extract_routes_from_file(file_path: Path) -> list[RouteType]:
182 module_path = _get_module_path_from_file_path(file_path)
183 debug(f"Extracting routes from {file_path} -> {module_path}")
184 try:
185 with suppress(ModuleNotFoundError, ImportError):
186 module = import_module(module_path)
187 return _extract_routes_from_module(module, module_path)
188 except Exception as e:
189 debug(f"Error extracting routes from {file_path}: {e}")
190 raise
192 return []
195def _get_module_path_from_file_path(file_path: Path) -> str:
196 depth = -2
197 if "adapters" in file_path.parts:
198 depth = -4
199 return ".".join(file_path.parts[depth:]).removesuffix(".py")
202def _extract_routes_from_module(module: t.Any, module_path: str) -> list[RouteType]:
203 if not hasattr(module, "routes"):
204 debug(f"No routes attribute found in {module_path}")
205 return []
206 module_routes = module.routes
207 if not isinstance(module_routes, list):
208 debug(f"Routes attribute in {module_path} is not a list: {type(module_routes)}")
209 return []
211 return _validate_route_objects(module_routes)
214def _validate_route_objects(module_routes: list[t.Any]) -> list[RouteType]:
215 valid_routes = []
216 for route in module_routes:
217 if isinstance(route, Route | Router | Mount | Host | WebSocketRoute):
218 valid_routes.append(route)
219 else:
220 debug(f"Skipping invalid route object: {type(route)}")
222 return valid_routes
225async def gather_route_patterns(
226 route_objects: list[RouteType],
227) -> dict[str, t.Any]:
228 patterns: dict[str, t.Any] = {
229 "total_routes": len(route_objects),
230 "route_types": {},
231 "path_patterns": [],
232 "methods": set(),
233 "endpoints": set(),
234 }
236 for route in route_objects:
237 route_type = type(route).__name__
238 patterns["route_types"][route_type] = (
239 patterns["route_types"].get(route_type, 0) + 1
240 )
242 path = getattr(route, "path", None)
243 if path is not None:
244 patterns["path_patterns"].append(path)
246 methods = getattr(route, "methods", None)
247 if methods is not None:
248 patterns["methods"].update(methods)
250 endpoint = getattr(route, "endpoint", None)
251 if endpoint is not None:
252 endpoint_name = getattr(endpoint, "__name__", str(endpoint))
253 patterns["endpoints"].add(endpoint_name)
255 patterns["methods"] = list(patterns["methods"])
256 patterns["endpoints"] = list(patterns["endpoints"])
258 return patterns
261def create_default_routes() -> list[RouteType]:
262 try:
263 routes_module = __import__(
264 "fastblocks.adapters.routes.default",
265 fromlist=["Routes"],
266 )
267 Routes = routes_module.Routes
268 routes_instance = Routes()
269 return [
270 Route("/favicon.ico", endpoint=routes_instance.favicon, methods=["GET"]),
271 Route("/robots.txt", endpoint=routes_instance.robots, methods=["GET"]),
272 ]
273 except (ImportError, AttributeError) as e:
274 debug(f"Error loading default routes: {e}")
275 return []
278async def validate_routes(routes: list[RouteType]) -> dict[str, t.Any]:
279 validation: dict[str, t.Any] = {
280 "valid_routes": [],
281 "invalid_routes": [],
282 "warnings": [],
283 "total_checked": len(routes),
284 }
285 path_patterns: set[str] = set()
286 for route in routes:
287 _validate_single_route(route, validation, path_patterns)
289 return validation
292def _validate_single_route(
293 route: RouteType,
294 validation: dict[str, t.Any],
295 path_patterns: set[str],
296) -> None:
297 try:
298 path = getattr(route, "path", None)
299 if path is None:
300 validation["invalid_routes"].append(
301 {"route": str(route), "error": "Missing path attribute"},
302 )
303 return
305 _check_route_path_duplicates(route, validation, path_patterns)
306 _check_route_endpoint(route, validation)
307 validation["valid_routes"].append(route)
309 except Exception as e:
310 validation["invalid_routes"].append({"route": str(route), "error": str(e)})
313def _check_route_path_duplicates(
314 route: RouteType,
315 validation: dict[str, t.Any],
316 path_patterns: set[str],
317) -> None:
318 path = getattr(route, "path", None)
319 if path is not None:
320 if path in path_patterns:
321 validation["warnings"].append(f"Duplicate path: {path}")
322 path_patterns.add(path)
325def _check_route_endpoint(route: RouteType, validation: dict[str, t.Any]) -> None:
326 endpoint = getattr(route, "endpoint", None)
327 path = getattr(route, "path", "unknown")
328 if hasattr(route, "endpoint") and endpoint is None:
329 validation["warnings"].append(f"Route {path} has no endpoint")