]> git.ipfire.org Git - thirdparty/starlette.git/commitdiff
Add Jinja2Templates component
authorTom Christie <tom@tomchristie.com>
Mon, 18 Feb 2019 13:12:55 +0000 (13:12 +0000)
committerTom Christie <tom@tomchristie.com>
Mon, 18 Feb 2019 13:12:55 +0000 (13:12 +0000)
docs/responses.md
docs/templates.md
starlette/applications.py
starlette/database/mysql.py
starlette/responses.py
starlette/templating.py [new file with mode: 0644]
tests/test_database.py
tests/test_responses.py
tests/test_templates.py

index 60f89b72a21d077ae171b0113f9d1147f5948112..595c9101cfc8ab3cd5467c6b7b914da12512372a 100644 (file)
@@ -89,52 +89,6 @@ class App:
         await response(receive, send)
 ```
 
-### TemplateResponse
-
-The `TemplateResponse` class return plain text responses generated
-from a template instance, and a dictionary of context to render into the
-template.
-
-A `request` argument must always be included in the context. Responses default
-to `text/html` unless an alternative `media_type` is specified.
-
-```python
-from starlette.responses import TemplateResponse
-from starlette.requests import Request
-
-from jinja2 import Environment, FileSystemLoader
-
-
-env = Environment(loader=FileSystemLoader('templates'))
-
-
-class App:
-    def __init__(self, scope):
-        assert scope['type'] == 'http'
-        self.scope = scope
-
-    async def __call__(self, receive, send):
-        template = env.get_template('index.html')
-        context = {
-            'request': Request(self.scope),
-        }
-        response = TemplateResponse(template, context)
-        await response(receive, send)
-```
-
-The advantage with using `TemplateResponse` over `HTMLResponse` is that
-it will make `template` and `context` properties available on response instances
-returned by the test client.
-
-```python
-def test_app():
-    client = TestClient(App)
-    response = client.get("/")
-    assert response.status_code == 200
-    assert response.template.name == "index.html"
-    assert "request" in response.context
-```
-
 ### JSONResponse
 
 Takes some data and returns an `application/json` encoded response.
index f4e59c4406fe3dc644785067d9b4ab77138bdfd3..faabdec6c218584de38131e5b7a797bf8d6ed0db 100644 (file)
@@ -1,74 +1,28 @@
 Starlette is not *strictly* coupled to any particular templating engine, but
 Jinja2 provides an excellent choice.
 
-The `Starlette` application class provides a simple way to get `jinja2`
-configured. This is probably what you want to use by default.
-
-```python
-app = Starlette(debug=True, template_directory='templates')
-app.mount('/static', StaticFiles(directory='statics'), name='static')
-
-
-@app.route('/')
-async def homepage(request):
-    template = app.get_template('index.html')
-    content = template.render(request=request)
-    return HTMLResponse(content)
-```
-
-If you include `request` in the template context, then the `url_for` function
-will also be available within your template code.
-
-The Jinja2 `Environment` instance is available as `app.template_env`.
-
-## Handling templates explicitly
-
-If you don't want to use `jinja2`, or you don't want to rely on
-Starlette's default configuration you can configure a template renderer
-explicitly instead.
-
-Here we're going to take a look at an example of how you can explicitly
-configure a Jinja2 environment together with Starlette.
+Starlette provides a simple way to get `jinja2` configured. This is probably
+what you want to use by default.
 
 ```python
 from starlette.applications import Starlette
-from starlette.staticfiles import StaticFiles
-from starlette.responses import HTMLResponse
-
+from starlette.templating import Jinja2Templates
 
-def setup_jinja2(template_dir):
-    @jinja2.contextfunction
-    def url_for(context, name, **path_params):
-        request = context['request']
-        return request.url_for(name, **path_params)
 
-    loader = jinja2.FileSystemLoader(template_dir)
-    env = jinja2.Environment(loader=loader, autoescape=True)
-    env.globals['url_for'] = url_for
-    return env
+templates = Jinja2Templates(directory='templates')
 
-
-env = setup_jinja2('templates')
 app = Starlette(debug=True)
 app.mount('/static', StaticFiles(directory='statics'), name='static')
 
 
 @app.route('/')
 async def homepage(request):
-    template = env.get_template('index.html')
-    content = template.render(request=request)
-    return HTMLResponse(content)
+    return templates.TemplateResponse('index.html', {'request': request})
 ```
 
-This gives you the equivalent of the default `app.get_template()`, but we've
-got all the configuration explicitly out in the open now.
-
-The important parts to note from the above example are:
-
-* The StaticFiles app has been mounted with `name='static'`, meaning we can use `app.url_path_for('static', path=...)` or `request.url_for('static', path=...)`.
-* The Jinja2 environment has a global `url_for` included, which allows us to use `url_for`
-inside our templates. We always need to pass the incoming `request` instance
-in our context in order to be able to use the `url_for` function.
+The Jinja2 environment sets up a global `url_for` included, which allows us to
+use `url_for` inside our templates. We always need to pass the incoming `request`
+instance as part of the template context.
 
 We can now link to static files from within our HTML templates. For example:
 
@@ -76,6 +30,20 @@ We can now link to static files from within our HTML templates. For example:
 <link href="{{ url_for('static', path='/css/bootstrap.min.css') }}" rel="stylesheet">
 ```
 
