From 3ea8ab374ad05c555a6981bb8ab7dec1279140bb Mon Sep 17 00:00:00 2001 From: ehsan amiri Date: Fri, 4 Sep 2026 00:07:21 +0330 Subject: [PATCH] Support async template context across Python, Node, and C#. Allow async def context() / Promise / ContextAsync so pages can load data before Tera render without overriding get(). Co-authored-by: Cursor --- .../FusionFramework/FusionBaseTemplate.cs | 59 +++++++++++- crates/fusion-node/index.d.ts | 7 +- crates/fusion-node/index.js | 35 ++++++- .../python/fusion_framework/template.py | 95 ++++++++++++++++--- tests/python/unit/test_templates.py | 41 ++++++++ 5 files changed, 216 insertions(+), 21 deletions(-) diff --git a/bindings/csharp/FusionFramework/FusionBaseTemplate.cs b/bindings/csharp/FusionFramework/FusionBaseTemplate.cs index 0672a51..ad25542 100644 --- a/bindings/csharp/FusionFramework/FusionBaseTemplate.cs +++ b/bindings/csharp/FusionFramework/FusionBaseTemplate.cs @@ -10,13 +10,36 @@ public abstract class FusionBaseTemplate : FusionBaseApi public static string TemplateAddress { get; set; } = ""; public static string TemplatesDir { get; set; } = ""; + /// Sync template variables (override in subclasses). public virtual Dictionary Context() => new(); + /// Async template variables; default wraps . + public virtual Task> ContextAsync() => + Task.FromResult(Context()); + + /// + /// Default GET — HTML or JSON context. Uses so + /// subclasses can override that for DB/API-backed pages (Python async context parity). + /// public virtual object Get() + { + var task = ContextAsync(); + if (task.IsCompletedSuccessfully) + return FinishGet(task.Result); + return FinishGetAsync(task); + } + + async Task FinishGetAsync(Task> task) + { + var ctx = await task.ConfigureAwait(false); + return FinishGet(ctx); + } + + object FinishGet(Dictionary ctx) { if (WantsJson()) - return Context(); - return Render(); + return ctx; + return HtmlResponse(ctx); } public virtual string TemplateName() @@ -41,12 +64,42 @@ public virtual object Render( IDictionary? context = null, string? templateName = null) { - var ctx = new Dictionary(Context(), StringComparer.Ordinal); + var task = ContextAsync(); + if (!task.IsCompletedSuccessfully) + return RenderAsync(task, status, headers, context, templateName); + + var ctx = new Dictionary(task.Result, StringComparer.Ordinal); + if (context != null) + { + foreach (var kv in context) + ctx[kv.Key] = kv.Value; + } + return HtmlResponse(ctx, status, headers, templateName); + } + + async Task RenderAsync( + Task> task, + int status, + IDictionary? headers, + IDictionary? context, + string? templateName) + { + var ctx = new Dictionary(await task.ConfigureAwait(false), StringComparer.Ordinal); if (context != null) { foreach (var kv in context) ctx[kv.Key] = kv.Value; } + return HtmlResponse(ctx, status, headers, templateName); + } + + /// Render an already-resolved context dictionary to an HTML envelope. + protected object HtmlResponse( + IDictionary ctx, + int status = 200, + IDictionary? headers = null, + string? templateName = null) + { var html = Templates.Render( templateName ?? TemplateName(), ctx, diff --git a/crates/fusion-node/index.d.ts b/crates/fusion-node/index.d.ts index 4dddf75..a2071fb 100644 --- a/crates/fusion-node/index.d.ts +++ b/crates/fusion-node/index.d.ts @@ -34,8 +34,9 @@ export class FusionBaseTemplate extends FusionBaseApi { static template: string static templateAddress: string static templatesDir: string - context(): Record - get(): FusionResponse + /** Sync object or Promise (async context). */ + context(): Record | Promise> + get(): FusionResponse | Promise templateName(): string templatesRoot(): string render(options?: { @@ -43,7 +44,7 @@ export class FusionBaseTemplate extends FusionBaseApi { headers?: Record context?: Record templateName?: string - }): FusionResponse + }): FusionResponse | Promise } export function renderTemplate( diff --git a/crates/fusion-node/index.js b/crates/fusion-node/index.js index 018f25a..d00b18c 100644 --- a/crates/fusion-node/index.js +++ b/crates/fusion-node/index.js @@ -372,13 +372,28 @@ class FusionBaseTemplate extends FusionBaseApi { static templateAddress = '' static templatesDir = '' + /** Template variables; may return a Promise (async context). */ context() { return {} } get() { - if (this.wantsJson()) return this.context() - return this.render() + const raw = this.context() + if (raw && typeof raw.then === 'function') { + return this._getAsync(raw) + } + return this._finishGet(raw) + } + + async _getAsync(raw) { + const ctx = await raw + return this._finishGet(ctx) + } + + _finishGet(ctx) { + const data = { ...(ctx || {}) } + if (this.wantsJson()) return data + return this._htmlResponse(data) } templateName() { @@ -400,7 +415,21 @@ class FusionBaseTemplate extends FusionBaseApi { context = null, templateName = null, } = {}) { - const ctx = { ...this.context(), ...(context || {}) } + const raw = this.context() + if (raw && typeof raw.then === 'function') { + return this._renderAsync(raw, { status, headers, context, templateName }) + } + const ctx = { ...(raw || {}), ...(context || {}) } + return this._htmlResponse(ctx, { status, headers, templateName }) + } + + async _renderAsync(raw, { status = 200, headers = {}, context = null, templateName = null } = {}) { + const base = await raw + const ctx = { ...(base || {}), ...(context || {}) } + return this._htmlResponse(ctx, { status, headers, templateName }) + } + + _htmlResponse(ctx, { status = 200, headers = {}, templateName = null } = {}) { const html = renderTemplate( templateName || this.templateName(), ctx, diff --git a/crates/fusion-py/python/fusion_framework/template.py b/crates/fusion-py/python/fusion_framework/template.py index 27d0636..8ae5b89 100644 --- a/crates/fusion-py/python/fusion_framework/template.py +++ b/crates/fusion-py/python/fusion_framework/template.py @@ -2,8 +2,9 @@ from __future__ import annotations +import inspect from pathlib import Path -from typing import Any, ClassVar, Mapping, Optional +from typing import Any, ClassVar, Mapping, Optional, Union from fusion_framework._fusion import render_template as _render_template from fusion_framework.api import FusionBaseApi @@ -25,9 +26,9 @@ class FusionBaseTemplate(FusionBaseApi): """Class-based HTML handler using Tera templates. Set ``template`` (or ``template_address``) to the file path under the templates - directory. Override ``context()`` to pass variables. The default ``get()`` renders - HTML for browsers and returns ``context()`` as JSON when the client sends - ``Accept: application/json`` or ``?format=json``. + directory. Override ``context()`` to pass variables — sync or ``async def``. + The default ``get()`` renders HTML for browsers and returns ``context()`` as JSON + when the client sends ``Accept: application/json`` or ``?format=json``. Template routes are mounted as HTTP handlers but are excluded from Swagger/OpenAPI. @@ -42,15 +43,32 @@ class FusionBaseTemplate(FusionBaseApi): template_address: ClassVar[str] = "" templates_dir: ClassVar[str] = "" - def context(self) -> dict[str, Any]: - """Template variables (override in subclasses).""" + def context(self) -> Union[dict[str, Any], Any]: + """Template variables (override in subclasses; may be ``async def``).""" return {} - def get(self) -> dict[str, Any]: - """Default GET — HTML page, or ``context()`` JSON when client wants JSON.""" + def get(self) -> Any: + """Default GET — HTML page, or ``context()`` JSON when client wants JSON. + + Supports sync or async ``context()``; async returns an awaitable for the + framework event loop. + """ + raw = self.context() + if inspect.isawaitable(raw): + return self._get_async(raw) + return self._finish_get(raw) + + async def _get_async(self, raw: Any) -> Any: + """Await async ``context()`` then finish the GET response.""" + ctx = await raw + return self._finish_get(ctx) + + def _finish_get(self, ctx: Any) -> dict[str, Any]: + """Build JSON or HTML from an already-resolved context mapping.""" + data = dict(ctx or {}) if self.wants_json(): - return self.context() - return self.render() + return data + return self._html_response(data) def template_name(self) -> str: """Resolved template path (override for dynamic templates).""" @@ -75,11 +93,64 @@ def render( context: Mapping[str, Any] | None = None, template_name: str | None = None, **extra: str, + ) -> Any: + """Render template and return an HTML response envelope. + + If ``context()`` is async and ``context=`` is omitted, returns an awaitable. + """ + raw = self.context() + if inspect.isawaitable(raw): + return self._render_async( + raw, + status=status, + headers=headers, + context=context, + template_name=template_name, + **extra, + ) + ctx = dict(raw or {}) + if context: + ctx.update(context) + return self._html_response( + ctx, + status=status, + headers=headers, + template_name=template_name, + **extra, + ) + + async def _render_async( + self, + raw: Any, + *, + status: int = 200, + headers: Mapping[str, str] | None = None, + context: Mapping[str, Any] | None = None, + template_name: str | None = None, + **extra: str, ) -> dict[str, Any]: - """Render template and return an HTML response envelope.""" - ctx = dict(self.context()) + """Await async ``context()`` then render HTML.""" + ctx = dict(await raw) if context: ctx.update(context) + return self._html_response( + ctx, + status=status, + headers=headers, + template_name=template_name, + **extra, + ) + + def _html_response( + self, + ctx: Mapping[str, Any], + status: int = 200, + headers: Mapping[str, str] | None = None, + *, + template_name: str | None = None, + **extra: str, + ) -> dict[str, Any]: + """Render ``ctx`` into an HTML response envelope.""" html = render_template( template_name or self.template_name(), ctx, diff --git a/tests/python/unit/test_templates.py b/tests/python/unit/test_templates.py index 968ca09..418066e 100644 --- a/tests/python/unit/test_templates.py +++ b/tests/python/unit/test_templates.py @@ -2,6 +2,8 @@ from __future__ import annotations +import asyncio +import inspect from pathlib import Path import pytest @@ -78,6 +80,45 @@ def context(self): assert page.get() == {"name": "Fusion"} +def test_async_context_renders_html(tmp_path: Path): + """async def context() is awaited by get()/render().""" + + class Page(FusionBaseTemplate): + template = "hello.html" + + async def context(self): + return {"name": "AsyncFusion"} + + (tmp_path / "hello.html").write_text("

Hello {{ name }}!

", encoding="utf-8") + page = Page({"method": "GET", "path": "/"}) + page.templates_dir = str(tmp_path) + + out = page.get() + assert inspect.isawaitable(out) + resolved = asyncio.run(out) + assert resolved["status"] == 200 + assert "Hello AsyncFusion!" in resolved["body"] + + +def test_async_context_json_accept(): + class Page(FusionBaseTemplate): + template = "hello.html" + + async def context(self): + return {"title": "from-db"} + + page = Page( + { + "method": "GET", + "path": "/", + "headers": {"accept": "application/json"}, + } + ) + out = page.get() + assert inspect.isawaitable(out) + assert asyncio.run(out) == {"title": "from-db"} + + def test_template_get_returns_html_for_browser_accept(tmp_path: Path): class Page(FusionBaseTemplate): template = "hello.html"