Merge remote-tracking branch 'upstream/master' into bodybytes

This commit is contained in:
L. Kärkkäinen 2020-01-15 10:53:48 +02:00
commit 7704814019
34 changed files with 441 additions and 416 deletions

1
.github/stale.yml vendored
View File

@ -8,6 +8,7 @@ exemptLabels:
- urgent - urgent
- necessary - necessary
- help wanted - help wanted
- RFC
# Label to use when marking an issue as stale # Label to use when marking an issue as stale
staleLabel: stale staleLabel: stale
# Comment to post when marking an issue as stale. Set to `false` to disable # Comment to post when marking an issue as stale. Set to `false` to disable

View File

@ -43,6 +43,19 @@ matrix:
dist: xenial dist: xenial
sudo: true sudo: true
name: "Python 3.7 Documentation tests" name: "Python 3.7 Documentation tests"
- env: TOX_ENV=pyNightly
python: 'nightly'
name: "Python nightly with Extensions"
- env: TOX_ENV=pyNightly-no-ext
python: 'nightly'
name: "Python nightly Extensions"
allow_failures:
- env: TOX_ENV=pyNightly
python: 'nightly'
name: "Python nightly with Extensions"
- env: TOX_ENV=pyNightly-no-ext
python: 'nightly'
name: "Python nightly Extensions"
install: install:
- pip install -U tox - pip install -U tox
- pip install codecov - pip install codecov

View File

@ -1,3 +1,38 @@
Version 19.12.0
===============
Bugfixes
********
- Fix blueprint middleware application
Currently, any blueprint middleware registered, irrespective of which blueprint was used to do so, was
being applied to all of the routes created by the :code:`@app` and :code:`@blueprint` alike.
As part of this change, the blueprint based middleware application is enforced based on where they are
registered.
- If you register a middleware via :code:`@blueprint.middleware` then it will apply only to the routes defined by the blueprint.
- If you register a middleware via :code:`@blueprint_group.middleware` then it will apply to all blueprint based routes that are part of the group.
- If you define a middleware via :code:`@app.middleware` then it will be applied on all available routes (`#37 <https://github.com/huge-success/sanic/issues/37>`__)
- Fix `url_for` behavior with missing SERVER_NAME
If the `SERVER_NAME` was missing in the `app.config` entity, the `url_for` on the `request` and `app` were failing
due to an `AttributeError`. This fix makes the availability of `SERVER_NAME` on our `app.config` an optional behavior. (`#1707 <https://github.com/huge-success/sanic/issues/1707>`__)
Improved Documentation
**********************
- Move docs from RST to MD
Moved all docs from markdown to restructured text like the rest of the docs to unify the scheme and make it easier in
the future to update documentation. (`#1691 <https://github.com/huge-success/sanic/issues/1691>`__)
- Fix documentation for `get` and `getlist` of the `request.args`
Add additional example for showing the usage of `getlist` and fix the documentation string for `request.args` behavior (`#1704 <https://github.com/huge-success/sanic/issues/1704>`__)
Version 19.6.3 Version 19.6.3
============== ==============

View File

@ -1,4 +0,0 @@
Move docs from RST to MD
Moved all docs from markdown to restructured text like the rest of the docs to unify the scheme and make it easier in
the future to update documentation.

View File

@ -1,3 +0,0 @@
Fix documentation for `get` and `getlist` of the `request.args`
Add additional example for showing the usage of `getlist` and fix the documentation string for `request.args` behavior

View File

@ -1,4 +0,0 @@
Fix `url_for` behavior with missing SERVER_NAME
If the `SERVER_NAME` was missing in the `app.config` entity, the `url_for` on the `request` and `app` were failing
due to an `AttributeError`. This fix makes the availability of `SERVER_NAME` on our `app.config` an optional behavior.

View File

@ -107,6 +107,19 @@ Response without encoding the body
def handle_request(request): def handle_request(request):
return response.raw(b'raw data') return response.raw(b'raw data')
Empty
--------------
For responding with an empty message as defined by `RFC 2616 <https://tools.ietf.org/search/rfc2616#section-7.2.1>`_
.. code-block:: python
from sanic import response
@app.route('/empty')
async def handle_request(request):
return response.empty()
Modify headers or status Modify headers or status
------------------------ ------------------------

View File