+## Testing template responses
+
+When using the test client, template responses include `.template` and `.context`
+attributes.
+
+```python
+def test_homepage():
+    client = TestClient(app)
+    response = client.get("/")
+    assert response.status_code == 200
+    assert response.template.name == 'index.html'
+    assert "request" in response.context
+```
+
 ## Asynchronous template rendering
 
 Jinja2 supports async template rendering, however as a general rule
index 3b83a3f6e0fa885ad1af821eef9eee587f9227f2..f604cd40a35f0ff753fb4e70a8baa46cfa8a8502 100644 (file)
@@ -20,7 +20,10 @@ class Starlette:
         )
         self.lifespan_middleware = LifespanMiddleware(self.error_middleware)
         self.schema_generator = None  # type: typing.Optional[BaseSchemaGenerator]
-        self.template_env = self.load_template_env(template_directory)
+        if template_directory is not None:
+            from starlette.templating import Jinja2Templates
+
+            self.templates = Jinja2Templates(template_directory)
 
     @property
     def routes(self) -> typing.List[BaseRoute]:
@@ -36,25 +39,8 @@ class Starlette:
         self.exception_middleware.debug = value
         self.error_middleware.debug = value
 
-    def load_template_env(self, template_directory: str = None) -> typing.Any:
-        if template_directory is None:
-            return None
-
-        # Import jinja2 lazily.
-        import jinja2
-
-        @jinja2.contextfunction
-        def url_for(context: dict, name: str, **path_params: typing.Any) -> str:
-            request = context["request"]
-            return request.url_for(name, **path_params)
-
-        loader = jinja2.FileSystemLoader(str(template_directory))
-        env = jinja2.Environment(loader=loader, autoescape=True)
-        env.globals["url_for"] = url_for
-        return env
-
     def get_template(self, name: str) -> typing.Any:
-        return self.template_env.get_template(name)
+        return self.templates.get_template(name)
 
     @property
     def schema(self) -> dict:
index c9a8ac4300d6f4c61b3e9f42d3d7b7b5738d1e5b..20899b8b6efffaa15b6f32bd32a117b45c489826 100644 (file)
@@ -4,11 +4,11 @@ import typing
 import uuid
 from types import TracebackType
 
-import aiomysql
 from sqlalchemy.dialects.mysql import pymysql
 from sqlalchemy.engine.interfaces import Dialect
 from sqlalchemy.sql import ClauseElement
 
+import aiomysql
 from starlette.database.core import (
     DatabaseBackend,
     DatabaseSession,
index 3b8d2752defd85869479dd4477140d1c499d49a0..7ed28225b64140f7f52892a5e63f08ac3dc7096f 100644 (file)
@@ -138,39 +138,6 @@ class PlainTextResponse(Response):
     media_type = "text/plain"
 
 
-class TemplateResponse(Response):
-    media_type = "text/html"
-
-    def __init__(
-        self,
-        template: typing.Any,
-        context: dict,
-        status_code: int = 200,
-        headers: dict = None,
-        media_type: str = None,
-        background: BackgroundTask = None,
-    ):
-        if "request" not in context:
-            raise ValueError('context must include a "request" key')
-        self.template = template
-        self.context = context
-        content = template.render(context)
-        super().__init__(content, status_code, headers, media_type, background)
-
-    async def __call__(self, receive: Receive, send: Send) -> None:
-        request = self.context["request"]
-        extensions = request.get("extensions", {})
-        if "http.response.template" in extensions:
-            await send(
-                {
-                    "type": "http.response.template",
-                    "template": self.template,
-                    "context": self.context,
-                }
-            )
-        await super().__call__(receive, send)
-
-
 class JSONResponse(Response):
     media_type = "application/json"
 
@@ -314,3 +281,8 @@ class FileResponse(Response):
                     )
         if self.background is not None:
             await self.background()
