Compare commits

...

6 Commits

Author SHA1 Message Date
Adam Hopkins
df4970a73d Merge branch '19.12LTS' of github.com:huge-success/sanic into 19.12LTS 2020-10-25 14:32:42 +02:00
Adam Hopkins
c5070bd449
Backport stream header fix (#1959)
Resolve headers as body in ASGI mode

* Bump version to 19.12.3

* Update multidict==5.0.0
2020-10-25 14:32:18 +02:00
Adam Hopkins
eb3d0a3f87 squash 2020-10-25 10:45:22 +02:00
Adam Hopkins
c09129ec63 Resolve headers as body in ASGI mode 2020-10-25 10:40:08 +02:00
Adam Hopkins
2a44a27236
Backport to 1912 (#1900)
* Cherry pick PRs to backport to 19.12LTS

Includes commits from:
https://github.com/huge-success/sanic/pull/1762
https://github.com/huge-success/sanic/pull/1764
https://github.com/huge-success/sanic/pull/1789

* Fix type annotation issue; run black and isort

* Update Makefile

Co-authored-by: Ashley Sommer <ashleysommer@gmail.com>
2020-07-29 13:54:33 +03:00
Adam Hopkins
bb9ff7cec1 Set version
Set version
2020-01-02 23:34:02 +02:00
15 changed files with 170 additions and 86 deletions

View File

@ -71,7 +71,7 @@ black:
black --config ./.black.toml sanic tests
fix-import: black
isort -rc sanic tests
isort sanic tests
docs-clean:

View File

@ -1 +1 @@
__version__ = "19.12.0"
__version__ = "19.12.3"

View File

@ -194,6 +194,12 @@ class Sanic:
strict_slashes = self.strict_slashes
def response(handler):
if isinstance(handler, tuple):
# if a handler fn is already wrapped in a route, the handler
# variable will be a tuple of (existing routes, handler fn)
routes, handler = handler
else:
routes = []
args = list(signature(handler).parameters.keys())
if not args:
@ -205,7 +211,8 @@ class Sanic:
if stream:
handler.is_stream = stream
routes = self.router.add(
routes.extend(
self.router.add(
uri=uri,
methods=methods,
handler=handler,
@ -214,6 +221,7 @@ class Sanic:
version=version,
name=name,
)
)
return routes, handler
return response
@ -476,6 +484,13 @@ class Sanic:
strict_slashes = self.strict_slashes
def response(handler):
if isinstance(handler, tuple):
# if a handler fn is already wrapped in a route, the handler
# variable will be a tuple of (existing routes, handler fn)
routes, handler = handler
else:
routes = []
async def websocket_handler(request, *args, **kwargs):
request.app = self
if not getattr(handler, "__blueprintname__", False):
@ -516,7 +531,8 @@ class Sanic:
self.websocket_tasks.remove(fut)
await ws.close()
routes = self.router.add(
routes.extend(
self.router.add(
uri=uri,
handler=websocket_handler,
methods=frozenset({"GET"}),
@ -524,6 +540,7 @@ class Sanic:
strict_slashes=strict_slashes,
name=name,
)
)
return routes, handler
return response
@ -813,6 +830,14 @@ class Sanic:
"Endpoint with name `{}` was not found".format(view_name)
)
# If the route has host defined, split that off
# TODO: Retain netloc and path separately in Route objects
host = uri.find("/")
if host > 0:
host, uri = uri[:host], uri[host:]
else:
host = None
if view_name == "static" or view_name.endswith(".static"):
filename = kwargs.pop("filename", None)
# it's static folder
@ -845,7 +870,7 @@ class Sanic:
netloc = kwargs.pop("_server", None)
if netloc is None and external:
netloc = self.config.get("SERVER_NAME", "")
netloc = host or self.config.get("SERVER_NAME", "")
if external:
if not scheme:

View File

@ -1,6 +1,5 @@
import asyncio
import warnings
from inspect import isawaitable
from typing import (
Any,
@ -16,7 +15,6 @@ from typing import (
from urllib.parse import quote
import sanic.app # noqa
from sanic.compat import Header
from sanic.exceptions import InvalidUsage, ServerError
from sanic.log import logger
@ -25,7 +23,6 @@ from sanic.response import HTTPResponse, StreamingHTTPResponse
from sanic.server import StreamBuffer
from sanic.websocket import WebSocketConnection
ASGIScope = MutableMapping[str, Any]
ASGIMessage = MutableMapping[str, Any]
ASGISend = Callable[[ASGIMessage], Awaitable[None]]
@ -68,9 +65,7 @@ class MockProtocol:
class MockTransport:
_protocol: Optional[MockProtocol]
def __init__(
self, scope: ASGIScope, receive: ASGIReceive, send: ASGISend
) -> None:
def __init__(self, scope: ASGIScope, receive: ASGIReceive, send: ASGISend) -> None:
self.scope = scope
self._receive = receive
self._send = send
@ -146,9 +141,7 @@ class Lifespan:
) + self.asgi_app.sanic_app.listeners.get("after_server_start", [])
for handler in listeners:
response = handler(
self.asgi_app.sanic_app, self.asgi_app.sanic_app.loop
)
response = handler(self.asgi_app.sanic_app, self.asgi_app.sanic_app.loop)
if isawaitable(response):
await response
@ -166,9 +159,7 @@ class Lifespan:
) + self.asgi_app.sanic_app.listeners.get("after_server_stop", [])
for handler in listeners:
response = handler(
self.asgi_app.sanic_app, self.asgi_app.sanic_app.loop
)
response = handler(self.asgi_app.sanic_app, self.asgi_app.sanic_app.loop)
if isawaitable(response):
await response
@ -213,19 +204,13 @@ class ASGIApp:
for key, value in scope.get("headers", [])
]
)
instance.do_stream = (
True if headers.get("expect") == "100-continue" else False
)
instance.do_stream = True if headers.get("expect") == "100-continue" else False
instance.lifespan = Lifespan(instance)
if scope["type"] == "lifespan":
await instance.lifespan(scope, receive, send)
else:
path = (
scope["path"][1:]
if scope["path"].startswith("/")
else scope["path"]
)
path = scope["path"][1:] if scope["path"].startswith("/") else scope["path"]
url = "/".join([scope.get("root_path", ""), quote(path)])
url_bytes = url.encode("latin-1")
url_bytes += b"?" + scope["query_string"]
@ -248,18 +233,11 @@ class ASGIApp:
request_class = sanic_app.request_class or Request
instance.request = request_class(
url_bytes,
headers,
version,
method,
instance.transport,
sanic_app,
url_bytes, headers, version, method, instance.transport, sanic_app,
)
if sanic_app.is_request_stream:
is_stream_handler = sanic_app.router.is_stream_handler(
instance.request
)
is_stream_handler = sanic_app.router.is_stream_handler(instance.request)
if is_stream_handler:
instance.request.stream = StreamBuffer(
sanic_app.config.REQUEST_BUFFER_QUEUE_SIZE
@ -338,26 +316,22 @@ class ASGIApp:
type(response),
)
exception = ServerError("Invalid response type")
response = self.sanic_app.error_handler.response(
self.request, exception
)
response = self.sanic_app.error_handler.response(self.request, exception)
headers = [
(str(name).encode("latin-1"), str(value).encode("latin-1"))
for name, value in response.headers.items()
if name not in (b"Set-Cookie",)
]
response.asgi = True
if "content-length" not in response.headers and not isinstance(
response, StreamingHTTPResponse
):
headers += [
(b"content-length", str(len(response.body)).encode("latin-1"))
]
headers += [(b"content-length", str(len(response.body)).encode("latin-1"))]
if "content-type" not in response.headers:
headers += [
(b"content-type", str(response.content_type).encode("latin-1"))
]
headers += [(b"content-type", str(response.content_type).encode("latin-1"))]
if response.cookies:
cookies.update(
@ -369,8 +343,7 @@ class ASGIApp:
)
headers += [
(b"set-cookie", cookie.encode("utf-8"))
for k, cookie in cookies.items()
(b"set-cookie", cookie.encode("utf-8")) for k, cookie in cookies.items()
]
await self.transport.send(

View File

@ -22,6 +22,9 @@ except ImportError:
class BaseHTTPResponse:
def __init__(self):
self.asgi = False
def _encode_body(self, data):
try:
# Try to encode it regularly
@ -59,12 +62,15 @@ class StreamingHTTPResponse(BaseHTTPResponse):
content_type="text/plain",
chunked=True,
):
super().__init__()
self.content_type = content_type
self.streaming_fn = streaming_fn
self.status = status
self.headers = Header(headers or {})
self.chunked = chunked
self._cookies = None
self.protocol = None
async def write(self, data):
"""Writes a chunk of data to the streaming response.
@ -93,6 +99,7 @@ class StreamingHTTPResponse(BaseHTTPResponse):
keep_alive=keep_alive,
keep_alive_timeout=keep_alive_timeout,
)
if not getattr(self, "asgi", False):
await self.protocol.push_data(headers)
await self.protocol.drain()
await self.streaming_fn(self)
@ -144,6 +151,8 @@ class HTTPResponse(BaseHTTPResponse):
content_type=None,
body_bytes=b"",
):
super().__init__()
self.content_type = content_type
if body is not None:
@ -202,16 +211,14 @@ class HTTPResponse(BaseHTTPResponse):
return self._cookies
def empty(
status=204, headers=None,
):
def empty(status=204, headers=None):
"""
Returns an empty response to the client.
:param status Response code.
:param headers Custom Headers.
"""
return HTTPResponse(body_bytes=b"", status=status, headers=headers,)
return HTTPResponse(body_bytes=b"", status=status, headers=headers)
def json(

View File

@ -731,6 +731,26 @@ class AsyncioServer:
task = asyncio.ensure_future(coro, loop=self.loop)
return task
def start_serving(self):
if self.server:
try:
return self.server.start_serving()
except AttributeError:
raise NotImplementedError(
"server.start_serving not available in this version "
"of asyncio or uvloop."
)
def serve_forever(self):
if self.server:
try:
return self.server.serve_forever()
except AttributeError:
raise NotImplementedError(
"server.serve_forever not available in this version "
"of asyncio or uvloop."
)
def __await__(self):
"""Starts the asyncio server, returns AsyncServerCoro"""
task = asyncio.ensure_future(self.serve_coro)

View File

@ -5,7 +5,6 @@ import codecs
import os
import re
import sys
from distutils.util import strtobool
from setuptools import setup
@ -39,9 +38,7 @@ def open_local(paths, mode="r", encoding="utf8"):
with open_local(["sanic", "__version__.py"], encoding="latin1") as fp:
try:
version = re.findall(
r"^__version__ = \"([^']+)\"\r?$", fp.read(), re.M
)[0]
version = re.findall(r"^__version__ = \"([^']+)\"\r?$", fp.read(), re.M)[0]
except IndexError:
raise RuntimeError("Unable to determine version.")
@ -71,9 +68,7 @@ setup_kwargs = {
],
}
env_dependency = (
'; sys_platform != "win32" ' 'and implementation_name == "cpython"'
)
env_dependency = '; sys_platform != "win32" ' 'and implementation_name == "cpython"'
ujson = "ujson>=1.35" + env_dependency
uvloop = "uvloop>=0.5.3" + env_dependency
@ -83,13 +78,13 @@ requirements = [
ujson,
"aiofiles>=0.3.0",
"websockets>=7.0,<9.0",
"multidict>=4.0,<5.0",
"multidict==5.0.0",
"httpx==0.9.3",
]
tests_require = [
"pytest==5.2.1",
"multidict>=4.0,<5.0",
"multidict==5.0.0",
"gunicorn",
"pytest-cov",
"httpcore==0.3.0",

View File

@ -39,6 +39,7 @@ main = WSGIApplication(
if __name__ == "__main__":
import sys
from wsgiref.simple_server import make_server
try:

View File

@ -41,6 +41,20 @@ def test_create_asyncio_server(app):
assert srv.is_serving() is True
@pytest.mark.skipif(
sys.version_info < (3, 7), reason="requires python3.7 or higher"
)
def test_asyncio_server_no_start_serving(app):
if not uvloop_installed():
loop = asyncio.get_event_loop()
asyncio_srv_coro = app.create_server(
return_asyncio_server=True,
asyncio_server_kwargs=dict(start_serving=False),
)
srv = loop.run_until_complete(asyncio_srv_coro)
assert srv.is_serving() is False
@pytest.mark.skipif(
sys.version_info < (3, 7), reason="requires python3.7 or higher"
)
@ -53,6 +67,10 @@ def test_asyncio_server_start_serving(app):
)
srv = loop.run_until_complete(asyncio_srv_coro)
assert srv.is_serving() is False
loop.run_until_complete(srv.start_serving())
assert srv.is_serving() is True
srv.close()
# Looks like we can't easily test `serve_forever()`
def test_app_loop_not_running(app):

View File

@ -15,13 +15,13 @@ from aiofiles import os as async_os
from sanic.response import (
HTTPResponse,
StreamingHTTPResponse,
empty,
file,
file_stream,
json,
raw,
stream,
)
from sanic.response import empty
from sanic.server import HttpProtocol
from sanic.testing import HOST, PORT
@ -232,6 +232,12 @@ def test_chunked_streaming_returns_correct_content(streaming_app):
assert response.text == "foo,bar"
@pytest.mark.asyncio
async def test_chunked_streaming_returns_correct_content_asgi(streaming_app):
request, response = await streaming_app.asgi_client.get("/")
assert response.text == "4\r\nfoo,\r\n3\r\nbar\r\n0\r\n\r\n"
def test_non_chunked_streaming_adds_correct_headers(non_chunked_streaming_app):
request, response = non_chunked_streaming_app.test_client.get("/")
assert "Transfer-Encoding" not in response.headers
@ -239,8 +245,18 @@ def test_non_chunked_streaming_adds_correct_headers(non_chunked_streaming_app):
assert response.headers["Content-Length"] == "7"
@pytest.mark.asyncio
async def test_non_chunked_streaming_adds_correct_headers_asgi(
non_chunked_streaming_app,
):
request, response = await non_chunked_streaming_app.asgi_client.get("/")
assert "Transfer-Encoding" not in response.headers
assert response.headers["Content-Type"] == "text/csv"
assert response.headers["Content-Length"] == "7"
def test_non_chunked_streaming_returns_correct_content(
non_chunked_streaming_app
non_chunked_streaming_app,
):
request, response = non_chunked_streaming_app.test_client.get("/")
assert response.text == "foo,bar"
@ -255,7 +271,7 @@ def test_stream_response_status_returns_correct_headers(status):
@pytest.mark.parametrize("keep_alive_timeout", [10, 20, 30])
def test_stream_response_keep_alive_returns_correct_headers(
keep_alive_timeout
keep_alive_timeout,
):
response = StreamingHTTPResponse(sample_streaming_fn)
headers = response.get_headers(
@ -284,7 +300,7 @@ def test_stream_response_does_not_include_chunked_header_if_disabled():
def test_stream_response_writes_correct_content_to_transport_when_chunked(
streaming_app
streaming_app,
):
response = StreamingHTTPResponse(sample_streaming_fn)
response.protocol = MagicMock(HttpProtocol)

View File

@ -551,6 +551,35 @@ def test_route_duplicate(app):
pass
def test_double_stack_route(app):
@app.route("/test/1")
@app.route("/test/2")
async def handler1(request):
return text("OK")
request, response = app.test_client.get("/test/1")
assert response.status == 200
request, response = app.test_client.get("/test/2")
assert response.status == 200
@pytest.mark.asyncio
async def test_websocket_route_asgi(app):
ev = asyncio.Event()
@app.websocket("/test/1")
@app.websocket("/test/2")
async def handler(request, ws):
ev.set()
request, response = await app.asgi_client.websocket("/test/1")
first_set = ev.is_set()
ev.clear()
request, response = await app.asgi_client.websocket("/test/1")
second_set = ev.is_set()
assert first_set and second_set
def test_method_not_allowed(app):
@app.route("/test", methods=["GET"])
async def handler(request):