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
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-09 03:52 -0700
1"""Default Routes Adapter for FastBlocks.
3Provides dynamic route discovery and registration for FastBlocks applications.
4Includes automatic route gathering from adapters, static file serving, and HTMX endpoint support.
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
15Requirements:
16- starlette>=0.47.1
17- jinja2>=3.1.6
19Usage:
20```python
21import typing as t
23from acb.depends import Inject, depends
24from acb.adapters import import_adapter
26routes = depends.get("routes")
28Routes = import_adapter("routes")
30app_routes = routes.routes
31```
33Author: lesleslie <les@wedgwoodwebworks.com>
34Created: 2025-01-12
35"""
37from contextlib import suppress
38from importlib import import_module
39from uuid import UUID
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
62from ._base import RoutesBase, RoutesBaseSettings
64try:
65 Templates = import_adapter("templates")
66except Exception:
67 Templates = None
69base_routes_path = root_path / "routes.py"
72class RoutesSettings(RoutesBaseSettings): ...
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")
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)
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)
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)
171class Routes(RoutesBase):
172 routes: list[Route | Router | Mount | Host | WebSocketRoute] = []
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
186 @staticmethod
187 async def favicon(request: Request) -> Response:
188 return PlainTextResponse("", 200)
190 @staticmethod
191 async def robots(request: Request) -> Response:
192 txt = "User-agent: *\nDisallow: /dashboard/\nDisallow: /blocks/"
193 return PlainTextResponse(txt, 200)
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
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
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)
237MODULE_ID = UUID("01937d86-6f4c-7d5e-a01f-3456789012cd")
238MODULE_STATUS = AdapterStatus.STABLE
240with suppress(Exception):
241 depends.set(Routes)