@ -2,7 +2,7 @@ Testing
======= =======
Sanic endpoints can be tested locally using the `test_client` object, which Sanic endpoints can be tested locally using the `test_client` object, which
depends on the additional `requests-async <https://github.com/encode/requests-async>`_ depends on an additional package: `httpx <https://www.encode.io/httpx/>`_
library, which implements an API that mirrors the `requests` library. library, which implements an API that mirrors the `requests` library.
The `test_client` exposes `get`, `post`, `put`, `delete`, `patch`, `head` and `options` methods The `test_client` exposes `get`, `post`, `put`, `delete`, `patch`, `head` and `options` methods
@ -22,7 +22,7 @@ for you to run against your application. A simple example (using pytest) is like
assert response.status == 405 assert response.status == 405
Internally, each time you call one of the `test_client` methods, the Sanic app is run at `127.0.0.1:42101` and Internally, each time you call one of the `test_client` methods, the Sanic app is run at `127.0.0.1:42101` and
your test request is executed against your application, using `requests-async`. your test request is executed against your application, using `httpx`.
The `test_client` methods accept the following arguments and keyword arguments: The `test_client` methods accept the following arguments and keyword arguments:
@ -55,8 +55,8 @@ And to supply data to a JSON POST request:
assert request.json.get('key1') == 'value1' assert request.json.get('key1') == 'value1'
More information about More information about
the available arguments to `requests-async` can be found the available arguments to `httpx` can be found
[in the documentation for `requests <https://2.python-requests.org/en/master/>`_. [in the documentation for `httpx <https://www.encode.io/httpx/>`_.
Using a random port Using a random port

View File

@ -13,7 +13,7 @@ dependencies:
- sphinx==1.8.3 - sphinx==1.8.3
- sphinx_rtd_theme==0.4.2 - sphinx_rtd_theme==0.4.2
- recommonmark==0.5.0 - recommonmark==0.5.0
- requests-async==0.5.0 - httpx==0.9.3
- sphinxcontrib-asyncio>=0.2.0 - sphinxcontrib-asyncio>=0.2.0
- docutils==0.14 - docutils==0.14
- pygments==2.3.1 - pygments==2.3.1

View File

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

View File

@ -85,7 +85,8 @@ class Sanic:
self.is_request_stream = False self.is_request_stream = False
self.websocket_enabled = False self.websocket_enabled = False
self.websocket_tasks = set() self.websocket_tasks = set()
self.named_request_middleware = {}
self.named_response_middleware = {}
# Register alternative method names # Register alternative method names
self.go_fast = self.run self.go_fast = self.run
@ -178,7 +179,7 @@ class Sanic:
:param stream: :param stream:
:param version: :param version:
:param name: user defined route name for url_for :param name: user defined route name for url_for
:return: decorated function :return: tuple of routes, decorated function
""" """
# Fix case where the user did not prefix the URL with a / # Fix case where the user did not prefix the URL with a /
@ -193,6 +194,12 @@ class Sanic:
strict_slashes = self.strict_slashes strict_slashes = self.strict_slashes
def response(handler): 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()) args = list(signature(handler).parameters.keys())
if not args: if not args:
@ -204,6 +211,7 @@ class Sanic:
if stream: if stream:
handler.is_stream = stream handler.is_stream = stream
routes.extend(
self.router.add( self.router.add(
uri=uri, uri=uri,
methods=methods, methods=methods,
@ -213,7 +221,8 @@ class Sanic:
version=version, version=version,
name=name, name=name,
) )
return handler )
return routes, handler
return response return response
@ -462,7 +471,7 @@ class Sanic:
:param subprotocols: optional list of str with supported subprotocols :param subprotocols: optional list of str with supported subprotocols
:param name: A unique name assigned to the URL so that it can :param name: A unique name assigned to the URL so that it can
be used with :func:`url_for` be used with :func:`url_for`
:return: decorated function :return: tuple of routes, decorated function
""" """
self.enable_websocket() self.enable_websocket()
@ -475,6 +484,13 @@ class Sanic:
strict_slashes = self.strict_slashes strict_slashes = self.strict_slashes
def response(handler): 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): async def websocket_handler(request, *args, **kwargs):
request.app = self request.app = self
if not getattr(handler, "__blueprintname__", False): if not getattr(handler, "__blueprintname__", False):
@ -515,6 +531,7 @@ class Sanic:
self.websocket_tasks.remove(fut) self.websocket_tasks.remove(fut)
await ws.close() await ws.close()
routes.extend(
self.router.add( self.router.add(
uri=uri, uri=uri,
handler=websocket_handler, handler=websocket_handler,
@ -523,7 +540,8 @@ class Sanic:
strict_slashes=strict_slashes, strict_slashes=strict_slashes,
name=name, name=name,
) )
return handler )
return routes, handler
return response return response
@ -544,6 +562,7 @@ class Sanic:
:param host: Host IP or FQDN details :param host: Host IP or FQDN details
:param uri: URL path that will be mapped to the websocket :param uri: URL path that will be mapped to the websocket
handler handler
handler
:param strict_slashes: If the API endpoint needs to terminate :param strict_slashes: If the API endpoint needs to terminate
with a "/" or not with a "/" or not
:param subprotocols: Subprotocols to be used with websocket :param subprotocols: Subprotocols to be used with websocket
@ -645,6 +664,22 @@ class Sanic:
self.response_middleware.appendleft(middleware) self.response_middleware.appendleft(middleware)
return middleware return middleware
def register_named_middleware(
self, middleware, route_names, attach_to="request"
):
if attach_to == "request":
for _rn in route_names:
if _rn not in self.named_request_middleware:
self.named_request_middleware[_rn] = deque()
if middleware not in self.named_request_middleware[_rn]:
self.named_request_middleware[_rn].append(middleware)
if attach_to == "response":
for _rn in route_names:
if _rn not in self.named_response_middleware:
self.named_response_middleware[_rn] = deque()
if middleware not in self.named_response_middleware[_rn]:
self.named_response_middleware[_rn].append(middleware)
# Decorator # Decorator
def middleware(self, middleware_or_request): def middleware(self, middleware_or_request):
""" """
@ -916,20 +951,23 @@ class Sanic:
# allocation before assignment below. # allocation before assignment below.
response = None response = None
cancelled = False cancelled = False
name = None
try: try:
# Fetch handler from router
handler, args, kwargs, uri, name = self.router.get(request)
# -------------------------------------------- # # -------------------------------------------- #
# Request Middleware # Request Middleware
# -------------------------------------------- # # -------------------------------------------- #
response = await self._run_request_middleware(request) response = await self._run_request_middleware(
request, request_name=name
)
# No middleware results # No middleware results
if not response: if not response:
# -------------------------------------------- # # -------------------------------------------- #
# Execute Handler # Execute Handler
# -------------------------------------------- # # -------------------------------------------- #
# Fetch handler from router
handler, args, kwargs, uri = self.router.get(request)
request.uri_template = uri request.uri_template = uri
if handler is None: if handler is None:
raise ServerError( raise ServerError(
@ -993,7 +1031,7 @@ class Sanic:
if response is not None: if response is not None:
try: try:
response = await self._run_response_middleware( response = await self._run_response_middleware(
request, response request, response, request_name=name
) )
except CancelledError: except CancelledError:
# Response middleware can timeout too, as above. # Response middleware can timeout too, as above.
@ -1265,10 +1303,14 @@ class Sanic:
if isawaitable(result): if isawaitable(result):
await result await result
async def _run_request_middleware(self, request): async def _run_request_middleware(self, request, request_name=None):
# The if improves speed. I don't know why # The if improves speed. I don't know why
if self.request_middleware: named_middleware = self.named_request_middleware.get(
for middleware in self.request_middleware: request_name, deque()
)
applicable_middleware = self.request_middleware + named_middleware
if applicable_middleware:
for middleware in applicable_middleware:
response = middleware(request) response = middleware(request)
if isawaitable(response): if isawaitable(response):
response = await response response = await response
@ -1276,9 +1318,15 @@ class Sanic:
return response return response
return None return None
async def _run_response_middleware(self, request, response): async def _run_response_middleware(
if self.response_middleware: self, request, response, request_name=None
for middleware in self.response_middleware: ):
named_middleware = self.named_response_middleware.get(
request_name, deque()
)
applicable_middleware = self.response_middleware + named_middleware
if applicable_middleware:
for middleware in applicable_middleware:
_response = middleware(request, response) _response = middleware(request, response)
if isawaitable(_response): if isawaitable(_response):
_response = await _response _response = await _response

View File

@ -15,8 +15,6 @@ from typing import (
) )
from urllib.parse import quote from urllib.parse import quote
from requests_async import ASGISession # type: ignore
import sanic.app # noqa import sanic.app # noqa
from sanic.compat import Header from sanic.compat import Header
@ -189,7 +187,7 @@ class Lifespan:
class ASGIApp: class ASGIApp:
sanic_app: Union[ASGISession, "sanic.app.Sanic"] sanic_app: "sanic.app.Sanic"
request: Request request: Request
transport: MockTransport transport: MockTransport
do_stream: bool do_stream: bool
@ -223,8 +221,13 @@ class ASGIApp:
if scope["type"] == "lifespan": if scope["type"] == "lifespan":
await instance.lifespan(scope, receive, send) await instance.lifespan(scope, receive, send)
else: else:
url_bytes = scope.get("root_path", "") + quote(scope["path"]) path = (
url_bytes = url_bytes.encode("latin-1") 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"] url_bytes += b"?" + scope["query_string"]
if scope["type"] == "http": if scope["type"] == "http":

View File

@ -104,6 +104,8 @@ class Blueprint:
url_prefix = options.get("url_prefix", self.url_prefix) url_prefix = options.get("url_prefix", self.url_prefix)
routes = []
# Routes # Routes
for future in self.routes: for future in self.routes:
# attach the blueprint name to the handler so that it can be # attach the blueprint name to the handler so that it can be
@ -114,7 +116,7 @@ class Blueprint:
version = future.version or self.version version = future.version or self.version
app.route( _routes, _ = app.route(
uri=uri[1:] if uri.startswith("//") else uri, uri=uri[1:] if uri.startswith("//") else uri,
methods=future.methods, methods=future.methods,
host=future.host or self.host, host=future.host or self.host,
@ -123,6 +125,8 @@ class Blueprint:
version=version, version=version,
name=future.name, name=future.name,
)(future.handler) )(future.handler)
if _routes:
routes += _routes
for future in self.websocket_routes: for future in self.websocket_routes:
# attach the blueprint name to the handler so that it can be # attach the blueprint name to the handler so that it can be
@ -130,21 +134,27 @@ class Blueprint:
future.handler.__blueprintname__ = self.name future.handler.__blueprintname__ = self.name
# Prepend the blueprint URI prefix if available # Prepend the blueprint URI prefix if available
uri = url_prefix + future.uri if url_prefix else future.uri uri = url_prefix + future.uri if url_prefix else future.uri
app.websocket( _routes, _ = app.websocket(
uri=uri, uri=uri,
host=future.host or self.host, host=future.host or self.host,
strict_slashes=future.strict_slashes, strict_slashes=future.strict_slashes,
name=future.name, name=future.name,
)(future.handler) )(future.handler)
if _routes:
routes += _routes
route_names = [route.name for route in routes]
# Middleware # Middleware
for future in self.middlewares: for future in self.middlewares:
if future.args or future.kwargs: if future.args or future.kwargs:
app.register_middleware( app.register_named_middleware(
future.middleware, *future.args, **future.kwargs future.middleware,
route_names,
*future.args,
**future.kwargs
) )
else: else:
app.register_middleware(future.middleware) app.register_named_middleware(future.middleware, route_names)
# Exceptions # Exceptions
for future in self.exceptions: for future in self.exceptions:

View File

@ -5,7 +5,6 @@ from urllib.parse import unquote
from sanic.helpers import STATUS_CODES from sanic.helpers import STATUS_CODES
HeaderIterable = Iterable[Tuple[str, Any]] # Values convertible to str HeaderIterable = Iterable[Tuple[str, Any]] # Values convertible to str
Options = Dict[str, Union[int, str]] # key=value fields in various headers Options = Dict[str, Union[int, str]] # key=value fields in various headers
OptionsIterable = Iterable[Tuple[str, str]] # May contain duplicate keys OptionsIterable = Iterable[Tuple[str, str]] # May contain duplicate keys
@ -192,8 +191,6 @@ def format_http1_response(
- If `body` is included, content-length must be specified in headers. - If `body` is included, content-length must be specified in headers.
""" """
headerbytes = format_http1(headers) headerbytes = format_http1(headers)
if status == 200:
return b"HTTP/1.1 200 OK\r\n%b\r\n%b" % (headerbytes, body)
return b"HTTP/1.1 %d %b\r\n%b\r\n%b" % ( return b"HTTP/1.1 %d %b\r\n%b\r\n%b" % (
status, status,
STATUS_CODES.get(status, b"UNKNOWN"), STATUS_CODES.get(status, b"UNKNOWN"),

View File

@ -98,7 +98,10 @@ class StreamingHTTPResponse(BaseHTTPResponse):
def get_headers( def get_headers(
self, version="1.1", keep_alive=False, keep_alive_timeout=None self, version="1.1", keep_alive=False, keep_alive_timeout=None
): ):
if "Content-Type" not in self.headers: assert version == "1.1", "No other versions are currently supported"
# self.headers get priority over content_type
if self.content_type and "Content-Type" not in self.headers:
self.headers["Content-Type"] = self.content_type self.headers["Content-Type"] = self.content_type
if keep_alive and keep_alive_timeout is not None: if keep_alive and keep_alive_timeout is not None:
@ -119,7 +122,7 @@ class HTTPResponse(BaseHTTPResponse):
body=None, body=None,
status=200, status=200,
headers=None, headers=None,
content_type="text/plain; charset=utf-8", content_type=None,
body_bytes=b"", body_bytes=b"",
): ):
self.content_type = content_type self.content_type = content_type
@ -129,9 +132,7 @@ class HTTPResponse(BaseHTTPResponse):
self._cookies = None self._cookies = None
def output(self, version="1.1", keep_alive=False, keep_alive_timeout=None): def output(self, version="1.1", keep_alive=False, keep_alive_timeout=None):
if "Content-Type" not in self.headers: assert version == "1.1", "No other versions are currently supported"
self.headers["Content-Type"] = self.content_type
body = b"" body = b""
if has_message_body(self.status): if has_message_body(self.status):
body = self.body body = self.body
@ -139,15 +140,13 @@ class HTTPResponse(BaseHTTPResponse):
"Content-Length", len(self.body) "Content-Length", len(self.body)
) )
# self.headers get priority over content_type
if self.content_type and "Content-Type" not in self.headers:
self.headers["Content-Type"] = self.content_type
if self.status in (304, 412): if self.status in (304, 412):
self.headers = remove_entity_headers(self.headers) self.headers = remove_entity_headers(self.headers)
if keep_alive and keep_alive_timeout is not None:
self.headers["Connection"] = "keep-alive"
self.headers["Keep-Alive"] = keep_alive_timeout
elif not keep_alive:
self.headers["Connection"] = "close"
return format_http1_response(self.status, self.headers.items(), body) return format_http1_response(self.status, self.headers.items(), body)
@property @property
@ -157,6 +156,16 @@ class HTTPResponse(BaseHTTPResponse):
return self._cookies return self._cookies
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)
def json( def json(
body, body,
status=200, status=200,

View File

@ -140,21 +140,22 @@ class Router:
docs for further details. docs for further details.
:return: Nothing :return: Nothing
""" """
routes = []
if version is not None: if version is not None:
version = re.escape(str(version).strip("/").lstrip("v")) version = re.escape(str(version).strip("/").lstrip("v"))
uri = "/".join(["/v{}".format(version), uri.lstrip("/")]) uri = "/".join(["/v{}".format(version), uri.lstrip("/")])
# add regular version # add regular version
self._add(uri, methods, handler, host, name) routes.append(self._add(uri, methods, handler, host, name))
if strict_slashes: if strict_slashes:
return return routes
if not isinstance(host, str) and host is not None: if not isinstance(host, str) and host is not None:
# we have gotten back to the top of the recursion tree where the # we have gotten back to the top of the recursion tree where the
# host was originally a list. By now, we've processed the strict # host was originally a list. By now, we've processed the strict
# slashes logic on the leaf nodes (the individual host strings in # slashes logic on the leaf nodes (the individual host strings in
# the list of host) # the list of host)
return return routes
# Add versions with and without trailing / # Add versions with and without trailing /
slashed_methods = self.routes_all.get(uri + "/", frozenset({})) slashed_methods = self.routes_all.get(uri + "/", frozenset({}))
@ -176,10 +177,12 @@ class Router:
) )
# add version with trailing slash # add version with trailing slash
if slash_is_missing: if slash_is_missing:
self._add(uri + "/", methods, handler, host, name) routes.append(self._add(uri + "/", methods, handler, host, name))
# add version without trailing slash # add version without trailing slash
elif without_slash_is_missing: elif without_slash_is_missing:
self._add(uri[:-1], methods, handler, host, name) routes.append(self._add(uri[:-1], methods, handler, host, name))
return routes
def _add(self, uri, methods, handler, host=None, name=None): def _add(self, uri, methods, handler, host=None, name=None):
"""Add a handler to the route list """Add a handler to the route list
@ -328,6 +331,7 @@ class Router:
self.routes_dynamic[url_hash(uri)].append(route) self.routes_dynamic[url_hash(uri)].append(route)
else: else:
self.routes_static[uri] = route self.routes_static[uri] = route
return route
@staticmethod @staticmethod
def check_dynamic_route_exists(pattern, routes_to_check, parameters): def check_dynamic_route_exists(pattern, routes_to_check, parameters):
@ -442,6 +446,7 @@ class Router:
method=method, method=method,
allowed_methods=self.get_supported_methods(url), allowed_methods=self.get_supported_methods(url),
) )
if route: if route:
if route.methods and method not in route.methods: if route.methods and method not in route.methods:
raise method_not_supported raise method_not_supported
@ -476,7 +481,7 @@ class Router:
route_handler = route.handler route_handler = route.handler
if hasattr(route_handler, "handlers"): if hasattr(route_handler, "handlers"):
route_handler = route_handler.handlers[method] route_handler = route_handler.handlers[method]
return route_handler, [], kwargs, route.uri return route_handler, [], kwargs, route.uri, route.name
def is_stream_handler(self, request): def is_stream_handler(self, request):
""" Handler for request is stream or not. """ Handler for request is stream or not.

