From 1a5c6aca08a0356195327340496da8d418c80733 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:37:21 +0000 Subject: [PATCH] Fix TemplateResponse calls to use Starlette 1.1.0+ keyword-argument style --- app/sovran_systemsos_web/server.py | 35 ++++--- app/tests/test_template_response_signature.py | 99 +++++++++++++++++++ 2 files changed, 121 insertions(+), 13 deletions(-) create mode 100644 app/tests/test_template_response_signature.py diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 39f9bdf..4b0634b 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -1950,10 +1950,13 @@ def _verify_support_removed() -> bool: @app.get("/login", response_class=HTMLResponse) async def login_page(request: Request): - return templates.TemplateResponse("login.html", { - "request": request, - "asset_version": ASSET_VERSION, - }) + return templates.TemplateResponse( + request=request, + name="login.html", + context={ + "asset_version": ASSET_VERSION, + }, + ) @app.get("/auto-login") @@ -2018,20 +2021,26 @@ async def api_logout(request: Request): @app.get("/", response_class=HTMLResponse) async def index(request: Request): - return templates.TemplateResponse("index.html", { - "request": request, - "asset_version": ASSET_VERSION, - }) + return templates.TemplateResponse( + request=request, + name="index.html", + context={ + "asset_version": ASSET_VERSION, + }, + ) @app.get("/onboarding", response_class=HTMLResponse) async def onboarding(request: Request): _ensure_onboarding_reopened_for_migration() - return templates.TemplateResponse("onboarding.html", { - "request": request, - "asset_version": ASSET_VERSION, - "onboarding_js_hash": _ONBOARDING_JS_HASH, - }) + return templates.TemplateResponse( + request=request, + name="onboarding.html", + context={ + "asset_version": ASSET_VERSION, + "onboarding_js_hash": _ONBOARDING_JS_HASH, + }, + ) @app.get("/api/onboarding/status") diff --git a/app/tests/test_template_response_signature.py b/app/tests/test_template_response_signature.py new file mode 100644 index 0000000..4be6620 --- /dev/null +++ b/app/tests/test_template_response_signature.py @@ -0,0 +1,99 @@ +"""Regression test for Starlette 1.1.0+ TemplateResponse keyword-argument style. + +Prior to this fix, the three HTML routes called: + templates.TemplateResponse("name.html", {"request": request, ...}) +which passes the context dict as the second positional argument. With the +updated Starlette/FastAPI versions shipped in NixOS unstable (Starlette 1.1.0, +FastAPI 0.136.3) that positional argument is the template name, causing Jinja2 +to receive a dict as a cache key and raise: + TypeError: unhashable type: 'dict' + +The fix updates every call to use keyword arguments: + templates.TemplateResponse(request=request, name="name.html", context={...}) +""" + +import ast +import unittest +from pathlib import Path + + +SERVER_PY = Path(__file__).resolve().parents[1] / "sovran_systemsos_web" / "server.py" + + +def _template_response_calls(source: str): + """Return a list of ast.Call nodes that are TemplateResponse calls.""" + tree = ast.parse(source) + calls = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + func = node.func + if isinstance(func, ast.Attribute) and func.attr == "TemplateResponse": + calls.append(node) + return calls + + +class TemplateResponseSignatureTests(unittest.TestCase): + def setUp(self): + self.source = SERVER_PY.read_text() + self.calls = _template_response_calls(self.source) + + def test_at_least_one_template_response_call_found(self): + self.assertGreater(len(self.calls), 0, "No TemplateResponse calls found in server.py") + + def test_no_old_style_positional_dict_context(self): + """No TemplateResponse call should pass a dict literal as its second positional arg. + + The old style was: + templates.TemplateResponse("name.html", {"request": request, ...}) + where args[0] is a string and args[1] is a Dict node. That pattern + triggers the Starlette 1.1.0 bug. + """ + for call in self.calls: + positional = call.args + if len(positional) >= 2 and isinstance(positional[1], ast.Dict): + self.fail( + f"Found old-style TemplateResponse call at line {call.lineno}: " + "second positional argument is a dict literal. " + "Use keyword arguments (request=, name=, context=) instead." + ) + + def test_request_not_duplicated_in_context(self): + """The 'request' key must not appear inside the context= dict when + request= is already passed as a dedicated keyword argument.""" + for call in self.calls: + kw_dict = {kw.arg: kw.value for kw in call.keywords if isinstance(kw, ast.keyword)} + + if "request" not in kw_dict: + continue # no request= kwarg, nothing to check + + context_node = kw_dict.get("context") + if not isinstance(context_node, ast.Dict): + continue + + for key_node in context_node.keys: + if isinstance(key_node, ast.Constant) and key_node.value == "request": + self.fail( + f"TemplateResponse at line {call.lineno} passes 'request' both as " + "request= keyword argument and inside the context dict." + ) + + def test_all_calls_use_keyword_arguments(self): + """Every TemplateResponse call should use keyword arguments for request, name, + and context rather than relying on positional ordering.""" + for call in self.calls: + kw_args = {kw.arg for kw in call.keywords if isinstance(kw, ast.keyword)} + self.assertIn( + "request", + kw_args, + f"TemplateResponse at line {call.lineno} is missing keyword argument 'request='.", + ) + self.assertIn( + "name", + kw_args, + f"TemplateResponse at line {call.lineno} is missing keyword argument 'name='.", + ) + + +if __name__ == "__main__": + unittest.main()