From 5147a5bd5b210b733ceaf6c3f3662a045d7e9fba Mon Sep 17 00:00:00 2001 From: thomasborgen Date: Tue, 17 Feb 2026 23:27:35 +0100 Subject: [PATCH 1/4] Simplify fastapi typing and add tests to decorator --- hypermedia/fastapi.py | 51 +++++++++++++++------------- tests/fastapi/__init__.py | 0 tests/fastapi/conftest.py | 23 +++++++++---- tests/fastapi/index.py | 20 +++++++++++ tests/fastapi/test_htmx_decorator.py | 49 ++++++++++++++++++++++++++ 5 files changed, 113 insertions(+), 30 deletions(-) create mode 100644 tests/fastapi/__init__.py create mode 100644 tests/fastapi/index.py create mode 100644 tests/fastapi/test_htmx_decorator.py diff --git a/hypermedia/fastapi.py b/hypermedia/fastapi.py index 00afda3..97c2c7c 100644 --- a/hypermedia/fastapi.py +++ b/hypermedia/fastapi.py @@ -3,8 +3,8 @@ Any, Callable, Coroutine, - ParamSpec, Protocol, + TypeAlias, TypeVar, ) @@ -19,29 +19,36 @@ "Or `uv add hypermedia --extras fastapi`" ) from ie -Param = ParamSpec("Param") -ReturnType = TypeVar("ReturnType") +T = TypeVar("T", bound="Element") +LazyElement: TypeAlias = Callable[..., Element] -class RequestPartialAndFull(Protocol): - """Requires, `request`, `partial` and `full` args on decorated function.""" + +class PartialHTMXRequest(Protocol): + """Requires, `request`, `partial` args on decorated function.""" def __call__( # noqa: D102 - self, request: Request, partial: Element, full: Element + self, + request: Request, + partial: Element, + full: None = None, ) -> Coroutine[Any, Any, None]: ... -class RequestAndPartial(Protocol): - """Requires, `request` and `partial` args on decorated function.""" +class FullHTMXRequest(Protocol): + """Requires, `request`, `partial` and `full` args on decorated function.""" def __call__( # noqa: D102 - self, request: Request, partial: Element + self, + request: Request, + partial: Element, + full: LazyElement, ) -> Coroutine[Any, Any, None]: ... def htmx( - func: RequestPartialAndFull | RequestAndPartial, -) -> Callable[..., str]: + func: PartialHTMXRequest | FullHTMXRequest, +) -> PartialHTMXRequest | FullHTMXRequest: """Wrap a FastAPI endpoint, to enable partial and full rendering. The endpoint function _must_ have a partial render dependency, and @@ -59,7 +66,7 @@ def htmx( @wraps(func) async def wrapper( *, - request: Any, + request: Request, partial: Element, full: None | Callable[..., Element] = None, ) -> str: @@ -77,29 +84,25 @@ async def wrapper( return wrapper # type: ignore -def full( - func: Callable[Param, ReturnType], -) -> Callable[Param, Coroutine[Any, Any, Callable[[], ReturnType]]]: - """Wrap the full page render dependency and makes it lazy.""" +def full(func: Callable[..., T]) -> Callable[..., T]: + """Mark a function as a full renderer. + + This will prevent the function from being evaluated before it is needed + """ @wraps(func) - async def wrapper( - *args: Param.args, - **kwargs: Param.kwargs, - ) -> Callable[[], ReturnType]: - """Wrap function.""" + def deferred_renderer(*args: Any, **kwargs: Any) -> T: return lambda: func(*args, **kwargs) - return wrapper + return deferred_renderer def add_htmx_middleware(app: FastAPI) -> None: - """Instrument the app with middleware to add Vary: Accept header. + """Add middleware to the app that adds the Vary: Accept header. This allows the browser to cache the responses based on caller, which should prevent the browser from caching htmx responses as a full page """ - # Check if we've already instrumented if getattr(app.state, "hypermedia_htmx_middleware", False): return diff --git a/tests/fastapi/__init__.py b/tests/fastapi/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fastapi/conftest.py b/tests/fastapi/conftest.py index 6de3cc8..d96b8d9 100644 --- a/tests/fastapi/conftest.py +++ b/tests/fastapi/conftest.py @@ -1,9 +1,13 @@ +from typing import Annotated + import pytest -from fastapi import FastAPI +from fastapi import Depends, FastAPI, Request from fastapi.responses import HTMLResponse from fastapi.testclient import TestClient -from hypermedia.fastapi import add_htmx_middleware +from hypermedia.fastapi import LazyElement, add_htmx_middleware, full, htmx +from hypermedia.models import Element +from tests.fastapi.index import render_index, render_index_partial @pytest.fixture @@ -14,15 +18,22 @@ def app() -> FastAPI: ) @_app.get("/", response_class=HTMLResponse) - async def root() -> str: - """Root.""" - return "root" + @htmx + async def index( + request: Request, + partial: Annotated[Element, Depends(render_index_partial)], + full: Annotated[LazyElement, Depends(full(render_index))], + ) -> None: + """Return the index page.""" + pass return _app @pytest.fixture -def client(app: FastAPI) -> TestClient: +def client( + app: FastAPI, +) -> TestClient: """Test client.""" return TestClient(app) diff --git a/tests/fastapi/index.py b/tests/fastapi/index.py new file mode 100644 index 0000000..e05cf7b --- /dev/null +++ b/tests/fastapi/index.py @@ -0,0 +1,20 @@ +from typing import Annotated + +from fastapi import Depends + +from hypermedia import Div, Element + + +def render_index_partial() -> Element: + """Return partial HTML.""" + print("HEI HEI HHALLO PARTIAL") + return Div("partial") + + +def render_index( + partial: Annotated[Element, Depends(render_index_partial)], +) -> Element: + """Return full HTML.""" + + print("HEI HEI HALO FULL") + return Div("full", partial) diff --git a/tests/fastapi/test_htmx_decorator.py b/tests/fastapi/test_htmx_decorator.py new file mode 100644 index 0000000..8c45000 --- /dev/null +++ b/tests/fastapi/test_htmx_decorator.py @@ -0,0 +1,49 @@ +from unittest.mock import Mock + +from fastapi import FastAPI, status +from fastapi.testclient import TestClient + +from hypermedia import Div +from tests.fastapi.index import render_index, render_index_partial + + +def test_full_html_returned( + client: TestClient, +) -> None: + response = client.get("/") + assert response.status_code == status.HTTP_200_OK + assert "full" in response.text + assert "partial" in response.text + + +def test_only_partial_html_returned( + client: TestClient, +) -> None: + response = client.get("/", headers={"HX-Request": "true"}) + assert response.status_code == status.HTTP_200_OK + assert "full" not in response.text + assert "partial" in response.text + + +def test_render_index_partial_called( + app: FastAPI, + client: TestClient, +) -> None: + mock_partial = Mock(return_value=Div("mocked")) + app.dependency_overrides[render_index_partial] = lambda: mock_partial() + + client.get("/", headers={"HX-Request": "true"}) + + mock_partial.assert_called_once() + + +def test_render_index_not_called( + app: FastAPI, + client: TestClient, +) -> None: + mock_full = Mock(return_value=Div("mocked")) + app.dependency_overrides[render_index] = lambda: mock_full() + + client.get("/", headers={"HX-Request": "true"}) + + mock_full.assert_not_called() From 6616186571d6645ea683512322d7f5d93dd2593e Mon Sep 17 00:00:00 2001 From: thomasborgen Date: Tue, 17 Feb 2026 23:55:04 +0100 Subject: [PATCH 2/4] add test for partial only endpoint --- hypermedia/fastapi.py | 5 ++--- tests/fastapi/conftest.py | 9 +++++++++ tests/fastapi/index.py | 3 --- tests/fastapi/test_htmx_decorator.py | 18 ++++++++++++++++++ 4 files changed, 29 insertions(+), 6 deletions(-) diff --git a/hypermedia/fastapi.py b/hypermedia/fastapi.py index 97c2c7c..2988bbe 100644 --- a/hypermedia/fastapi.py +++ b/hypermedia/fastapi.py @@ -31,7 +31,6 @@ def __call__( # noqa: D102 self, request: Request, partial: Element, - full: None = None, ) -> Coroutine[Any, Any, None]: ... @@ -84,14 +83,14 @@ async def wrapper( return wrapper # type: ignore -def full(func: Callable[..., T]) -> Callable[..., T]: +def full(func: Callable[..., T]) -> Callable[..., Callable[..., T]]: """Mark a function as a full renderer. This will prevent the function from being evaluated before it is needed """ @wraps(func) - def deferred_renderer(*args: Any, **kwargs: Any) -> T: + def deferred_renderer(*args: Any, **kwargs: Any) -> Callable[..., T]: return lambda: func(*args, **kwargs) return deferred_renderer diff --git a/tests/fastapi/conftest.py b/tests/fastapi/conftest.py index d96b8d9..33144c3 100644 --- a/tests/fastapi/conftest.py +++ b/tests/fastapi/conftest.py @@ -27,6 +27,15 @@ async def index( """Return the index page.""" pass + @_app.get("/partial", response_class=HTMLResponse) + @htmx + async def index( + request: Request, + partial: Annotated[Element, Depends(render_index_partial)], + ) -> None: + """Return the index page.""" + pass + return _app diff --git a/tests/fastapi/index.py b/tests/fastapi/index.py index e05cf7b..7ada2f7 100644 --- a/tests/fastapi/index.py +++ b/tests/fastapi/index.py @@ -7,7 +7,6 @@ def render_index_partial() -> Element: """Return partial HTML.""" - print("HEI HEI HHALLO PARTIAL") return Div("partial") @@ -15,6 +14,4 @@ def render_index( partial: Annotated[Element, Depends(render_index_partial)], ) -> Element: """Return full HTML.""" - - print("HEI HEI HALO FULL") return Div("full", partial) diff --git a/tests/fastapi/test_htmx_decorator.py b/tests/fastapi/test_htmx_decorator.py index 8c45000..d355e18 100644 --- a/tests/fastapi/test_htmx_decorator.py +++ b/tests/fastapi/test_htmx_decorator.py @@ -47,3 +47,21 @@ def test_render_index_not_called( client.get("/", headers={"HX-Request": "true"}) mock_full.assert_not_called() + + +def test_render_partial_data_only_when_no_full_available( + client: TestClient, +) -> None: + response = client.get("/partial") + + assert "full" not in response.text + assert "partial" in response.text + + +def test_render_partial_data_only_when_no_full_available_htmx( + client: TestClient, +) -> None: + response = client.get("/partial", headers={"HX-Request": "true"}) + + assert "full" not in response.text + assert "partial" in response.text From 595b39824bb402d91d52f63f5a6038aac3b3bb66 Mon Sep 17 00:00:00 2001 From: thomasborgen Date: Wed, 18 Feb 2026 23:18:09 +0100 Subject: [PATCH 3/4] Add one more tests --- tests/fastapi/conftest.py | 5 +++-- tests/fastapi/test_htmx_decorator.py | 12 ++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/tests/fastapi/conftest.py b/tests/fastapi/conftest.py index 33144c3..b61a2e7 100644 --- a/tests/fastapi/conftest.py +++ b/tests/fastapi/conftest.py @@ -21,8 +21,9 @@ def app() -> FastAPI: @htmx async def index( request: Request, - partial: Annotated[Element, Depends(render_index_partial)], - full: Annotated[LazyElement, Depends(full(render_index))], + partial: Annotated[str, Depends(render_index_partial)], + asdf: Annotated[str, Depends(full(render_index))], + arne: str, ) -> None: """Return the index page.""" pass diff --git a/tests/fastapi/test_htmx_decorator.py b/tests/fastapi/test_htmx_decorator.py index d355e18..bb6ecff 100644 --- a/tests/fastapi/test_htmx_decorator.py +++ b/tests/fastapi/test_htmx_decorator.py @@ -49,6 +49,18 @@ def test_render_index_not_called( mock_full.assert_not_called() +def test_render_index_partial_called_only_once_on_full( + app: FastAPI, + client: TestClient, +) -> None: + mock_partial = Mock(return_value=Div("mocked")) + app.dependency_overrides[render_index_partial] = lambda: mock_partial() + + client.get("/") + + mock_partial.assert_called_once() + + def test_render_partial_data_only_when_no_full_available( client: TestClient, ) -> None: From 30baae1d70e81a5818433ccceac92b490730b7c5 Mon Sep 17 00:00:00 2001 From: thomasborgen Date: Sat, 9 May 2026 15:08:23 +0200 Subject: [PATCH 4/4] hmmm --- tests/fastapi/conftest.py | 7 +++---- tests/fastapi/test_htmx_decorator.py | 1 + 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/fastapi/conftest.py b/tests/fastapi/conftest.py index b61a2e7..ed23bc3 100644 --- a/tests/fastapi/conftest.py +++ b/tests/fastapi/conftest.py @@ -21,16 +21,15 @@ def app() -> FastAPI: @htmx async def index( request: Request, - partial: Annotated[str, Depends(render_index_partial)], - asdf: Annotated[str, Depends(full(render_index))], - arne: str, + partial: Annotated[Element, Depends(render_index_partial)], + full: Annotated[LazyElement, Depends(full(render_index))], ) -> None: """Return the index page.""" pass @_app.get("/partial", response_class=HTMLResponse) @htmx - async def index( + async def index_partial( request: Request, partial: Annotated[Element, Depends(render_index_partial)], ) -> None: diff --git a/tests/fastapi/test_htmx_decorator.py b/tests/fastapi/test_htmx_decorator.py index bb6ecff..d671f92 100644 --- a/tests/fastapi/test_htmx_decorator.py +++ b/tests/fastapi/test_htmx_decorator.py @@ -20,6 +20,7 @@ def test_only_partial_html_returned( client: TestClient, ) -> None: response = client.get("/", headers={"HX-Request": "true"}) + print(response.text) assert response.status_code == status.HTTP_200_OK assert "full" not in response.text assert "partial" in response.text