Coverage for fastblocks/adapters/routes/default.py: 0%

134 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2025-10-09 03:52 -0700

1"""Default Routes Adapter for FastBlocks. 

2 

3Provides dynamic route discovery and registration for FastBlocks applications. 

4Includes automatic route gathering from adapters, static file serving, and HTMX endpoint support. 

5 

6Features: 

7- Dynamic route discovery from adapter modules 

8- HTMX-aware endpoints with template fragment rendering 

9- Built-in static routes (favicon, robots.txt) 

10- Automatic static file serving for storage adapters 

11- Template block rendering endpoints 

12- Route gathering from base routes.py files 

13- Integration with FastBlocks template system 

14 

15Requirements: 

16- starlette>=0.47.1 

17- jinja2>=3.1.6 

18 

19Usage: 

20```python 

21import typing as t 

22 

23from acb.depends import Inject, depends 

24from acb.adapters import import_adapter 

25 

26routes = depends.get("routes") 

27 

28Routes = import_adapter("routes") 

29 

30app_routes = routes.routes 

31``` 

32 

33Author: lesleslie <les@wedgwoodwebworks.com> 

34Created: 2025-01-12 

35""" 

36 

37from contextlib import suppress 

38from importlib import import_module 

39from uuid import UUID 

40 

41from acb.adapters import ( 

42 AdapterStatus, 

43 get_adapters, 

44 get_installed_adapter, 

45 import_adapter, 

46 root_path, 

47) 

48from acb.config import Config 

49from acb.debug import debug 

50from acb.depends import Inject, depends 

51from anyio import Path as AsyncPath 

52from jinja2.exceptions import TemplateNotFound 

53from starlette.endpoints import HTTPEndpoint 

54from starlette.exceptions import HTTPException 

55from starlette.requests import Request 

56from starlette.responses import PlainTextResponse, Response 

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

58from starlette.types import Receive, Scope, Send 

59from fastblocks.actions.query import create_query_context 

60from fastblocks.htmx import HtmxRequest 

61 

62from ._base import RoutesBase, RoutesBaseSettings 

63 

64try: 

65 Templates = import_adapter("templates") 

66except Exception: 

67 Templates = None 

68 

69base_routes_path = root_path / "routes.py" 

70 

71 

72class RoutesSettings(RoutesBaseSettings): ... 

73 

74 

75class FastBlocksEndpoint(HTTPEndpoint): 

76 @depends.inject # type: ignore[misc] 

77 def __init__( 

78 self, 

79 scope: Scope, 

80 receive: Receive, 

81 send: Send, 

82 config: Inject[Config] | None = None, 

83 ) -> None: 

84 super().__init__(scope, receive, send) 

85 self.config = config or depends.get(Config) 

86 self.templates = depends.get("templates") 

87 

88 

89class Index(FastBlocksEndpoint): 

90 async def get(self, request: HtmxRequest | Request) -> Response: 

91 debug(request) 

92 path_params = getattr(request, "path_params", {}) 

93 page = path_params.get("page") or "home" 

94 template = "index.html" 

95 headers = {"vary": "hx-request"} 

96 scope = getattr(request, "scope", {}) 

97 if htmx := scope.get("htmx"): 

98 debug(htmx) 

99 template = f"{page.lstrip('/')}.html" 

100 headers["hx-push-url"] = "/" if page == "home" else page 

101 debug(page, template) 

102 context = create_query_context(request, base_context={"page": page.lstrip("/")}) 

103 query_params = getattr(request, "query_params", {}) 

104 if "model" in query_params: 

105 model_name = query_params["model"] 

106 if f"{model_name}_parser" in context: 

107 parser = context[f"{model_name}_parser"] 

108 context[f"{model_name}_list"] = await parser.parse_and_execute() 

109 context[f"{model_name}_count"] = await parser.get_count() 

110 try: 

111 result = await self.templates.render_template( 

112 request, 

113 template, 

114 headers=headers, 

115 context=context, 

116 ) 

117 return result # type: ignore[no-any-return] 

118 except TemplateNotFound: 

119 raise HTTPException(status_code=404) 

120 

121 

122class Block(FastBlocksEndpoint): 

123 async def get(self, request: HtmxRequest | Request) -> Response: 

124 debug(request) 

125 path_params = getattr(request, "path_params", {}) 

126 block = f"blocks/{path_params.get('block', 'default')}.html" 

127 context = create_query_context(request) 