+
+
+# For compat with earlier versions.
+# We'll drop this with the next median release.
+from starlette.templating import _TemplateResponse as TemplateResponse
diff --git a/starlette/templating.py b/starlette/templating.py
new file mode 100644 (file)
index 0000000..b584732
--- /dev/null
@@ -0,0 +1,87 @@
+import typing
+
+from starlette.background import BackgroundTask
+from starlette.responses import Response
+from starlette.types import Receive, Send
+
+try:
+    import jinja2
+except ImportError:  # pragma: nocover
+    jinja2 = None  # type: ignore
+
+
+class _TemplateResponse(Response):
+    media_type = "text/html"
+
+    def __init__(
+        self,
+        template: typing.Any,
+        context: dict,
+        status_code: int = 200,
+        headers: dict = None,
+        media_type: str = None,
+        background: BackgroundTask = None,
+    ):
+        self.template = template
+        self.context = context
+        content = template.render(context)
+        super().__init__(content, status_code, headers, media_type, background)
+
+    async def __call__(self, receive: Receive, send: Send) -> None:
+        request = self.context.get("request", {})
+        extensions = request.get("extensions", {})
+        if "http.response.template" in extensions:
+            await send(
+                {
+                    "type": "http.response.template",
+                    "template": self.template,
+                    "context": self.context,
+                }
+            )
+        await super().__call__(receive, send)
+
+
+class Jinja2Templates:
+    """
+    templates = Jinja2Templates("templates")
+
+    return templates.TemplateResponse("index.html", {"request": request})
+    """
+
+    def __init__(self, directory: str) -> None:
+        self.env = self.get_env(directory)
+
+    def get_env(self, directory: str) -> jinja2.Environment:
+        @jinja2.contextfunction
+        def url_for(context: dict, name: str, **path_params: typing.Any) -> str:
+            request = context["request"]
+            return request.url_for(name, **path_params)
+
+        loader = jinja2.FileSystemLoader(directory)
+        env = jinja2.Environment(loader=loader, autoescape=True)
+        env.globals["url_for"] = url_for
+        return env
+
+    def get_template(self, name: str) -> jinja2.Template:
+        return self.env.get_template(name)
+
+    def TemplateResponse(
+        self,
+        name: str,
+        context: dict,
+        status_code: int = 200,
+        headers: dict = None,
+        media_type: str = None,
+        background: BackgroundTask = None,
+    ) -> _TemplateResponse:
+        if "request" not in context:
+            raise ValueError('context must include a "request" key')
+        template = self.get_template(name)
+        return _TemplateResponse(
+            template,
+            context,
+            status_code=status_code,
+            headers=headers,
+            media_type=media_type,
+            background=background,
+        )
index 02106be540df06bbb22c96091a39dfb394acb100..ba1b7a1c9c065383b387b017a1faac022472c1cb 100644 (file)
@@ -1,7 +1,7 @@
-import databases
 import pytest
 import sqlalchemy
 
+import databases
 from starlette.applications import Starlette
 from starlette.database import transaction
 from starlette.datastructures import CommaSeparatedStrings, DatabaseURL
index 1c0e5a998eae9ae5ed73748c8c0f92143fd27c53..e2201425faee2081affc6b073ac800a296fdc724 100644 (file)
@@ -246,37 +246,6 @@ def test_delete_cookie():
     assert not response.cookies.get("mycookie")
 
 
-def test_template_response():
-    def app(scope):
-        request = Request(scope)
-
-        class Template:
-            def __init__(self, name):
-                self.name = name
-
-            def render(self, context):
-                return f"username: {context['username']}"
-
-        async def asgi(receive, send):
-            template = Template("index.html")
-            context = {"username": "tomchristie", "request": request}
-            response = TemplateResponse(template, context)
-            await response(receive, send)
-
-        return asgi
-
-    client = TestClient(app)
-    response = client.get("/")
-    assert response.text == "username: tomchristie"
-    assert response.template.name == "index.html"
-    assert response.context["username"] == "tomchristie"
-
-
-def test_template_response_requires_request():
-    with pytest.raises(ValueError):
-        TemplateResponse(None, {})
-
-
 def test_populate_headers():
     def app(scope):
         headers = {}
index bc213a0b432cece701df25cfcec1ac2e3fc7996c..f8eb68424b6ee95920f0bb9cb35bbbc31929d7c4 100644 (file)
@@ -1,7 +1,9 @@
 import os
+import pytest
 
 from starlette.applications import Starlette
 from starlette.responses import HTMLResponse
+from starlette.templating import Jinja2Templates
 from starlette.testclient import TestClient
 
 
@@ -10,7 +12,26 @@ def test_templates(tmpdir):
     with open(path, "w") as file:
         file.write("<html>Hello, <a href='{{ url_for('homepage') }}'>world</a></html>")
 
-    app = Starlette(debug=True, template_directory=tmpdir)
+    app = Starlette(debug=True)
+    templates = Jinja2Templates(directory=str(tmpdir))
+
+    @app.route("/")
+    async def homepage(request):
+        return templates.TemplateResponse("index.html", {"request": request})
+
+    client = TestClient(app)
+    response = client.get("/")
+    assert response.text == "<html>Hello, <a href='http://testserver/'>world</a></html>"
+    assert response.template.name == "index.html"
+    assert set(response.context.keys()) == {"request"}
+
+
+def test_templates_legacy(tmpdir):
+    path = os.path.join(tmpdir, "index.html")
+    with open(path, "w") as file:
+        file.write("<html>Hello, <a href='{{ url_for('homepage') }}'>world</a></html>")
+
+    app = Starlette(debug=True, template_directory=str(tmpdir))
 
     @app.route("/")
     async def homepage(request):
@@ -21,3 +42,9 @@ def test_templates(tmpdir):
     client = TestClient(app)
     response = client.get("/")
     assert response.text == "<html>Hello, <a href='http://testserver/'>world</a></html>"
+
+
+def test_template_response_requires_request(tmpdir):
+    templates = Jinja2Templates(str(tmpdir))
+    with pytest.raises(ValueError):
+        templates.TemplateResponse(None, {})