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

133 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-09-21 04:50 -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 

21from acb.depends import depends 

22from acb.adapters import import_adapter 

23 

24routes = depends.get("routes") 

25 

26Routes = import_adapter("routes") 

27 

28app_routes = routes.routes 

29``` 

30 

31Author: lesleslie <les@wedgwoodwebworks.com> 

32Created: 2025-01-12 

33""" 

34 

35from contextlib import suppress 

36from importlib import import_module 

37from uuid import UUID 

38 

39from acb.adapters import ( 

40 AdapterStatus, 

41 get_adapters, 

42 get_installed_adapter, 

43 import_adapter, 

44 root_path, 

45) 

46from acb.config import Config 

47from acb.debug import debug 

48from acb.depends import depends 

49from anyio import Path as AsyncPath 

50from jinja2.exceptions import TemplateNotFound 

51from starlette.endpoints import HTTPEndpoint 

52from starlette.exceptions import HTTPException 

53from starlette.requests import Request 

54from starlette.responses import PlainTextResponse, Response 

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

56from starlette.types import Receive, Scope, Send 

57from fastblocks.actions.query import create_query_context 

58from fastblocks.htmx import HtmxRequest 

59 

60from ._base import RoutesBase, RoutesBaseSettings 

61 

62try: 

63 Templates = import_adapter("templates") 

64except Exception: 

65 Templates = None 

66 

67base_routes_path = root_path / "routes.py" 

68 

69 

70class RoutesSettings(RoutesBaseSettings): ... 

71 

72 

73class FastBlocksEndpoint(HTTPEndpoint): 

74 config: Config = depends() 

75 

76 def __init__(self, scope: Scope, receive: Receive, send: Send) -> None: 

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

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

79 

80 

81class Index(FastBlocksEndpoint): 

82 @depends.inject 

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

84 debug(request) 

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

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

87 template = "index.html" 

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

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

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

91 debug(htmx) 

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

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

94 debug(page, template) 

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

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

97 if "model" in query_params: 

98 model_name = query_params["model"] 

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

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

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

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

103 try: 

104 return await self.templates.render_template( 

105 request, 

106 template, 

107 headers=headers, 

108 context=context, 

109 ) 

110 except TemplateNotFound: 

111 raise HTTPException(status_code=404) 

112 

113 

114class Block(FastBlocksEndpoint): 

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

116 debug(request) 

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

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

119 context = create_query_context(request) 

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

121 if "model" in query_params: 

122 model_name = query_params["model"] 

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

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

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

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

127 try: 

128 return await self.templates.render_template(request, block, context=context) 

129 except TemplateNotFound: 

130 raise HTTPException(status_code=404) 

131 

132 

133class Component(FastBlocksEndpoint): 

134 @depends.inject 

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

136 debug(request) 

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

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

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

140 if "model" in query_params: 

141 model_name = query_params["model"] 

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

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

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

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

146 try: 

147 htmy = depends.get("htmy") 

148 if htmy is None: 

149 raise HTTPException( 

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

151 ) 

152 return await htmy.render_component(request, component_name, context=context) 

153 except Exception as e: 

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

155 raise HTTPException(status_code=404) 

156 

157 

158class Routes(RoutesBase): 

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

160 

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

162 depth = -2 

163 if "adapters" in path.parts: 

164 depth = -4 

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

166 debug(path, depth, module_path) 

167 with suppress(ModuleNotFoundError): 

168 module = import_module(module_path) 

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

170 if module_routes and isinstance(module_routes, list): 

171 self.routes = module.routes + self.routes 

172 

173 @staticmethod 

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

175 return PlainTextResponse("", 200) 

176 

177 @staticmethod 

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

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

180 return PlainTextResponse(txt, 200) 

181 

182 @depends.inject 

183 async def init(self) -> None: 

184 self.routes.extend( 

185 [ 

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

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

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

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

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

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

192 ], 

193 ) 

194 for adapter in get_adapters(): 

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

196 if await routes_path.exists(): 

197 await self.gather_routes(routes_path) 

198 if await base_routes_path.exists(): 

199 await self.gather_routes(base_routes_path) 

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

201 from starlette.staticfiles import StaticFiles 

202 

203 self.routes.append( 

204 Mount( 

205 "/media", 

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

207 name="media", 

208 ), 

209 ) 

210 if not self.config.deployed: 

211 from starlette.staticfiles import StaticFiles 

212 

213 self.routes.append( 

214 Mount( 

215 "/static", 

216 app=StaticFiles( 

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

218 ), 

219 name="media", 

220 ), 

221 ) 

222 debug(self.routes) 

223 

224 

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

226MODULE_STATUS = AdapterStatus.STABLE 

227 

228with suppress(Exception): 

229 depends.set(Routes)