128 query_params = getattr(request, "query_params", {}) 

129 if "model" in query_params: 

130 model_name = query_params["model"] 

131 if f"{model_name}_parser" in context: 

132 parser = context[f"{model_name}_parser"] 

133 context[f"{model_name}_list"] = await parser.parse_and_execute() 

134 context[f"{model_name}_count"] = await parser.get_count() 

135 try: 

136 result = await self.templates.render_template( 

137 request, block, context=context 

138 ) 

139 return result # type: ignore[no-any-return] 

140 except TemplateNotFound: 

141 raise HTTPException(status_code=404) 

142 

143 

144class Component(FastBlocksEndpoint): 

145 async def get(self, request: HtmxRequest | Request) -> Response: 

146 debug(request) 

147 component_name = getattr(request, "path_params", {}).get("component", "default") 

148 query_params = getattr(request, "query_params", {}) 

149 context = create_query_context(request, base_context=dict(query_params)) 

150 if "model" in query_params: 

151 model_name = query_params["model"] 

152 if f"{model_name}_parser" in context: 

153 parser = context[f"{model_name}_parser"] 

154 context[f"{model_name}_list"] = await parser.parse_and_execute() 

155 context[f"{model_name}_count"] = await parser.get_count() 

156 try: 

157 htmy = depends.get("htmy") 

158 if htmy is None: 

159 raise HTTPException( 

160 status_code=500, detail="HTMY adapter not available" 

161 ) 

162 result = await htmy.render_component( 

163 request, component_name, context=context 

164 ) 

165 return result # type: ignore[no-any-return] 

166 except Exception as e: 

167 debug(f"Component '{component_name}' not found: {e}") 

168 raise HTTPException(status_code=404) 

169 

170 

171class Routes(RoutesBase): 

172 routes: list[Route | Router | Mount | Host | WebSocketRoute] = [] 

173 

174 async def gather_routes(self, path: AsyncPath) -> None: 

175 depth = -2 

176 if "adapters" in path.parts: 

177 depth = -4 

178 module_path = ".".join(path.parts[depth:]).removesuffix(".py") 

179 debug(path, depth, module_path) 

180 with suppress(ModuleNotFoundError): 

181 module = import_module(module_path) 

182 module_routes = getattr(module, "routes", None) 

183 if module_routes and isinstance(module_routes, list): 

184 self.routes = module.routes + self.routes 

185 

186 @staticmethod 

187 async def favicon(request: Request) -> Response: 

188 return PlainTextResponse("", 200) 

189 

190 @staticmethod 

191 async def robots(request: Request) -> Response: 

192 txt = "User-agent: *\nDisallow: /dashboard/\nDisallow: /blocks/" 

193 return PlainTextResponse(txt, 200) 

194 

195 async def init(self) -> None: 

196 self.routes.extend( 

197 [ 

198 Route("/favicon.ico", endpoint=self.favicon, methods=["GET"]), 

199 Route("/robots.txt", endpoint=self.robots, methods=["GET"]), 

200 Route("/", Index, methods=["GET"]), 

201 Route("/{page}", Index, methods=["GET"]), 

202 Route("/block/{block}", Block, methods=["GET"]), 

203 Route("/component/{component}", Component, methods=["GET"]), 

204 ], 

205 ) 

206 for adapter in get_adapters(): 

207 routes_path = adapter.path.parent / "_routes.py" 

208 if await routes_path.exists(): 

209 await self.gather_routes(routes_path) 

210 if await base_routes_path.exists(): 

211 await self.gather_routes(base_routes_path) 

212 if get_installed_adapter("storage") in ("file", "memory"): 

213 from starlette.staticfiles import StaticFiles 

214 

215 self.routes.append( 

216 Mount( 

217 "/media", 

218 app=StaticFiles(directory=self.config.storage.local_path / "media"), 

219 name="media", 

220 ), 

221 ) 

222 if not self.config.deployed: 

223 from starlette.staticfiles import StaticFiles 

224 

225 self.routes.append( 

226 Mount( 

227 "/static", 

228 app=StaticFiles( 

229 directory=self.config.storage.local_path / "static" 

230 ), 

231 name="media", 

232 ), 

233 ) 

234 debug(self.routes) 

235 

236 

237MODULE_ID = UUID("01937d86-6f4c-7d5e-a01f-3456789012cd") 

238MODULE_STATUS = AdapterStatus.STABLE 

239 

240with suppress(Exception): 

241 depends.set(Routes)