Repository: samuelcolvin/foxglove (language: python)

Issue:
add back templates
See https://github.com/samuelcolvin/foxglove/blob/v0.0.18/foxglove/templates.py (was remove in v0.0.19)


Discussion / hints:

Fix patch:
diff --git a/foxglove/settings.py b/foxglove/settings.py
index 1e79bcc..bc30d9b 100644
--- a/foxglove/settings.py
+++ b/foxglove/settings.py
@@ -56,6 +56,7 @@ class BaseSettings(PydanticBaseSettings):
     patch_paths: List[str] = ['src.patches']
 
     sql_path: Path = 'models.sql'
+    template_dir: Optional[Path] = None
     pg_dsn: Optional[str] = pg_dsn_default
     # eg. the db already exists on heroku and never has to be created
     pg_db_exists = False
@@ -154,5 +155,5 @@ class Config:
             'redis_settings': {'env': ['REDISCLOUD_URL', 'REDIS_URL']},
             'dev_mode': {'env': ['foxglove_dev_mode']},
             'environment': {'env': ['ENV', 'ENVIRONMENT']},
-            'release': {'env': ['COMMIT', 'RELEASE', 'HEROKU_SLUG_COMMIT']},
+            'release': {'env': ['COMMIT', 'RELEASE', 'HEROKU_SLUG_COMMIT', 'RENDER_GIT_COMMIT']},
         }
diff --git a/foxglove/templates.py b/foxglove/templates.py
new file mode 100644
index 0000000..e04f568
--- /dev/null
+++ b/foxglove/templates.py
@@ -0,0 +1,111 @@
+import asyncio
+import os
+import secrets
+from functools import wraps
+from time import time
+from typing import Any, Optional, Tuple, Union
+
+from starlette.responses import Response
+from starlette.templating import Jinja2Templates as _Jinja2Templates, _TemplateResponse
+
+from .main import glove
+
+try:
+    import jinja2
+except ImportError:  # pragma: nocover
+    jinja2 = None  # type: ignore
+
+try:
+    from markupsafe import Markup
+except ImportError:  # pragma: nocover
+    Markup = None  # type: ignore
+
+__all__ = ('FoxgloveTemplates',)
+
+
+class FoxgloveTemplates(_Jinja2Templates):
+    def __init__(self, directory: Union[str, os.PathLike, None] = None, **env_options: Any) -> None:
+        directory = glove.settings.template_dir if directory is None else directory
+        super().__init__(directory, **env_options)
+        self.env.globals.update(static_url=static_url, dev_mode=glove.settings.dev_mode)
+
+    def render(self, template_name: str):
+        def view_decorator(view):
+            if asyncio.iscoroutinefunction(view):
+
+                @wraps(view)
+                async def view_wrapper(request, *args, **kwargs):
+                    return self._return_template(request, template_name, await view(request, *args, **kwargs))
+
+            else:
+
+                @wraps(view)
+                def view_wrapper(request, *args, **kwargs):
+                    return self._return_template(request, template_name, view(request, *args, **kwargs))
+
+            return view_wrapper
+
+        return view_decorator
+
+    def _return_template(self, request, template_name: str, r: Union[Optional[dict], Tuple[int, Optional[dict]]]):
+        if isinstance(r, (tuple, list)):
+            status_code, context = r
+        else:
+            status_code, context = 200, r
+        if context is None:
+            context = {}
+        context['request'] = request
+        return self.TemplateResponse(template_name, context, status_code=status_code)
+
+    if glove.settings.test_mode:  # pragma: no branch
+        # hacky workaround for https://github.com/encode/starlette/issues/472
+
+        def TemplateResponse(
+            self,
+            name: str,
+            context: dict,
+            status_code: int = 200,
+            headers: dict = None,
+            media_type: str = None,
+            background=None,
+        ) -> _TemplateResponse:
+            if 'request' not in context:
+                raise ValueError('context must include a "request" key')
+            template = self.get_template(name)
+            return TestingTemplateResponse(
+                template,
+                context,
+                status_code=status_code,
+                headers=headers,
+                media_type=media_type,
+                background=background,
+            )
+
+
+static_version = (glove.settings.release or secrets.token_hex(4))[:7]
+
+
+@jinja2.pass_context
+def static_url(context: dict, path: str) -> str:
+    request = context['request']
+    try:
+        static_route_name = context.get('static_route_name', 'static')
+        url = request.url_for(static_route_name, path=path)
+        if context['dev_mode']:
+            url += f'?t={time() * 1000:0.0f}'
+        else:
+            url += f'?v={static_version}'
+        return url
+    except KeyError:
+        return f'/static/{path}'
+
+
+class TestingTemplateResponse(_TemplateResponse):
+    """
+    See https://github.com/encode/starlette/issues/472. Alternative for _TemplateResponse that avoids
+    `send(..."type": "http.response.template"...)` and thus works while testing.
+    """
+
+    async def __call__(self, scope, receive, send) -> None:
+        # context sending removed
+        await Response.__call__(self, scope, receive, send)


Test patch:
diff --git a/requirements/testing.in b/requirements/testing.in
index d9d7868..1e74bbc 100644
--- a/requirements/testing.in
+++ b/requirements/testing.in
@@ -1,6 +1,7 @@
 aiohttp
 coverage
 dirty-equals
+jinja2
 pytest
 pytest-asyncio
 pytest-cov
