From: Tom Christie Date: Mon, 18 Feb 2019 13:12:55 +0000 (+0000) Subject: Add Jinja2Templates component X-Git-Tag: 0.10.4^2~4 X-Git-Url: http://git.ipfire.org/?a=commitdiff_plain;h=37ee43e;p=thirdparty%2Fstarlette.git Add Jinja2Templates component --- diff --git a/docs/responses.md b/docs/responses.md index 60f89b72..595c9101 100644 --- a/docs/responses.md +++ b/docs/responses.md @@ -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. diff --git a/docs/templates.md b/docs/templates.md index f4e59c44..faabdec6 100644 --- a/docs/templates.md +++ b/docs/templates.md @@ -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: ``` +## 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 diff --git a/starlette/applications.py b/starlette/applications.py index 3b83a3f6..f604cd40 100644 --- a/starlette/applications.py +++ b/starlette/applications.py @@ -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: diff --git a/starlette/database/mysql.py b/starlette/database/mysql.py index c9a8ac43..20899b8b 100644 --- a/starlette/database/mysql.py +++ b/starlette/database/mysql.py @@ -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, diff --git a/starlette/responses.py b/starlette/responses.py index 3b8d2752..7ed28225 100644 --- a/starlette/responses.py +++ b/starlette/responses.py @@ -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 index 00000000..b584732b --- /dev/null +++ b/starlette/templating.py @@ -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, + ) diff --git a/tests/test_database.py b/tests/test_database.py index 02106be5..ba1b7a1c 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -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 diff --git a/tests/test_responses.py b/tests/test_responses.py index 1c0e5a99..e2201425 100644 --- a/tests/test_responses.py +++ b/tests/test_responses.py @@ -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 = {} diff --git a/tests/test_templates.py b/tests/test_templates.py index bc213a0b..f8eb6842 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -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("Hello, world") - 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 == "Hello, world" + 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("Hello, world") + + 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 == "Hello, world" + + +def test_template_response_requires_request(tmpdir): + templates = Jinja2Templates(str(tmpdir)) + with pytest.raises(ValueError): + templates.TemplateResponse(None, {})