View File

@ -1,5 +1,6 @@
import asyncio import asyncio
import os import os
import sys
import traceback import traceback
from collections import deque from collections import deque
@ -87,6 +88,7 @@ class HttpProtocol(asyncio.Protocol):
"_header_fragment", "_header_fragment",
"state", "state",
"_debug", "_debug",
"_body_chunks",
) )
def __init__( def __init__(
@ -133,6 +135,9 @@ class HttpProtocol(asyncio.Protocol):
self.request_class = request_class or Request self.request_class = request_class or Request
self.is_request_stream = is_request_stream self.is_request_stream = is_request_stream
self._is_stream_handler = False self._is_stream_handler = False
if sys.version_info.minor >= 8:
self._not_paused = asyncio.Event()
else:
self._not_paused = asyncio.Event(loop=loop) self._not_paused = asyncio.Event(loop=loop)
self._total_request_size = 0 self._total_request_size = 0
self._request_timeout_handler = None self._request_timeout_handler = None
@ -364,6 +369,21 @@ class HttpProtocol(asyncio.Protocol):
else: else:
self.request.body_push(body) self.request.body_push(body)
async def body_append(self, body):
if (
self.request is None
or self._request_stream_task is None
or self._request_stream_task.cancelled()
):
return
if self.request.stream.is_full():
self.transport.pause_reading()
await self.request.stream.put(body)
self.transport.resume_reading()
else:
await self.request.stream.put(body)
async def stream_append(self): async def stream_append(self):
while self._body_chunks: while self._body_chunks:
body = self._body_chunks.popleft() body = self._body_chunks.popleft()
@ -935,7 +955,10 @@ def serve(
else: else:
conn.close() conn.close()
if sys.version_info.minor >= 8:
_shutdown = asyncio.gather(*coros, loop=loop) _shutdown = asyncio.gather(*coros, loop=loop)
else:
_shutdown = asyncio.gather(*coros)
loop.run_until_complete(_shutdown) loop.run_until_complete(_shutdown)
trigger_events(after_stop, loop) trigger_events(after_stop, loop)

View File

@ -1,14 +1,8 @@
import asyncio
import types
import typing
from json import JSONDecodeError from json import JSONDecodeError
from socket import socket from socket import socket
from urllib.parse import unquote, urlsplit
import httpcore # type: ignore import httpx
import requests_async as requests # type: ignore import websockets
import websockets # type: ignore
from sanic.asgi import ASGIApp from sanic.asgi import ASGIApp
from sanic.exceptions import MethodNotSupported from sanic.exceptions import MethodNotSupported
@ -22,13 +16,14 @@ PORT = 42101
class SanicTestClient: class SanicTestClient:
def __init__(self, app, port=PORT): def __init__(self, app, port=PORT, host=HOST):
"""Use port=None to bind to a random port""" """Use port=None to bind to a random port"""
self.app = app self.app = app
self.port = port self.port = port
self.host = host
def get_new_session(self): def get_new_session(self):
return requests.Session() return httpx.Client()
async def _local_request(self, method, url, *args, **kwargs): async def _local_request(self, method, url, *args, **kwargs):
logger.info(url) logger.info(url)
@ -59,7 +54,8 @@ class SanicTestClient:
if raw_cookies: if raw_cookies:
response.raw_cookies = {} response.raw_cookies = {}
for cookie in response.cookies:
for cookie in response.cookies.jar:
response.raw_cookies[cookie.name] = cookie response.raw_cookies[cookie.name] = cookie
return response return response
@ -71,6 +67,7 @@ class SanicTestClient:
gather_request=True, gather_request=True,
debug=False, debug=False,
server_kwargs={"auto_reload": False}, server_kwargs={"auto_reload": False},
host=None,
*request_args, *request_args,
**request_kwargs, **request_kwargs,
): ):
@ -95,11 +92,13 @@ class SanicTestClient:
return self.app.error_handler.default(request, exception) return self.app.error_handler.default(request, exception)
if self.port: if self.port:
server_kwargs = dict(host=HOST, port=self.port, **server_kwargs) server_kwargs = dict(
host, port = HOST, self.port host=host or self.host, port=self.port, **server_kwargs
)
host, port = host or self.host, self.port
else: else:
sock = socket() sock = socket()
sock.bind((HOST, 0)) sock.bind((host or self.host, 0))
server_kwargs = dict(sock=sock, **server_kwargs) server_kwargs = dict(sock=sock, **server_kwargs)
host, port = sock.getsockname() host, port = sock.getsockname()
@ -175,181 +174,6 @@ class SanicTestClient:
return self._sanic_endpoint_test("websocket", *args, **kwargs) return self._sanic_endpoint_test("websocket", *args, **kwargs)
class SanicASGIAdapter(requests.asgi.ASGIAdapter): # noqa
async def send( # type: ignore
self,
request: requests.PreparedRequest,
gather_return: bool = False,
*args: typing.Any,
**kwargs: typing.Any,
) -> requests.Response:
"""This method is taken MOSTLY verbatim from requests-asyn. The
difference is the capturing of a response on the ASGI call and then
returning it on the response object. This is implemented to achieve:
request, response = await app.asgi_client.get("/")
You can see the original code here:
https://github.com/encode/requests-async/blob/614f40f77f19e6c6da8a212ae799107b0384dbf9/requests_async/asgi.py#L51""" # noqa
scheme, netloc, path, query, fragment = urlsplit(
request.url
) # type: ignore
default_port = {"http": 80, "ws": 80, "https": 443, "wss": 443}[scheme]
if ":" in netloc:
host, port_string = netloc.split(":", 1)
port = int(port_string)
else:
host = netloc
port = default_port
# Include the 'host' header.
if "host" in request.headers:
headers = [] # type: typing.List[typing.Tuple[bytes, bytes]]
elif port == default_port:
headers = [(b"host", host.encode())]
else:
headers = [(b"host", (f"{host}:{port}").encode())]
# Include other request headers.
headers += [
(key.lower().encode(), value.encode())
for key, value in request.headers.items()
]
no_response = False
if scheme in {"ws", "wss"}:
subprotocol = request.headers.get("sec-websocket-protocol", None)
if subprotocol is None:
subprotocols = [] # type: typing.Sequence[str]
else:
subprotocols = [
value.strip() for value in subprotocol.split(",")
]
scope = {
"type": "websocket",
"path": unquote(path),
"root_path": "",
"scheme": scheme,
"query_string": query.encode(),
"headers": headers,
"client": ["testclient", 50000],
"server": [host, port],
"subprotocols": subprotocols,
}
no_response = True
else:
scope = {
"type": "http",
"http_version": "1.1",
"method": request.method,
"path": unquote(path),
"root_path": "",
"scheme": scheme,
"query_string": query.encode(),
"headers": headers,
"client": ["testclient", 50000],
"server": [host, port],
"extensions": {"http.response.template": {}},
}
async def receive():
nonlocal request_complete, response_complete
if request_complete:
while not response_complete:
await asyncio.sleep(0.0001)
return {"type": "http.disconnect"}
body = request.body
if isinstance(body, str):
body_bytes = body.encode("utf-8") # type: bytes
elif body is None:
body_bytes = b""
elif isinstance(body, types.GeneratorType):
try:
chunk = body.send(None)
if isinstance(chunk, str):
chunk = chunk.encode("utf-8")
return {
"type": "http.request",
"body": chunk,
"more_body": True,
}
except StopIteration:
request_complete = True
return {"type": "http.request", "body": b""}
else:
body_bytes = body
request_complete = True
return {"type": "http.request", "body": body_bytes}
request_complete = False
response_started = False
response_complete = False
raw_kwargs = {"content": b""} # type: typing.Dict[str, typing.Any]
template = None
context = None
return_value = None
async def send(message) -> None:
nonlocal raw_kwargs, response_started, response_complete, template, context # noqa
if message["type"] == "http.response.start":
assert (
not response_started
), 'Received multiple "http.response.start" messages.'
raw_kwargs["status_code"] = message["status"]
raw_kwargs["headers"] = message["headers"]
response_started = True
elif message["type"] == "http.response.body":
assert response_started, (
'Received "http.response.body" '
'without "http.response.start".'
)
assert (
not response_complete
), 'Received "http.response.body" after response completed.'
body = message.get("body", b"")
more_body = message.get("more_body", False)
if request.method != "HEAD":
raw_kwargs["content"] += body
if not more_body:
response_complete = True
elif message["type"] == "http.response.template":
template = message["template"]
context = message["context"]
try:
return_value = await self.app(scope, receive, send)
except BaseException as exc:
if not self.suppress_exceptions:
raise exc from None
if no_response:
response_started = True
raw_kwargs = {"status_code": 204, "headers": []}
if not self.suppress_exceptions:
assert response_started, "TestClient did not receive any response."
elif not response_started:
raw_kwargs = {"status_code": 500, "headers": []}
raw = httpcore.Response(**raw_kwargs)
response = self.build_response(request, raw)
if template is not None:
response.template = template
response.context = context
if gather_return:
response.return_value = return_value
return response
class TestASGIApp(ASGIApp): class TestASGIApp(ASGIApp):
async def __call__(self): async def __call__(self):
await super().__call__() await super().__call__()
@ -361,7 +185,11 @@ async def app_call_with_return(self, scope, receive, send):
return await asgi_app() return await asgi_app()
class SanicASGITestClient(requests.ASGISession): class SanicASGIDispatch(httpx.dispatch.ASGIDispatch):
pass
class SanicASGITestClient(httpx.Client):
def __init__( def __init__(
self, self,
app, app,
@ -370,18 +198,18 @@ class SanicASGITestClient(requests.ASGISession):
) -> None: ) -> None:
app.__class__.__call__ = app_call_with_return app.__class__.__call__ = app_call_with_return
app.asgi = True app.asgi = True
super().__init__(app)
adapter = SanicASGIAdapter(
app, suppress_exceptions=suppress_exceptions
)
self.mount("http://", adapter)
self.mount("https://", adapter)
self.mount("ws://", adapter)
self.mount("wss://", adapter)
self.headers.update({"user-agent": "testclient"})
self.app = app self.app = app
self.base_url = base_url
dispatch = SanicASGIDispatch(app=app, client=(ASGI_HOST, PORT))
super().__init__(dispatch=dispatch, base_url=base_url)
self.last_request = None
def _collect_request(request):
self.last_request = request
app.request_middleware.appendleft(_collect_request)
async def request(self, method, url, gather_request=True, *args, **kwargs): async def request(self, method, url, gather_request=True, *args, **kwargs):
@ -391,24 +219,12 @@ class SanicASGITestClient(requests.ASGISession):
response.body = response.content response.body = response.content
response.content_type = response.headers.get("content-type") response.content_type = response.headers.get("content-type")
if hasattr(response, "return_value"): return self.last_request, response
request = response.return_value
del response.return_value
return request, response
return response
def merge_environment_settings(self, *args, **kwargs):
settings = super().merge_environment_settings(*args, **kwargs)
settings.update({"gather_return": self.gather_request})
return settings
async def websocket(self, uri, subprotocols=None, *args, **kwargs): async def websocket(self, uri, subprotocols=None, *args, **kwargs):
if uri.startswith(("ws:", "wss:")): scheme = "ws"
url = uri path = uri
else: root_path = "{}://{}".format(scheme, ASGI_HOST)
uri = uri if uri.startswith("/") else "/{uri}".format(uri=uri)
url = "ws://testserver{uri}".format(uri=uri)
headers = kwargs.get("headers", {}) headers = kwargs.get("headers", {})
headers.setdefault("connection", "upgrade") headers.setdefault("connection", "upgrade")
@ -418,6 +234,24 @@ class SanicASGITestClient(requests.ASGISession):
headers.setdefault( headers.setdefault(
"sec-websocket-protocol", ", ".join(subprotocols) "sec-websocket-protocol", ", ".join(subprotocols)
) )
kwargs["headers"] = headers
return await self.request("websocket", url, **kwargs) scope = {
"type": "websocket",
"asgi": {"version": "3.0"},
"http_version": "1.1",
"headers": [map(lambda y: y.encode(), x) for x in headers.items()],
"scheme": scheme,
"root_path": root_path,
"path": path,
"query_string": b"",
}
async def receive():
return {}
async def send(message):
pass
await self.app(scope, receive, send)
return None, {}

View File

@ -1,6 +1,6 @@
[tool.towncrier] [tool.towncrier]
package = "sanic" package = "sanic"
package_dir = "." package_dir = ".."
filename = "../CHANGELOG.rst" filename = "../CHANGELOG.rst"
directory = "./changelogs" directory = "./changelogs"
underlines = ["=", "*", "~"] underlines = ["=", "*", "~"]

View File

@ -14,7 +14,7 @@ multi_line_output = 3
not_skip = __init__.py not_skip = __init__.py
[version] [version]
current_version = 19.9.0 current_version = 19.12.0
files = sanic/__version__.py files = sanic/__version__.py
current_version_pattern = __version__ = "{current_version}" current_version_pattern = __version__ = "{current_version}"
new_version_pattern = __version__ = "{new_version}" new_version_pattern = __version__ = "{new_version}"

View File

@ -5,6 +5,7 @@ import codecs
import os import os
import re import re
import sys import sys
from distutils.util import strtobool from distutils.util import strtobool
from setuptools import setup from setuptools import setup
@ -83,7 +84,7 @@ requirements = [
"aiofiles>=0.3.0", "aiofiles>=0.3.0",
"websockets>=7.0,<9.0", "websockets>=7.0,<9.0",
"multidict>=4.0,<5.0", "multidict>=4.0,<5.0",
"requests-async==0.5.0", "httpx==0.9.3",
] ]
tests_require = [ tests_require = [

View File

@ -94,7 +94,7 @@ def test_app_route_raise_value_error(app):
def test_app_handle_request_handler_is_none(app, monkeypatch): def test_app_handle_request_handler_is_none(app, monkeypatch):
def mockreturn(*args, **kwargs): def mockreturn(*args, **kwargs):
return None, [], {}, "" return None, [], {}, "", ""
# Not sure how to make app.router.get() return None, so use mock here. # Not sure how to make app.router.get() return None, so use mock here.
monkeypatch.setattr(app.router, "get", mockreturn) monkeypatch.setattr(app.router, "get", mockreturn)

View File

@ -1,6 +1,6 @@
import asyncio import asyncio
from collections import deque from collections import deque, namedtuple
import pytest import pytest
import uvicorn import uvicorn
@ -245,17 +245,26 @@ async def test_cookie_customization(app):
return response return response
_, response = await app.asgi_client.get("/cookie") _, response = await app.asgi_client.get("/cookie")
CookieDef = namedtuple("CookieDef", ("value", "httponly"))
Cookie = namedtuple("Cookie", ("domain", "path", "value", "httponly"))
cookie_map = { cookie_map = {
"test": {"value": "Cookie1", "HttpOnly": True}, "test": CookieDef("Cookie1", True),
"c2": {"value": "Cookie2", "HttpOnly": False}, "c2": CookieDef("Cookie2", False),
} }
for k, v in ( cookies = {
response.cookies._cookies.get("mockserver.local").get("/").items() c.name: Cookie(c.domain, c.path, c.value, "HttpOnly" in c._rest.keys())
): for c in response.cookies.jar
assert cookie_map.get(k).get("value") == v.value }
if cookie_map.get(k).get("HttpOnly"):
assert "HttpOnly" in v._rest.keys() for name, definition in cookie_map.items():
cookie = cookies.get(name)
assert cookie
assert cookie.value == definition.value
assert cookie.domain == "mockserver.local"
assert cookie.path == "/"
assert cookie.httponly == definition.httponly
@pytest.mark.asyncio @pytest.mark.asyncio

View File

@ -83,7 +83,7 @@ def test_bp_group_with_additional_route_params(app: Sanic):
_, response = app.test_client.patch("/api/bp2/route/bp2", headers=header) _, response = app.test_client.patch("/api/bp2/route/bp2", headers=header)
assert response.text == "PATCH_bp2" assert response.text == "PATCH_bp2"
_, response = app.test_client.get("/v2/api/bp1/request_path") _, response = app.test_client.put("/v2/api/bp1/request_path")
assert response.status == 401 assert response.status == 401
@ -141,8 +141,8 @@ def test_bp_group(app: Sanic):
_, response = app.test_client.get("/api/bp3") _, response = app.test_client.get("/api/bp3")
assert response.text == "BP3_OK" assert response.text == "BP3_OK"
assert MIDDLEWARE_INVOKE_COUNTER["response"] == 4 assert MIDDLEWARE_INVOKE_COUNTER["response"] == 3
assert MIDDLEWARE_INVOKE_COUNTER["request"] == 4 assert MIDDLEWARE_INVOKE_COUNTER["request"] == 2
def test_bp_group_list_operations(app: Sanic): def test_bp_group_list_operations(app: Sanic):

View File

@ -268,7 +268,7 @@ def test_bp_middleware(app):
request, response = app.test_client.get("/") request, response = app.test_client.get("/")
assert response.status == 200 assert response.status == 200
assert response.text == "OK" assert response.text == "FAIL"
def test_bp_exception_handler(app): def test_bp_exception_handler(app):

View File

@ -1,15 +1,9 @@
import asyncio import asyncio
import functools
import socket
from asyncio import sleep as aio_sleep from asyncio import sleep as aio_sleep
from http.client import _encode
from json import JSONDecodeError from json import JSONDecodeError
import httpcore import httpx
import requests_async as requests
from httpcore import PoolTimeout
from sanic import Sanic, server from sanic import Sanic, server
from sanic.response import text from sanic.response import text
@ -21,24 +15,28 @@ CONFIG_FOR_TESTS = {"KEEP_ALIVE_TIMEOUT": 2, "KEEP_ALIVE": True}
old_conn = None old_conn = None
class ReusableSanicConnectionPool(httpcore.ConnectionPool): class ReusableSanicConnectionPool(
async def acquire_connection(self, origin): httpx.dispatch.connection_pool.ConnectionPool
):
async def acquire_connection(self, origin, timeout):
global old_conn global old_conn
connection = self.active_connections.pop_by_origin( connection = self.pop_connection(origin)
origin, http2_only=True
)
if connection is None:
connection = self.keepalive_connections.pop_by_origin(origin)
if connection is None: if connection is None:
await self.max_connections.acquire() pool_timeout = None if timeout is None else timeout.pool_timeout
connection = httpcore.HTTPConnection(
await self.max_connections.acquire(timeout=pool_timeout)
connection = httpx.dispatch.connection.HTTPConnection(
origin, origin,
ssl=self.ssl, verify=self.verify,
timeout=self.timeout, cert=self.cert,
http2=self.http2,
backend=self.backend, backend=self.backend,
release_func=self.release_connection, release_func=self.release_connection,
trust_env=self.trust_env,
uds=self.uds,
) )
self.active_connections.add(connection) self.active_connections.add(connection)
if old_conn is not None: if old_conn is not None:
@ -51,17 +49,10 @@ class ReusableSanicConnectionPool(httpcore.ConnectionPool):
return connection return connection
class ReusableSanicAdapter(requests.adapters.HTTPAdapter): class ResusableSanicSession(httpx.Client):
def __init__(self):
self.pool = ReusableSanicConnectionPool()
class ResusableSanicSession(requests.Session):
def __init__(self, *args, **kwargs) -> None: def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs) dispatch = ReusableSanicConnectionPool()
adapter = ReusableSanicAdapter() super().__init__(dispatch=dispatch, *args, **kwargs)
self.mount("http://", adapter)
self.mount("https://", adapter)
class ReuseableSanicTestClient(SanicTestClient): class ReuseableSanicTestClient(SanicTestClient):
@ -74,6 +65,9 @@ class ReuseableSanicTestClient(SanicTestClient):
self._tcp_connector = None self._tcp_connector = None
self._session = None self._session = None
def get_new_session(self):
return ResusableSanicSession()
# Copied from SanicTestClient, but with some changes to reuse the # Copied from SanicTestClient, but with some changes to reuse the
# same loop for the same app. # same loop for the same app.
def _sanic_endpoint_test( def _sanic_endpoint_test(
@ -167,7 +161,6 @@ class ReuseableSanicTestClient(SanicTestClient):
self._server.close() self._server.close()
self._loop.run_until_complete(self._server.wait_closed()) self._loop.run_until_complete(self._server.wait_closed())
self._server = None self._server = None
self.app.stop()
if self._session: if self._session:
self._loop.run_until_complete(self._session.close()) self._loop.run_until_complete(self._session.close())
@ -186,7 +179,7 @@ class ReuseableSanicTestClient(SanicTestClient):
"request_keepalive", CONFIG_FOR_TESTS["KEEP_ALIVE_TIMEOUT"] "request_keepalive", CONFIG_FOR_TESTS["KEEP_ALIVE_TIMEOUT"]
) )
if not self._session: if not self._session:
self._session = ResusableSanicSession() self._session = self.get_new_session()
try: try:
response = await getattr(self._session, method.lower())( response = await getattr(self._session, method.lower())(
url, verify=False, timeout=request_keepalive, *args, **kwargs url, verify=False, timeout=request_keepalive, *args, **kwargs

View File

@ -2,6 +2,7 @@ import io
from sanic.response import text from sanic.response import text
data = "abc" * 10_000_000 data = "abc" * 10_000_000

View File

@ -332,7 +332,7 @@ def test_request_stream_handle_exception(app):
assert response.text == "Error: Requested URL /in_valid_post not found" assert response.text == "Error: Requested URL /in_valid_post not found"
# 405 # 405
request, response = app.test_client.get("/post/random_id", data=data) request, response = app.test_client.get("/post/random_id")
assert response.status == 405 assert response.status == 405
assert ( assert (
response.text == "Error: Method GET not allowed for URL" response.text == "Error: Method GET not allowed for URL"

View File

@ -1,49 +1,70 @@
import asyncio import asyncio
import httpcore import httpx
import requests_async as requests
from sanic import Sanic from sanic import Sanic
from sanic.response import text from sanic.response import text
from sanic.testing import SanicTestClient from sanic.testing import SanicTestClient
class DelayableSanicConnectionPool(httpcore.ConnectionPool): class DelayableHTTPConnection(httpx.dispatch.connection.HTTPConnection):
def __init__(self, *args, **kwargs):
self._request_delay = None
if "request_delay" in kwargs:
self._request_delay = kwargs.pop("request_delay")
super().__init__(*args, **kwargs)
async def send(self, request, verify=None, cert=None, timeout=None):
if self.h11_connection is None and self.h2_connection is None:
await self.connect(verify=verify, cert=cert, timeout=timeout)
if self._request_delay:
await asyncio.sleep(self._request_delay)
if self.h2_connection is not None:
response = await self.h2_connection.send(request, timeout=timeout)
else:
assert self.h11_connection is not None
response = await self.h11_connection.send(request, timeout=timeout)
return response
class DelayableSanicConnectionPool(
httpx.dispatch.connection_pool.ConnectionPool
):
def __init__(self, request_delay=None, *args, **kwargs): def __init__(self, request_delay=None, *args, **kwargs):
self._request_delay = request_delay self._request_delay = request_delay
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
async def send(self, request, stream=False, ssl=None, timeout=None): async def acquire_connection(self, origin, timeout=None):
connection = await self.acquire_connection(request.url.origin) connection = self.pop_connection(origin)
if (
connection.h11_connection is None if connection is None:
and connection.h2_connection is None pool_timeout = None if timeout is None else timeout.pool_timeout
):
await connection.connect(ssl=ssl, timeout=timeout) await self.max_connections.acquire(timeout=pool_timeout)
if self._request_delay: connection = DelayableHTTPConnection(
await asyncio.sleep(self._request_delay) origin,
try: verify=self.verify,
response = await connection.send( cert=self.cert,
request, stream=stream, ssl=ssl, timeout=timeout http2=self.http2,
backend=self.backend,
release_func=self.release_connection,
trust_env=self.trust_env,
uds=self.uds,
request_delay=self._request_delay,
) )
except BaseException as exc:
self.active_connections.remove(connection) self.active_connections.add(connection)
self.max_connections.release()
raise exc return connection
return response
class DelayableSanicAdapter(requests.adapters.HTTPAdapter): class DelayableSanicSession(httpx.Client):
def __init__(self, request_delay=None):
self.pool = DelayableSanicConnectionPool(request_delay=request_delay)
class DelayableSanicSession(requests.Session):
def __init__(self, request_delay=None, *args, **kwargs) -> None: def __init__(self, request_delay=None, *args, **kwargs) -> None:
super().__init__(*args, **kwargs) dispatch = DelayableSanicConnectionPool(request_delay=request_delay)
adapter = DelayableSanicAdapter(request_delay=request_delay) super().__init__(dispatch=dispatch, *args, **kwargs)
self.mount("http://", adapter)
self.mount("https://", adapter)
class DelayableSanicTestClient(SanicTestClient): class DelayableSanicTestClient(SanicTestClient):

View File

@ -55,11 +55,11 @@ def test_ip(app):
async def test_ip_asgi(app): async def test_ip_asgi(app):
@app.route("/") @app.route("/")
def handler(request): def handler(request):
return text("{}".format(request.ip)) return text("{}".format(request.url))
request, response = await app.asgi_client.get("/") request, response = await app.asgi_client.get("/")
assert response.text == "mockserver" assert response.text == "http://mockserver/"
def test_text(app): def test_text(app):
@ -242,24 +242,24 @@ async def test_empty_json_asgi(app):
def test_invalid_json(app): def test_invalid_json(app):
@app.route("/") @app.post("/")
async def handler(request): async def handler(request):
return json(request.json) return json(request.json)
data = "I am not json" data = "I am not json"
request, response = app.test_client.get("/", data=data) request, response = app.test_client.post("/", data=data)
assert response.status == 400 assert response.status == 400
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_invalid_json_asgi(app): async def test_invalid_json_asgi(app):
@app.route("/") @app.post("/")
async def handler(request): async def handler(request):
return json(request.json) return json(request.json)
data = "I am not json" data = "I am not json"
request, response = await app.asgi_client.get("/", data=data) request, response = await app.asgi_client.post("/", data=data)
assert response.status == 400 assert response.status == 400
@ -1842,26 +1842,6 @@ def test_request_port(app):
assert hasattr(request, "_port") assert hasattr(request, "_port")
@pytest.mark.asyncio
async def test_request_port_asgi(app):
@app.get("/")
def handler(request):
return text("OK")
request, response = await app.asgi_client.get("/")
port = request.port
assert isinstance(port, int)
delattr(request, "_socket")
delattr(request, "_port")
port = request.port
assert isinstance(port, int)
assert hasattr(request, "_socket")
assert hasattr(request, "_port")
def test_request_socket(app): def test_request_socket(app):
@app.get("/") @app.get("/")
def handler(request): def handler(request):

View File

@ -22,6 +22,7 @@ from sanic.response import (
stream, stream,
text, text,
) )
from sanic.response import empty
from sanic.server import HttpProtocol from sanic.server import HttpProtocol
from sanic.testing import HOST, PORT from sanic.testing import HOST, PORT
@ -592,3 +593,13 @@ def test_raw_response(app):
request, response = app.test_client.get("/test") request, response = app.test_client.get("/test")
assert response.content_type == "application/octet-stream" assert response.content_type == "application/octet-stream"
assert response.body == b"raw_response" assert response.body == b"raw_response"
def test_empty_response(app):
@app.get("/test")
def handler(request):
return empty()
request, response = app.test_client.get("/test")
assert response.content_type is None
assert response.body == b""

View File

@ -551,6 +551,35 @@ def test_route_duplicate(app):
pass 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): def test_method_not_allowed(app):
@app.route("/test", methods=["GET"]) @app.route("/test", methods=["GET"])
async def handler(request): async def handler(request):

View File

@ -37,14 +37,14 @@ def skip_test_utf8_route(app):
def test_utf8_post_json(app): def test_utf8_post_json(app):
@app.route("/") @app.post("/")
async def handler(request): async def handler(request):
return text("OK") return text("OK")
payload = {"test": ""} payload = {"test": ""}
headers = {"content-type": "application/json"} headers = {"content-type": "application/json"}
request, response = app.test_client.get( request, response = app.test_client.post(
"/", data=json_dumps(payload), headers=headers "/", data=json_dumps(payload), headers=headers
) )

View File

@ -1,11 +1,11 @@
[tox] [tox]
envlist = py36, py37, {py36,py37}-no-ext, lint, check, security, docs envlist = py36, py37, pyNightly, {py36,py37,pyNightly}-no-ext, lint, check, security, docs
[testenv] [testenv]
usedevelop = True usedevelop = True
setenv = setenv =
{py36,py37}-no-ext: SANIC_NO_UJSON=1 {py36,py37,pyNightly}-no-ext: SANIC_NO_UJSON=1
{py36,py37}-no-ext: SANIC_NO_UVLOOP=1 {py36,py37,pyNightly}-no-ext: SANIC_NO_UVLOOP=1
deps = deps =
coverage coverage
pytest==5.2.1 pytest==5.2.1
@ -13,7 +13,7 @@ deps =
pytest-sanic pytest-sanic
pytest-sugar pytest-sugar
httpcore==0.3.0 httpcore==0.3.0
requests-async==0.5.0 httpx==0.9.3
chardet<=2.3.0 chardet<=2.3.0
beautifulsoup4 beautifulsoup4
gunicorn gunicorn