diff --git a/requirements/testing.txt b/requirements/testing.txt
index 5ae5d82..aab73ca 100644
--- a/requirements/testing.txt
+++ b/requirements/testing.txt
@@ -36,6 +36,10 @@ idna==3.4
     #   yarl
 iniconfig==1.1.1
     # via pytest
+jinja2==3.1.2
+    # via -r requirements/testing.in
+markupsafe==2.1.1
+    # via jinja2
 multidict==6.0.2
     # via
     #   aiohttp
diff --git a/tests/demo/main.py b/tests/demo/main.py
index 066a97c..5071dbd 100644
--- a/tests/demo/main.py
+++ b/tests/demo/main.py
@@ -4,6 +4,7 @@
 
 from buildpg.asyncpg import BuildPgConnection
 from fastapi import Depends, FastAPI, Request
+from fastapi.staticfiles import StaticFiles
 from pydantic import BaseModel
 from starlette.middleware import Middleware
 from starlette.middleware.sessions import SessionMiddleware
@@ -15,6 +16,7 @@
 from foxglove.middleware import CsrfMiddleware, ErrorMiddleware
 from foxglove.recaptcha import RecaptchaDepends
 from foxglove.route_class import SafeAPIRoute
+from foxglove.templates import FoxgloveTemplates
 
 logger = logging.getLogger('main')
 
@@ -53,6 +55,7 @@ def should_check_csrf(request):
     redoc_url='/docs',
 )
 app.router.route_class = SafeAPIRoute
+app.mount('/static', StaticFiles(directory=glove.settings.static), name='static')
 
 
 @app.exception_handler(exceptions.HttpMessageError)
@@ -124,3 +127,24 @@ async def rate_limit_raise():
 @app.get('/rate-limit-return/')
 async def rate_limit_return(request_count: int = Depends(rate_limit(request_limit=None, interval=1000))):
     return {'request_count': request_count}
+
+
+templates = FoxgloveTemplates()
+
+
+@app.get('/template/')
+@templates.render('foobar.jinja')
+async def render_template(request: Request):
+    return {'name': 'Samuel'}
+
+
+@app.get('/template/456/')
+@templates.render('foobar.jinja')
+def render_template_456(request: Request):
+    return 456, None
+
+
+@app.get('/template/spam/')
+@templates.render('spam.jinja')
+def render_template_spam(request: Request):
+    pass
diff --git a/tests/demo/settings.py b/tests/demo/settings.py
index 53713dd..219b6a3 100644
--- a/tests/demo/settings.py
+++ b/tests/demo/settings.py
@@ -14,5 +14,7 @@ class Settings(BaseSettings):
     pg_dsn = 'postgres://postgres@localhost:5432/foxglove_demo'
     app = 'demo.main:app'
     redis_settings: Optional[RedisSettings] = 'redis://localhost:6379/6'
+    template_dir = THIS_DIR / 'templates'
+    static = THIS_DIR / 'static'
 
     worker_func = 'demo.main:worker'
diff --git a/tests/demo/static/foobar.js b/tests/demo/static/foobar.js
new file mode 100644
index 0000000..0a230c9
--- /dev/null
+++ b/tests/demo/static/foobar.js
@@ -0,0 +1,1 @@
+alert('hello')
diff --git a/tests/demo/templates/foobar.jinja b/tests/demo/templates/foobar.jinja
new file mode 100644
index 0000000..9b3a958
--- /dev/null
+++ b/tests/demo/templates/foobar.jinja
@@ -0,0 +1,1 @@
+<p>Hello {{ name or '???' }}</p>
diff --git a/tests/demo/templates/spam.jinja b/tests/demo/templates/spam.jinja
new file mode 100644
index 0000000..244207e
--- /dev/null
+++ b/tests/demo/templates/spam.jinja
@@ -0,0 +1,1 @@
+<script src="{{ static_url('foobar.js') }}"></script>
diff --git a/tests/test_templates.py b/tests/test_templates.py
new file mode 100644
index 0000000..b9e477f
--- /dev/null
+++ b/tests/test_templates.py
@@ -0,0 +1,33 @@
+import pytest
+from dirty_equals import IsStr
+
+from foxglove.testing import TestClient as Client
+
+
+def test_template(client: Client):
+    r = client.get('/template/')
+    assert r.status_code == 200, r.text
+    assert r.headers['content-type'] == 'text/html; charset=utf-8'
+    assert r.text == '<p>Hello Samuel</p>'
+
+
+def test_template_status(client: Client):
+    r = client.get('/template/456/')
+    assert r.status_code == 456, r.text
+    assert r.headers['content-type'] == 'text/html; charset=utf-8'
+    assert r.text == '<p>Hello ???</p>'
+
+
+def test_template_static_url(client: Client):
+    r = client.get('/template/spam/')
+    assert r.status_code == 200, r.text
+    assert r.headers['content-type'] == 'text/html; charset=utf-8'
+    assert r.text == IsStr(regex=r'<script src="http://testserver/static/foobar\.js\?v=[0-9a-f]{7}"></script>')
+
+
+def test_no_request(glove):
+    from foxglove.templates import FoxgloveTemplates
+
+    templates = FoxgloveTemplates()
+    with pytest.raises(ValueError, match='context must include a "request" key'):
+        templates.TemplateResponse('foobar', {})