Coverage for fastblocks / adapters / routes / default.py: 4%
135 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"""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"""
37import typing as t
38from contextlib import suppress
39from importlib import import_module
40from uuid import UUID
42try:
43 from acb.adapters import (
44 AdapterStatus,
45 get_adapters,
46 get_installed_adapter,
47 import_adapter,
48 root_path,
49 )
50except ImportError: # acb >= 0.19 removed get_installed_adapter
51 from acb.adapters import (
52 AdapterStatus,
53 get_adapter,
54 get_adapters,
55 get_installed_adapters,
56 import_adapter,
57 root_path,
58 )
60 def get_installed_adapter(adapter_name: str) -> str | None:
61 """Compatibility shim that resolves an installed adapter name."""
62 for adapter in get_installed_adapters():
63 meta = getattr(adapter, "metadata", None)
64 provider = getattr(meta, "provider", None)
65 if adapter_name in (adapter.category, adapter.name, provider):
66 return provider or adapter.name
67 adapter = get_adapter(adapter_name)
68 if adapter:
69 meta = getattr(adapter, "metadata", None)
70 provider = getattr(meta, "provider", None)
71 return provider or adapter.name
72 return None
75from acb.config import Config
76from acb.debug import debug
77from acb.depends import depends
78from anyio import Path as AsyncPath
79from jinja2.exceptions import TemplateNotFound
80from starlette.endpoints import HTTPEndpoint
81from starlette.exceptions import HTTPException
82from starlette.requests import Request
83from starlette.responses import PlainTextResponse, Response
84from starlette.routing import Host, Mount, Route, Router, WebSocketRoute
85from starlette.types import Receive, Scope, Send
86from fastblocks.actions.query import create_query_context
87from fastblocks.htmx import HtmxRequest
89from ._base import RoutesBase, RoutesBaseSettings
91try:
92 Templates = import_adapter("templates")
93except Exception:
94 Templates = None
96base_routes_path = root_path / "routes.py"
99class RoutesSettings(RoutesBaseSettings): ...
102class FastBlocksEndpoint(HTTPEndpoint):
103 def __init__(
104 self,
105 scope: Scope,
106 receive: Receive,
107 send: Send,
108 config: Config | None = None,
109 ) -> None:
110 super().__init__(scope, receive, send)
111 self.config = config or depends.get_sync(Config)
112 self.templates = depends.get_sync("templates")
115class Index(FastBlocksEndpoint):
116 async def get(self, request: HtmxRequest | Request) -> Response:
117 debug(request)
118 path_params = getattr(request, "path_params", {})
119 page = path_params.get("page") or "home"
120 template = "index.html"
121 headers = {"vary": "hx-request"}
122 scope = getattr(request, "scope", {})
123 if htmx := scope.get("htmx"):
124 debug(htmx)
125 template = f"{page.lstrip('/')}.html"
126 headers["hx-push-url"] = "/" if page == "home" else page
127 debug(page, template)
128 context = await create_query_context(
129 request, base_context={"page": page.lstrip("/")}
130 )
131 query_params = getattr(request, "query_params", {})
132 if "model" in query_params:
133 model_name = query_params["model"]
134 if f"{model_name}_parser" in context:
135 parser = context[f"{model_name}_parser"]
136 context[f"{model_name}_list"] = await parser.parse_and_execute()
137 context[f"{model_name}_count"] = await parser.get_count()
138 try:
139 result = await self.templates.render_template(
140 request,
141 template,
142 headers=headers,
143 context=context,
144 )
145 return t.cast(Response, result)
146 except TemplateNotFound:
147 raise HTTPException(status_code=404)
150class Block(FastBlocksEndpoint):
151 async def get(self, request: HtmxRequest | Request) -> Response:
152 debug(request)
153 path_params = getattr(request, "path_params", {})
154 block = f"blocks/{path_params.get('block', 'default')}.html"
155 context = await create_query_context(request)
156 query_params = getattr(request, "query_params", {})
157 if "model" in query_params:
158 model_name = query_params["model"]
159 if f"{model_name}_parser" in context:
160 parser = context[f"{model_name}_parser"]
161 context[f"{model_name}_list"] = await parser.parse_and_execute()
162 context[f"{model_name}_count"] = await parser.get_count()
163 try:
164 result = await self.templates.render_template(
165 request, block, context=context
166 )
167 return t.cast(Response, result)
168 except TemplateNotFound:
169 raise HTTPException(status_code=404)
172class Component(FastBlocksEndpoint):
173 async def get(self, request: HtmxRequest | Request) -> Response:
174 debug(request)
175 component_name = getattr(request, "path_params", {}).get("component", "default")
176 query_params = getattr(request, "query_params", {})
177 context = await create_query_context(request, base_context=dict(query_params))
178 if "model" in query_params:
179 model_name = query_params["model"]
180 if f"{model_name}_parser" in context:
181 parser = context[f"{model_name}_parser"]
182 context[f"{model_name}_list"] = await parser.parse_and_execute()
183 context[f"{model_name}_count"] = await parser.get_count()
184 try:
185 htmy = await depends.get("htmy")
186 if htmy is None:
187 raise HTTPException(
188 status_code=500, detail="HTMY adapter not available"
189 )
190 result = await htmy.render_component(
191 request, component_name, context=context
192 )
193 return t.cast(Response, result)
194 except Exception as e:
195 debug(f"Component '{component_name}' not found: {e}")
196 raise HTTPException(status_code=404)
199class Routes(RoutesBase):
200 routes: list[Route | Router | Mount | Host | WebSocketRoute] = []
202 async def gather_routes(self, path: AsyncPath) -> None:
203 depth = -2
204 if "adapters" in path.parts:
205 depth = -4
206 module_path = ".".join(path.parts[depth:]).removesuffix(".py")
207 debug(path, depth, module_path)
208 with suppress(ModuleNotFoundError):
209 module = import_module(module_path)
210 module_routes = getattr(module, "routes", None)
211 if module_routes and isinstance(module_routes, list):
212 self.routes = module.routes + self.routes
214 @staticmethod
215 async def favicon(request: Request) -> Response:
216 return PlainTextResponse("", 200)
218 @staticmethod
219 async def robots(request: Request) -> Response:
220 txt = "User-agent: *\nDisallow: /dashboard/\nDisallow: /blocks/"
221 return PlainTextResponse(txt, 200)
223 async def init(self) -> None:
224 self.routes.extend(
225 [
226 Route("/favicon.ico", endpoint=self.favicon, methods=["GET"]),
227 Route("/robots.txt", endpoint=self.robots, methods=["GET"]),
228 Route("/", Index, methods=["GET"]),
229 Route("/{page}", Index, methods=["GET"]),
230 Route("/block/{block}", Block, methods=["GET"]),
231 Route("/component/{component}", Component, methods=["GET"]),
232 ],
233 )
234 for adapter in get_adapters():
235 routes_path = adapter.path.parent / "_routes.py"
236 if await routes_path.exists():
237 await self.gather_routes(routes_path)
238 if await base_routes_path.exists():
239 await self.gather_routes(base_routes_path)
240 if get_installed_adapter("storage") in ("file", "memory"):
241 from starlette.staticfiles import StaticFiles
243 self.routes.append(
244 Mount(
245 "/media",
246 app=StaticFiles(directory=self.config.storage.local_path / "media"),
247 name="media",
248 ),
249 )
250 if not self.config.deployed:
251 from starlette.staticfiles import StaticFiles
253 self.routes.append(
254 Mount(
255 "/static",
256 app=StaticFiles(
257 directory=self.config.storage.local_path / "static"
258 ),
259 name="media",
260 ),
261 )
262 debug(self.routes)
265MODULE_ID = UUID("01937d86-6f4c-7d5e-a01f-3456789012cd")
266MODULE_STATUS = AdapterStatus.STABLE
268with suppress(Exception):
269 depends.set(Routes, "default")