Merge branch 'master' into Improving-documentation

This commit is contained in:
Adam Hopkins 2020-10-25 00:02:32 +03:00 committed by GitHub
commit 12521cd5b4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 1197 additions and 382 deletions

5
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@ -0,0 +1,5 @@
blank_issues_enabled: true
contact_links:
- name: Questions and Help
url: https://community.sanicframework.org/c/questions-and-help
about: Do you need help with Sanic? Ask your questions here.

View File

@ -1,13 +0,0 @@
---
name: Help wanted
about: Do you need help? Try community.sanicframework.org
---
*DELETE ALL BEFORE POSTING*
*Post your HELP WANTED questions on [the community forum](https://community.sanicframework.org/)*.
Checkout the community forum before posting any question here.
We prefer if you put these kinds of questions here:
https://community.sanicframework.org/c/questions-and-help

View File

@ -1,3 +1,73 @@
Version 20.9.0
===============
Features
********
*
`#1887 <https://github.com/huge-success/sanic/pull/1887>`_
Pass subprotocols in websockets (both sanic server and ASGI)
*
`#1894 <https://github.com/huge-success/sanic/pull/1894>`_
Automatically set ``test_mode`` flag on app instance
*
`#1903 <https://github.com/huge-success/sanic/pull/1903>`_
Add new unified method for updating app values
*
`#1906 <https://github.com/huge-success/sanic/pull/1906>`_,
`#1909 <https://github.com/huge-success/sanic/pull/1909>`_
Adds WEBSOCKET_PING_TIMEOUT and WEBSOCKET_PING_INTERVAL configuration values
*
`#1935 <https://github.com/huge-success/sanic/pull/1935>`_
httpx version dependency updated, it is slated for removal as a dependency in v20.12
*
`#1937 <https://github.com/huge-success/sanic/pull/1937>`_
Added auto, text, and json fallback error handlers (in v21.3, the default will change form html to auto)
Bugfixes
********
*
`#1897 <https://github.com/huge-success/sanic/pull/1897>`_
Resolves exception from unread bytes in stream
Deprecations and Removals
*************************
*
`#1903 <https://github.com/huge-success/sanic/pull/1903>`_
config.from_envar, config.from_pyfile, and config.from_object are deprecated and set to be removed in v21.3
Developer infrastructure
************************
*
`#1890 <https://github.com/huge-success/sanic/pull/1890>`_,
`#1891 <https://github.com/huge-success/sanic/pull/1891>`_
Update isort calls to be compatible with new API
*
`#1893 <https://github.com/huge-success/sanic/pull/1893>`_
Remove version section from setup.cfg
*
`#1924 <https://github.com/huge-success/sanic/pull/1924>`_
Adding --strict-markers for pytest
Improved Documentation
**********************
*
`#1922 <https://github.com/huge-success/sanic/pull/1922>`_
Add explicit ASGI compliance to the README
Version 20.6.3 Version 20.6.3
=============== ===============
@ -299,7 +369,7 @@ Features
* *
`#1562 <https://github.com/huge-success/sanic/pull/1562>`_ `#1562 <https://github.com/huge-success/sanic/pull/1562>`_
Remove ``aiohttp`` dependencey and create new ``SanicTestClient`` based upon Remove ``aiohttp`` dependency and create new ``SanicTestClient`` based upon
`requests-async <https://github.com/encode/requests-async>`_ `requests-async <https://github.com/encode/requests-async>`_
* *

View File

@ -58,6 +58,8 @@ Sanic | Build fast. Run fast.
Sanic is a **Python 3.6+** web server and web framework that's written to go fast. It allows the usage of the ``async/await`` syntax added in Python 3.5, which makes your code non-blocking and speedy. Sanic is a **Python 3.6+** web server and web framework that's written to go fast. It allows the usage of the ``async/await`` syntax added in Python 3.5, which makes your code non-blocking and speedy.
Sanic is also ASGI compliant, so you can deploy it with an `alternative ASGI webserver <https://sanic.readthedocs.io/en/latest/sanic/deploying.html#running-via-asgi>`_.
`Source code on GitHub <https://github.com/huge-success/sanic/>`_ | `Help and discussion board <https://community.sanicframework.org/>`_. `Source code on GitHub <https://github.com/huge-success/sanic/>`_ | `Help and discussion board <https://community.sanicframework.org/>`_.
The project is maintained by the community, for the community. **Contributions are welcome!** The project is maintained by the community, for the community. **Contributions are welcome!**
@ -104,7 +106,7 @@ Hello World Example
if __name__ == '__main__': if __name__ == '__main__':
app.run(host='0.0.0.0', port=8000) app.run(host='0.0.0.0', port=8000)
Sanic can now be easily run using ``python3 hello.py``. Sanic can now be easily run using ``sanic hello.app``.
.. code:: .. code::

View File

@ -5,10 +5,15 @@
Sanic releases long term support release once a year in December. LTS releases receive bug and security updates for **24 months**. Interim releases throughout the year occur every three months, and are supported until the subsequent interim release. Sanic releases long term support release once a year in December. LTS releases receive bug and security updates for **24 months**. Interim releases throughout the year occur every three months, and are supported until the subsequent interim release.
| Version | LTS | Supported | | Version | LTS | Supported |
| ------- | ------------------ | ------------------ | | ------- | ------------- | ------------------ |
| 19.6.0 | | :white_check_mark: | | 20.9 | | :heavy_check_mark: |
| 19.3.1 | | :heavy_check_mark: | | 20.6 | | :x: |
| 18.12.0 | :heavy_check_mark: | :heavy_check_mark: | | 20.3 | | :x: |
| 19.12 | until 2021-12 | :white_check_mark: |
| 19.9 | | :x: |
| 19.6 | | :x: |
| 19.3 | | :x: |
| 18.12 | until 2020-12 | :white_check_mark: |
| 0.8.3 | | :x: | | 0.8.3 | | :x: |
| 0.7.0 | | :x: | | 0.7.0 | | :x: |
| 0.6.0 | | :x: | | 0.6.0 | | :x: |
@ -18,6 +23,9 @@ Sanic releases long term support release once a year in December. LTS releases r
| 0.2.0 | | :x: | | 0.2.0 | | :x: |
| 0.1.9 | | :x: | | 0.1.9 | | :x: |
:white_check_mark: = security/bug fixes
:heavy_check_mark: = full support
## Reporting a Vulnerability ## Reporting a Vulnerability
If you discover a security vulnerability, we ask that you **do not** create an issue on GitHub. Instead, please [send a message to the core-devs](https://community.sanicframework.org/g/core-devs) on the community forums. Once logged in, you can send a message to the core-devs by clicking the message button. If you discover a security vulnerability, we ask that you **do not** create an issue on GitHub. Instead, please [send a message to the core-devs](https://community.sanicframework.org/g/core-devs) on the community forums. Once logged in, you can send a message to the core-devs by clicking the message button.

View File

@ -0,0 +1,3 @@
Adds WEBSOCKET_PING_TIMEOUT and WEBSOCKET_PING_INTERVAL configuration values
Allows setting the ping_interval and ping_timeout arguments when initializing `WebSocketCommonProtocol`.

View File

@ -12,9 +12,9 @@ Sanic holds the configuration in the `config` attribute of the application objec
app = Sanic('myapp') app = Sanic('myapp')
app.config.DB_NAME = 'appdb' app.config.DB_NAME = 'appdb'
app.config.DB_USER = 'appuser' app.config['DB_USER'] = 'appuser'
Since the config object actually is a dictionary, you can use its `update` method in order to set several values at once: Since the config object has a type that inherits from dictionary, you can use its ``update`` method in order to set several values at once:
.. code-block:: python .. code-block:: python
@ -47,9 +47,90 @@ Then the above variable would be `MYAPP_REQUEST_TIMEOUT`. If you want to disable
app = Sanic(__name__, load_env=False) app = Sanic(__name__, load_env=False)
From file, dict, or any object (having __dict__ attribute).
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
You can store app configurations in: (1) a Python file, (2) a dictionary, or (3) in some other type of custom object.
In order to load configuration from ove of those, you can use ``app.upload_config()``.
**1) From file**
Let's say you have ``my_config.py`` file that looks like this:
.. code-block:: python
# my_config.py
A = 1
B = 2
Loading config from this file is as easy as:
.. code-block:: python
app.update_config("/path/to/my_config.py")
You can also use environment variables in the path name here.
Let's say you have an environment variable like this:
.. code-block:: shell
$ export my_path="/path/to"
Then you can use it like this:
.. code-block:: python
app.update_config("${my_path}/my_config.py")
.. note::
Just remember that you have to provide environment variables in the format ${environment_variable} and that $environment_variable is not expanded (is treated as "plain" text).
**2) From dict**
You can also set your app config by providing a ``dict``:
.. code-block:: python
d = {"A": 1, "B": 2}
app.update_config(d)
**3) From _any_ object**
App config can be taken from an object. Internally, it uses ``__dict__`` to retrieve keys and values.
For example, pass the class:
.. code-block:: python
class C:
A = 1
B = 2
app.update_config(C)
or, it can be instantiated:
.. code-block:: python
c = C()
app.update_config(c)
- From an object (having __dict__ attribute)
From an Object From an Object
~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~
.. note::
Deprecated, will be removed in version 21.3.
If there are a lot of configuration values and they have sensible defaults it might be helpful to put them into a module: If there are a lot of configuration values and they have sensible defaults it might be helpful to put them into a module:
.. code-block:: python .. code-block:: python
@ -71,6 +152,10 @@ You could use a class or any other object as well.
From a File From a File
~~~~~~~~~~~ ~~~~~~~~~~~
.. note::
Deprecated, will be removed in version 21.3.
Usually you will want to load configuration from a file that is not part of the distributed application. You can load configuration from a file using `from_pyfile(/path/to/config_file)`. However, that requires the program to know the path to the config file. So instead you can specify the location of the config file in an environment variable and tell Sanic to use that to find the config file: Usually you will want to load configuration from a file that is not part of the distributed application. You can load configuration from a file using `from_pyfile(/path/to/config_file)`. However, that requires the program to know the path to the config file. So instead you can specify the location of the config file in an environment variable and tell Sanic to use that to find the config file:
.. code-block:: python .. code-block:: python
@ -98,7 +183,7 @@ The config files are regular Python files which are executed in order to load th
Builtin Configuration Values Builtin Configuration Values
---------------------------- ----------------------------
Out of the box there are just a few predefined values which can be overwritten when creating the application. Out of the box there are just a few predefined values which can be overwritten when creating the application. Note that websocket configuration values will have no impact if running in ASGI mode.
+---------------------------+-------------------+-----------------------------------------------------------------------------+ +---------------------------+-------------------+-----------------------------------------------------------------------------+
| Variable | Default | Description | | Variable | Default | Description |
@ -123,6 +208,10 @@ Out of the box there are just a few predefined values which can be overwritten w
+---------------------------+-------------------+-----------------------------------------------------------------------------+ +---------------------------+-------------------+-----------------------------------------------------------------------------+
| WEBSOCKET_WRITE_LIMIT | 2^16 | High-water limit of the buffer for outgoing bytes | | WEBSOCKET_WRITE_LIMIT | 2^16 | High-water limit of the buffer for outgoing bytes |
+---------------------------+-------------------+-----------------------------------------------------------------------------+ +---------------------------+-------------------+-----------------------------------------------------------------------------+
| WEBSOCKET_PING_INTERVAL | 20 | A Ping frame is sent every ping_interval seconds. |
+---------------------------+-------------------+-----------------------------------------------------------------------------+
| WEBSOCKET_PING_TIMEOUT | 20 | Connection is closed when Pong is not received after ping_timeout seconds |
+---------------------------+-------------------+-----------------------------------------------------------------------------+
| GRACEFUL_SHUTDOWN_TIMEOUT | 15.0 | How long to wait to force close non-idle connection (sec) | | GRACEFUL_SHUTDOWN_TIMEOUT | 15.0 | How long to wait to force close non-idle connection (sec) |
+---------------------------+-------------------+-----------------------------------------------------------------------------+ +---------------------------+-------------------+-----------------------------------------------------------------------------+
| ACCESS_LOG | True | Disable or enable access log | | ACCESS_LOG | True | Disable or enable access log |

View File

@ -51,5 +51,9 @@ You could setup your own WebSocket configuration through ``app.config``, like
app.config.WEBSOCKET_MAX_QUEUE = 32 app.config.WEBSOCKET_MAX_QUEUE = 32
app.config.WEBSOCKET_READ_LIMIT = 2 ** 16 app.config.WEBSOCKET_READ_LIMIT = 2 ** 16
app.config.WEBSOCKET_WRITE_LIMIT = 2 ** 16 app.config.WEBSOCKET_WRITE_LIMIT = 2 ** 16
app.config.WEBSOCKET_PING_INTERVAL = 20
app.config.WEBSOCKET_PING_TIMEOUT = 20
These settings will have no impact if running in ASGI mode.
Find more in ``Configuration`` section. Find more in ``Configuration`` section.

View File

@ -1 +1 @@
__version__ = "20.6.3" __version__ = "20.9.0"

View File

@ -676,9 +676,10 @@ class Sanic:
:param strict_slashes: Instruct :class:`Sanic` to check if the request :param strict_slashes: Instruct :class:`Sanic` to check if the request
URLs need to terminate with a */* URLs need to terminate with a */*
:param content_type: user defined content type for header :param content_type: user defined content type for header
:return: None :return: routes registered on the router
:rtype: List[sanic.router.Route]
""" """
static_register( return static_register(
self, self,
uri, uri,
file_or_directory, file_or_directory,
@ -1452,3 +1453,13 @@ class Sanic:
self.asgi = True self.asgi = True
asgi_app = await ASGIApp.create(self, scope, receive, send) asgi_app = await ASGIApp.create(self, scope, receive, send)
await asgi_app() await asgi_app()
# -------------------------------------------------------------------- #
# Configuration
# -------------------------------------------------------------------- #
def update_config(self, config: Union[bytes, str, dict, Any]):
"""Update app.config.
Please refer to config.py::Config.update_config for documentation."""
self.config.update_config(config)

View File

@ -143,7 +143,18 @@ class Blueprint:
if _routes: if _routes:
routes += _routes routes += _routes
# Static Files
for future in self.statics:
# Prepend the blueprint URI prefix if available
uri = url_prefix + future.uri if url_prefix else future.uri
_routes = app.static(
uri, future.file_or_directory, *future.args, **future.kwargs
)
if _routes:
routes += _routes
route_names = [route.name for route in routes if route] route_names = [route.name for route in routes if route]
# Middleware # Middleware
for future in self.middlewares: for future in self.middlewares:
if future.args or future.kwargs: if future.args or future.kwargs:
@ -160,14 +171,6 @@ class Blueprint:
for future in self.exceptions: for future in self.exceptions:
app.exception(*future.args, **future.kwargs)(future.handler) app.exception(*future.args, **future.kwargs)(future.handler)
# Static Files
for future in self.statics:
# Prepend the blueprint URI prefix if available
uri = url_prefix + future.uri if url_prefix else future.uri
app.static(
uri, future.file_or_directory, *future.args, **future.kwargs
)
# Event listeners # Event listeners
for event, listeners in self.listeners.items(): for event, listeners in self.listeners.items():
for listener in listeners: for listener in listeners:

View File

@ -1,8 +1,15 @@
import os from os import environ
import types from typing import Any, Union
from sanic.exceptions import PyFileError # NOTE(tomaszdrozdz): remove in version: 21.3
from sanic.helpers import import_string # We replace from_envvar(), from_object(), from_pyfile() config object methods
# with one simpler update_config() method.
# We also replace "loading module from file code" in from_pyfile()
# in a favour of load_module_from_file_location().
# Please see pull request: 1903
# and issue: 1895
from .deprecated import from_envvar, from_object, from_pyfile # noqa
from .utils import load_module_from_file_location, str_to_bool
SANIC_PREFIX = "SANIC_" SANIC_PREFIX = "SANIC_"
@ -24,12 +31,15 @@ DEFAULT_CONFIG = {
"WEBSOCKET_MAX_QUEUE": 32, "WEBSOCKET_MAX_QUEUE": 32,
"WEBSOCKET_READ_LIMIT": 2 ** 16, "WEBSOCKET_READ_LIMIT": 2 ** 16,
"WEBSOCKET_WRITE_LIMIT": 2 ** 16, "WEBSOCKET_WRITE_LIMIT": 2 ** 16,
"WEBSOCKET_PING_TIMEOUT": 20,
"WEBSOCKET_PING_INTERVAL": 20,
"GRACEFUL_SHUTDOWN_TIMEOUT": 15.0, # 15 sec "GRACEFUL_SHUTDOWN_TIMEOUT": 15.0, # 15 sec
"ACCESS_LOG": True, "ACCESS_LOG": True,
"FORWARDED_SECRET": None, "FORWARDED_SECRET": None,
"REAL_IP_HEADER": None, "REAL_IP_HEADER": None,
"PROXIES_COUNT": None, "PROXIES_COUNT": None,
"FORWARDED_FOR_HEADER": "X-Forwarded-For", "FORWARDED_FOR_HEADER": "X-Forwarded-For",
"FALLBACK_ERROR_FORMAT": "html",
} }
@ -56,76 +66,23 @@ class Config(dict):
def __setattr__(self, attr, value): def __setattr__(self, attr, value):
self[attr] = value self[attr] = value
def from_envvar(self, variable_name): # NOTE(tomaszdrozdz): remove in version: 21.3
"""Load a configuration from an environment variable pointing to # We replace from_envvar(), from_object(), from_pyfile() config object
a configuration file. # methods with one simpler update_config() method.
# We also replace "loading module from file code" in from_pyfile()
:param variable_name: name of the environment variable # in a favour of load_module_from_file_location().
:return: bool. ``True`` if able to load config, ``False`` otherwise. # Please see pull request: 1903
""" # and issue: 1895
config_file = os.environ.get(variable_name) from_envvar = from_envvar
if not config_file: from_pyfile = from_pyfile
raise RuntimeError( from_object = from_object
"The environment variable %r is not set and "
"thus configuration could not be loaded." % variable_name
)
return self.from_pyfile(config_file)
def from_pyfile(self, filename):
"""Update the values in the config from a Python file.
Only the uppercase variables in that module are stored in the config.
:param filename: an absolute path to the config file
"""
module = types.ModuleType("config")
module.__file__ = filename
try:
with open(filename) as config_file:
exec( # nosec
compile(config_file.read(), filename, "exec"),
module.__dict__,
)
except IOError as e:
e.strerror = "Unable to load configuration file (%s)" % e.strerror
raise
except Exception as e:
raise PyFileError(filename) from e
self.from_object(module)
return True
def from_object(self, obj):
"""Update the values from the given object.
Objects are usually either modules or classes.
Just the uppercase variables in that object are stored in the config.
Example usage::
from yourapplication import default_config
app.config.from_object(default_config)
or also:
app.config.from_object('myproject.config.MyConfigClass')
You should not use this function to load the actual configuration but
rather configuration defaults. The actual config should be loaded
with :meth:`from_pyfile` and ideally from a location not within the
package because the package might be installed system wide.
:param obj: an object holding the configuration
"""
if isinstance(obj, str):
obj = import_string(obj)
for key in dir(obj):
if key.isupper():
self[key] = getattr(obj, key)
def load_environment_vars(self, prefix=SANIC_PREFIX): def load_environment_vars(self, prefix=SANIC_PREFIX):
""" """
Looks for prefixed environment variables and applies Looks for prefixed environment variables and applies
them to the configuration if present. them to the configuration if present.
""" """
for k, v in os.environ.items(): for k, v in environ.items():
if k.startswith(prefix): if k.startswith(prefix):
_, config_key = k.split(prefix, 1) _, config_key = k.split(prefix, 1)
try: try:
@ -135,23 +92,47 @@ class Config(dict):
self[config_key] = float(v) self[config_key] = float(v)
except ValueError: except ValueError:
try: try:
self[config_key] = strtobool(v) self[config_key] = str_to_bool(v)
except ValueError: except ValueError:
self[config_key] = v self[config_key] = v
def update_config(self, config: Union[bytes, str, dict, Any]):
"""Update app.config.
def strtobool(val): Note:: only upper case settings are considered.
"""
This function was borrowed from distutils.utils. While distutils
is part of stdlib, it feels odd to use distutils in main application code.
The function was modified to walk its talk and actually return bool You can upload app config by providing path to py file
and not int. holding settings.
"""
val = val.lower() # /some/py/file
if val in ("y", "yes", "t", "true", "on", "1"): A = 1
return True B = 2
elif val in ("n", "no", "f", "false", "off", "0"):
return False config.update_config("${some}/py/file")
else:
raise ValueError("invalid truth value %r" % (val,)) Yes you can put environment variable here, but they must be provided
in format: ${some_env_var}, and mark that $some_env_var is treated
as plain string.
You can upload app config by providing dict holding settings.
d = {"A": 1, "B": 2}
config.update_config(d)
You can upload app config by providing any object holding settings,
but in such case config.__dict__ will be used as dict holding settings.
class C:
A = 1
B = 2
config.update_config(C)"""
if isinstance(config, (bytes, str)):
config = load_module_from_file_location(location=config)
if not isinstance(config, dict):
config = config.__dict__
config = dict(filter(lambda i: i[0].isupper(), config.items()))
self.update(config)

106
sanic/deprecated.py Normal file
View File

@ -0,0 +1,106 @@
# NOTE(tomaszdrozdz): remove in version: 21.3
# We replace from_envvar(), from_object(), from_pyfile() config object methods
# with one simpler update_config() method.
# We also replace "loading module from file code" in from_pyfile()
# in a favour of load_module_from_file_location().
# Please see pull request: 1903
# and issue: 1895
import types
from os import environ
from typing import Any
from warnings import warn
from sanic.exceptions import PyFileError
from sanic.helpers import import_string
def from_envvar(self, variable_name: str) -> bool:
"""Load a configuration from an environment variable pointing to
a configuration file.
:param variable_name: name of the environment variable
:return: bool. ``True`` if able to load config, ``False`` otherwise.
"""
warn(
"Using `from_envvar` method is deprecated and will be removed in "
"v21.3, use `app.update_config` method instead.",
DeprecationWarning,
stacklevel=2,
)
config_file = environ.get(variable_name)
if not config_file:
raise RuntimeError(
f"The environment variable {variable_name} is not set and "
f"thus configuration could not be loaded."
)
return self.from_pyfile(config_file)
def from_pyfile(self, filename: str) -> bool:
"""Update the values in the config from a Python file.
Only the uppercase variables in that module are stored in the config.
:param filename: an absolute path to the config file
"""
warn(
"Using `from_pyfile` method is deprecated and will be removed in "
"v21.3, use `app.update_config` method instead.",
DeprecationWarning,
stacklevel=2,
)
module = types.ModuleType("config")
module.__file__ = filename
try:
with open(filename) as config_file:
exec( # nosec
compile(config_file.read(), filename, "exec"),
module.__dict__,
)
except IOError as e:
e.strerror = "Unable to load configuration file (e.strerror)"
raise
except Exception as e:
raise PyFileError(filename) from e
self.from_object(module)
return True
def from_object(self, obj: Any) -> None:
"""Update the values from the given object.
Objects are usually either modules or classes.
Just the uppercase variables in that object are stored in the config.
Example usage::
from yourapplication import default_config
app.config.from_object(default_config)
or also:
app.config.from_object('myproject.config.MyConfigClass')
You should not use this function to load the actual configuration but
rather configuration defaults. The actual config should be loaded
with :meth:`from_pyfile` and ideally from a location not within the
package because the package might be installed system wide.
:param obj: an object holding the configuration
"""
warn(
"Using `from_object` method is deprecated and will be removed in "
"v21.3, use `app.update_config` method instead.",
DeprecationWarning,
stacklevel=2,
)
if isinstance(obj, str):
obj = import_string(obj)
for key in dir(obj):
if key.isupper():
self[key] = getattr(obj, key)

View File

@ -1,88 +1,75 @@
import sys import sys
import typing as t
from functools import partial
from traceback import extract_tb from traceback import extract_tb
from sanic.exceptions import SanicException from sanic.exceptions import InvalidUsage, SanicException
from sanic.helpers import STATUS_CODES from sanic.helpers import STATUS_CODES
from sanic.response import html from sanic.request import Request
from sanic.response import HTTPResponse, html, json, text
# Here, There Be Dragons (custom HTML formatting to follow) try:
from ujson import dumps
dumps = partial(dumps, escape_forward_slashes=False)
except ImportError: # noqa
from json import dumps # type: ignore
def escape(text): FALLBACK_TEXT = (
"""Minimal HTML escaping, not for attribute values (unlike html.escape).""" "The server encountered an internal error and "
return f"{text}".replace("&", "&amp;").replace("<", "&lt;") "cannot complete your request."
def exception_response(request, exception, debug):
status = 500
text = (
"The server encountered an internal error "
"and cannot complete your request."
) )
FALLBACK_STATUS = 500
headers = {}
if isinstance(exception, SanicException):
text = f"{exception}"
status = getattr(exception, "status_code", status)
headers = getattr(exception, "headers", headers)
elif debug:
text = f"{exception}"
status_text = STATUS_CODES.get(status, b"Error Occurred").decode() class BaseRenderer:
title = escape(f"{status}{status_text}") def __init__(self, request, exception, debug):
text = escape(text) self.request = request
self.exception = exception
self.debug = debug
if debug and not getattr(exception, "quiet", False): @property
return html( def headers(self):
f"<!DOCTYPE html><meta charset=UTF-8><title>{title}</title>" if isinstance(self.exception, SanicException):
f"<style>{TRACEBACK_STYLE}</style>\n" return getattr(self.exception, "headers", {})
f"<h1>⚠️ {title}</h1><p>{text}\n" return {}
f"{_render_traceback_html(request, exception)}",
status=status, @property
def status(self):
if isinstance(self.exception, SanicException):
return getattr(self.exception, "status_code", FALLBACK_STATUS)
return FALLBACK_STATUS
@property
def text(self):
if self.debug or isinstance(self.exception, SanicException):
return str(self.exception)
return FALLBACK_TEXT
@property
def title(self):
status_text = STATUS_CODES.get(self.status, b"Error Occurred").decode()
return f"{self.status}{status_text}"
def render(self):
output = (
self.full
if self.debug and not getattr(self.exception, "quiet", False)
else self.minimal
) )
return output()
# Keeping it minimal with trailing newline for pretty curl/console output def minimal(self): # noqa
return html( raise NotImplementedError
f"<!DOCTYPE html><meta charset=UTF-8><title>{title}</title>"
"<style>html { font-family: sans-serif }</style>\n" def full(self): # noqa
f"<h1>⚠️ {title}</h1><p>{text}\n", raise NotImplementedError
status=status,
headers=headers,
)
def _render_exception(exception):
frames = extract_tb(exception.__traceback__)
frame_html = "".join(TRACEBACK_LINE_HTML.format(frame) for frame in frames)
return TRACEBACK_WRAPPER_HTML.format(
exc_name=escape(exception.__class__.__name__),
exc_value=escape(exception),
frame_html=frame_html,
)
def _render_traceback_html(request, exception):
exc_type, exc_value, tb = sys.exc_info()
exceptions = []
while exc_value:
exceptions.append(_render_exception(exc_value))
exc_value = exc_value.__cause__
traceback_html = TRACEBACK_BORDER.join(reversed(exceptions))
appname = escape(request.app.name)
name = escape(exception.__class__.__name__)
value = escape(exception)
path = escape(request.path)
return (
f"<h2>Traceback of {appname} (most recent call last):</h2>"
f"{traceback_html}"
"<div class=summary><p>"
f"<b>{name}: {value}</b> while handling path <code>{path}</code>"
)
class HTMLRenderer(BaseRenderer):
TRACEBACK_STYLE = """ TRACEBACK_STYLE = """
html { font-family: sans-serif } html { font-family: sans-serif }
h2 { color: #888; } h2 { color: #888; }
@ -95,18 +82,15 @@ TRACEBACK_STYLE = """
.tb-header { background: #eee; padding: 0.3rem; font-weight: bold } .tb-header { background: #eee; padding: 0.3rem; font-weight: bold }
.frame-descriptor { background: #e2eafb; font-size: 14px } .frame-descriptor { background: #e2eafb; font-size: 14px }
""" """
TRACEBACK_WRAPPER_HTML = ( TRACEBACK_WRAPPER_HTML = (
"<div class=tb-header>{exc_name}: {exc_value}</div>" "<div class=tb-header>{exc_name}: {exc_value}</div>"
"<div class=tb-wrapper>{frame_html}</div>" "<div class=tb-wrapper>{frame_html}</div>"
) )
TRACEBACK_BORDER = ( TRACEBACK_BORDER = (
"<div class=frame-border>" "<div class=frame-border>"
"The above exception was the direct cause of the following exception:" "The above exception was the direct cause of the following exception:"
"</div>" "</div>"
) )
TRACEBACK_LINE_HTML = ( TRACEBACK_LINE_HTML = (
"<div class=frame-line>" "<div class=frame-line>"
"<p class=frame-descriptor>" "<p class=frame-descriptor>"
@ -115,3 +99,232 @@ TRACEBACK_LINE_HTML = (
"<p class=frame-code><code>{0.line}</code>" "<p class=frame-code><code>{0.line}</code>"
"</div>" "</div>"
) )
OUTPUT_HTML = (
"<!DOCTYPE html><html lang=en>"
"<meta charset=UTF-8><title>{title}</title>\n"
"<style>{style}</style>\n"
"<h1>{title}</h1><p>{text}\n"
"{body}"
)
def full(self):
return html(
self.OUTPUT_HTML.format(
title=self.title,
text=self.text,
style=self.TRACEBACK_STYLE,
body=self._generate_body(),
),
status=self.status,
)
def minimal(self):
return html(
self.OUTPUT_HTML.format(
title=self.title,
text=self.text,
style=self.TRACEBACK_STYLE,
body="",
),
status=self.status,
headers=self.headers,
)
@property
def text(self):
return escape(super().text)
@property
def title(self):
return escape(f"⚠️ {super().title}")
def _generate_body(self):
_, exc_value, __ = sys.exc_info()
exceptions = []
while exc_value:
exceptions.append(self._format_exc(exc_value))
exc_value = exc_value.__cause__
traceback_html = self.TRACEBACK_BORDER.join(reversed(exceptions))
appname = escape(self.request.app.name)
name = escape(self.exception.__class__.__name__)
value = escape(self.exception)
path = escape(self.request.path)
lines = [
f"<h2>Traceback of {appname} (most recent call last):</h2>",
f"{traceback_html}",
"<div class=summary><p>",
f"<b>{name}: {value}</b> while handling path <code>{path}</code>",
"</div>",
]
return "\n".join(lines)
def _format_exc(self, exc):
frames = extract_tb(exc.__traceback__)
frame_html = "".join(
self.TRACEBACK_LINE_HTML.format(frame) for frame in frames
)
return self.TRACEBACK_WRAPPER_HTML.format(
exc_name=escape(exc.__class__.__name__),
exc_value=escape(exc),
frame_html=frame_html,
)
class TextRenderer(BaseRenderer):
OUTPUT_TEXT = "{title}\n{bar}\n{text}\n\n{body}"
SPACER = " "
def full(self):
return text(
self.OUTPUT_TEXT.format(
title=self.title,
text=self.text,
bar=("=" * len(self.title)),
body=self._generate_body(),
),
status=self.status,
)
def minimal(self):
return text(
self.OUTPUT_TEXT.format(
title=self.title,
text=self.text,
bar=("=" * len(self.title)),
body="",
),
status=self.status,
headers=self.headers,
)
@property
def title(self):
return f"⚠️ {super().title}"
def _generate_body(self):
_, exc_value, __ = sys.exc_info()
exceptions = []
# traceback_html = self.TRACEBACK_BORDER.join(reversed(exceptions))
lines = [
f"{self.exception.__class__.__name__}: {self.exception} while "
f"handling path {self.request.path}",
f"Traceback of {self.request.app.name} (most recent call last):\n",
]
while exc_value:
exceptions.append(self._format_exc(exc_value))
exc_value = exc_value.__cause__
return "\n".join(lines + exceptions[::-1])
def _format_exc(self, exc):
frames = "\n\n".join(
[
f"{self.SPACER * 2}File {frame.filename}, "
f"line {frame.lineno}, in "
f"{frame.name}\n{self.SPACER * 2}{frame.line}"
for frame in extract_tb(exc.__traceback__)
]
)
return f"{self.SPACER}{exc.__class__.__name__}: {exc}\n{frames}"
class JSONRenderer(BaseRenderer):
def full(self):
output = self._generate_output(full=True)
return json(output, status=self.status, dumps=dumps)
def minimal(self):
output = self._generate_output(full=False)
return json(output, status=self.status, dumps=dumps)
def _generate_output(self, *, full):
output = {
"description": self.title,
"status": self.status,
"message": self.text,
}
if full:
_, exc_value, __ = sys.exc_info()
exceptions = []
while exc_value:
exceptions.append(
{
"type": exc_value.__class__.__name__,
"exception": str(exc_value),
"frames": [
{
"file": frame.filename,
"line": frame.lineno,
"name": frame.name,
"src": frame.line,
}
for frame in extract_tb(exc_value.__traceback__)
],
}
)
exc_value = exc_value.__cause__
output["path"] = self.request.path
output["args"] = self.request.args
output["exceptions"] = exceptions[::-1]
return output
@property
def title(self):
return STATUS_CODES.get(self.status, b"Error Occurred").decode()
def escape(text):
"""Minimal HTML escaping, not for attribute values (unlike html.escape)."""
return f"{text}".replace("&", "&amp;").replace("<", "&lt;")
RENDERERS_BY_CONFIG = {
"html": HTMLRenderer,
"json": JSONRenderer,
"text": TextRenderer,
}
RENDERERS_BY_CONTENT_TYPE = {
"multipart/form-data": HTMLRenderer,
"application/json": JSONRenderer,
"text/plain": TextRenderer,
}
def exception_response(
request: Request,
exception: Exception,
debug: bool,
renderer: t.Type[t.Optional[BaseRenderer]] = None,
) -> HTTPResponse:
"""Render a response for the default FALLBACK exception handler"""
if not renderer:
renderer = HTMLRenderer
if request:
if request.app.config.FALLBACK_ERROR_FORMAT == "auto":
try:
renderer = JSONRenderer if request.json else HTMLRenderer
except InvalidUsage:
renderer = HTMLRenderer
content_type, *_ = request.headers.get(
"content-type", ""
).split(";")
renderer = RENDERERS_BY_CONTENT_TYPE.get(
content_type, renderer
)
else:
render_format = request.app.config.FALLBACK_ERROR_FORMAT
renderer = RENDERERS_BY_CONFIG.get(render_format, renderer)
renderer = t.cast(t.Type[BaseRenderer], renderer)
return renderer(request, exception, debug).render()

View File

@ -169,6 +169,10 @@ class Unauthorized(SanicException):
} }
class LoadFileException(SanicException):
pass
def abort(status_code, message=None): def abort(status_code, message=None):
""" """
Raise an exception based on SanicException. Returns the HTTP response Raise an exception based on SanicException. Returns the HTTP response

View File

@ -249,7 +249,10 @@ def raw(
:param content_type: the content type (string) of the response. :param content_type: the content type (string) of the response.
""" """
return HTTPResponse( return HTTPResponse(
body=body, status=status, headers=headers, content_type=content_type, body=body,
status=status,
headers=headers,
content_type=content_type,
) )

View File

@ -14,11 +14,13 @@ from ipaddress import ip_address
from signal import SIG_IGN, SIGINT, SIGTERM, Signals from signal import SIG_IGN, SIGINT, SIGTERM, Signals
from signal import signal as signal_func from signal import signal as signal_func
from time import time from time import time
from typing import Dict, Type, Union
from httptools import HttpRequestParser # type: ignore from httptools import HttpRequestParser # type: ignore
from httptools.parser.errors import HttpParserError # type: ignore from httptools.parser.errors import HttpParserError # type: ignore
from sanic.compat import Header, ctrlc_workaround_for_windows from sanic.compat import Header, ctrlc_workaround_for_windows
from sanic.config import Config
from sanic.exceptions import ( from sanic.exceptions import (
HeaderExpectationFailed, HeaderExpectationFailed,
InvalidUsage, InvalidUsage,
@ -416,6 +418,7 @@ class HttpProtocol(asyncio.Protocol):
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()
if self.request:
if self.request.stream.is_full(): if self.request.stream.is_full():
self.transport.pause_reading() self.transport.pause_reading()
await self.request.stream.put(body) await self.request.stream.put(body)
@ -844,6 +847,7 @@ def serve(
app.asgi = False app.asgi = False
connections = connections if connections is not None else set() connections = connections if connections is not None else set()
protocol_kwargs = _build_protocol_kwargs(protocol, app.config)
server = partial( server = partial(
protocol, protocol,
loop=loop, loop=loop,
@ -852,6 +856,7 @@ def serve(
app=app, app=app,
state=state, state=state,
unix=unix, unix=unix,
**protocol_kwargs,
) )
asyncio_server_kwargs = ( asyncio_server_kwargs = (
asyncio_server_kwargs if asyncio_server_kwargs else {} asyncio_server_kwargs if asyncio_server_kwargs else {}
@ -948,6 +953,21 @@ def serve(
remove_unix_socket(unix) remove_unix_socket(unix)
def _build_protocol_kwargs(
protocol: Type[HttpProtocol], config: Config
) -> Dict[str, Union[int, float]]:
if hasattr(protocol, "websocket_handshake"):
return {
"websocket_max_size": config.WEBSOCKET_MAX_SIZE,
"websocket_max_queue": config.WEBSOCKET_MAX_QUEUE,
"websocket_read_limit": config.WEBSOCKET_READ_LIMIT,
"websocket_write_limit": config.WEBSOCKET_WRITE_LIMIT,
"websocket_ping_timeout": config.WEBSOCKET_PING_TIMEOUT,
"websocket_ping_interval": config.WEBSOCKET_PING_INTERVAL,
}
return {}
def bind_socket(host: str, port: int, *, backlog=100) -> socket.socket: def bind_socket(host: str, port: int, *, backlog=100) -> socket.socket:
"""Create TCP server socket. """Create TCP server socket.
:param host: IPv4, IPv6 or hostname may be specified :param host: IPv4, IPv6 or hostname may be specified

View File

@ -134,6 +134,8 @@ def register(
threshold size to switch to file_stream() threshold size to switch to file_stream()
:param name: user defined name used for url_for :param name: user defined name used for url_for
:param content_type: user defined content type for header :param content_type: user defined content type for header
:return: registered static routes
:rtype: List[sanic.router.Route]
""" """
# If we're not trying to match a file directly, # If we're not trying to match a file directly,
# serve from the folder # serve from the folder
@ -155,10 +157,11 @@ def register(
) )
) )
app.route( _routes, _ = app.route(
uri, uri,
methods=["GET", "HEAD"], methods=["GET", "HEAD"],
name=name, name=name,
host=host, host=host,
strict_slashes=strict_slashes, strict_slashes=strict_slashes,
)(_handler) )(_handler)
return _routes

View File

@ -11,6 +11,8 @@ from sanic.response import text
ASGI_HOST = "mockserver" ASGI_HOST = "mockserver"
ASGI_PORT = 1234
ASGI_BASE_URL = f"http://{ASGI_HOST}:{ASGI_PORT}"
HOST = "127.0.0.1" HOST = "127.0.0.1"
PORT = None PORT = None
@ -103,7 +105,9 @@ class SanicTestClient:
if self.port: if self.port:
server_kwargs = dict( server_kwargs = dict(
host=host or self.host, port=self.port, **server_kwargs, host=host or self.host,
port=self.port,
**server_kwargs,
) )
host, port = host or self.host, self.port host, port = host or self.host, self.port
else: else:
@ -193,24 +197,19 @@ async def app_call_with_return(self, scope, receive, send):
return await asgi_app() return await asgi_app()
class SanicASGIDispatch(httpx.ASGIDispatch):
pass
class SanicASGITestClient(httpx.AsyncClient): class SanicASGITestClient(httpx.AsyncClient):
def __init__( def __init__(
self, self,
app, app,
base_url: str = f"http://{ASGI_HOST}", base_url: str = ASGI_BASE_URL,
suppress_exceptions: bool = False, suppress_exceptions: bool = False,
) -> None: ) -> None:
app.__class__.__call__ = app_call_with_return app.__class__.__call__ = app_call_with_return
app.asgi = True app.asgi = True
self.app = app self.app = app
transport = httpx.ASGITransport(app=app, client=(ASGI_HOST, ASGI_PORT))
dispatch = SanicASGIDispatch(app=app, client=(ASGI_HOST, PORT or 0)) super().__init__(transport=transport, base_url=base_url)
super().__init__(dispatch=dispatch, base_url=base_url)
self.last_request = None self.last_request = None

99
sanic/utils.py Normal file
View File

@ -0,0 +1,99 @@
from importlib.util import module_from_spec, spec_from_file_location
from os import environ as os_environ
from re import findall as re_findall
from typing import Union
from .exceptions import LoadFileException
def str_to_bool(val: str) -> bool:
"""Takes string and tries to turn it into bool as human would do.
If val is in case insensitive (
"y", "yes", "yep", "yup", "t",
"true", "on", "enable", "enabled", "1"
) returns True.
If val is in case insensitive (
"n", "no", "f", "false", "off", "disable", "disabled", "0"
) returns False.
Else Raise ValueError."""
val = val.lower()
if val in {
"y",
"yes",
"yep",
"yup",
"t",
"true",
"on",
"enable",
"enabled",
"1",
}:
return True
elif val in {"n", "no", "f", "false", "off", "disable", "disabled", "0"}:
return False
else:
raise ValueError(f"Invalid truth value {val}")
def load_module_from_file_location(
location: Union[bytes, str], encoding: str = "utf8", *args, **kwargs
):
"""Returns loaded module provided as a file path.
:param args:
Coresponds to importlib.util.spec_from_file_location location
parameters,but with this differences:
- It has to be of a string or bytes type.
- You can also use here environment variables
in format ${some_env_var}.
Mark that $some_env_var will not be resolved as environment variable.
:encoding:
If location parameter is of a bytes type, then use this encoding
to decode it into string.
:param args:
Coresponds to the rest of importlib.util.spec_from_file_location
parameters.
:param kwargs:
Coresponds to the rest of importlib.util.spec_from_file_location
parameters.
For example You can:
some_module = load_module_from_file_location(
"some_module_name",
"/some/path/${some_env_var}"
)
"""
# 1) Parse location.
if isinstance(location, bytes):
location = location.decode(encoding)
# A) Check if location contains any environment variables
# in format ${some_env_var}.
env_vars_in_location = set(re_findall(r"\${(.+?)}", location))
# B) Check these variables exists in environment.
not_defined_env_vars = env_vars_in_location.difference(os_environ.keys())
if not_defined_env_vars:
raise LoadFileException(
"The following environment variables are not set: "
f"{', '.join(not_defined_env_vars)}"
)
# C) Substitute them in location.
for env_var in env_vars_in_location:
location = location.replace("${" + env_var + "}", os_environ[env_var])
# 2) Load and return module.
name = location.split("/")[-1].split(".")[
0
] # get just the file name without path and .py extension
_mod_spec = spec_from_file_location(name, location, *args, **kwargs)
module = module_from_spec(_mod_spec)
_mod_spec.loader.exec_module(module) # type: ignore
return module

View File

@ -35,6 +35,8 @@ class WebSocketProtocol(HttpProtocol):
websocket_max_queue=None, websocket_max_queue=None,
websocket_read_limit=2 ** 16, websocket_read_limit=2 ** 16,
websocket_write_limit=2 ** 16, websocket_write_limit=2 ** 16,
websocket_ping_interval=20,
websocket_ping_timeout=20,
**kwargs **kwargs
): ):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -45,6 +47,8 @@ class WebSocketProtocol(HttpProtocol):
self.websocket_max_queue = websocket_max_queue self.websocket_max_queue = websocket_max_queue
self.websocket_read_limit = websocket_read_limit self.websocket_read_limit = websocket_read_limit
self.websocket_write_limit = websocket_write_limit self.websocket_write_limit = websocket_write_limit
self.websocket_ping_interval = websocket_ping_interval
self.websocket_ping_timeout = websocket_ping_timeout
# timeouts make no sense for websocket routes # timeouts make no sense for websocket routes
def request_timeout_callback(self): def request_timeout_callback(self):
@ -119,6 +123,8 @@ class WebSocketProtocol(HttpProtocol):
max_queue=self.websocket_max_queue, max_queue=self.websocket_max_queue,
read_limit=self.websocket_read_limit, read_limit=self.websocket_read_limit,
write_limit=self.websocket_write_limit, write_limit=self.websocket_write_limit,
ping_interval=self.websocket_ping_interval,
ping_timeout=self.websocket_ping_timeout,
) )
# Following two lines are required for websockets 8.x # Following two lines are required for websockets 8.x
self.websocket.is_client = False self.websocket.is_client = False

View File

@ -81,7 +81,7 @@ requirements = [
"aiofiles>=0.3.0", "aiofiles>=0.3.0",
"websockets>=8.1,<9.0", "websockets>=8.1,<9.0",
"multidict>=4.0,<5.0", "multidict>=4.0,<5.0",
"httpx==0.11.1", "httpx==0.15.4",
] ]
tests_require = [ tests_require = [

View File

@ -0,0 +1 @@
TEST_SETTING_VALUE = 1

View File

@ -3,6 +3,7 @@ import logging
import sys import sys
from inspect import isawaitable from inspect import isawaitable
from unittest.mock import patch
import pytest import pytest
@ -125,7 +126,7 @@ def test_app_handle_request_handler_is_none(app, monkeypatch):
def handler(request): def handler(request):
return text("test") return text("test")
request, response = app.test_client.get("/test") _, response = app.test_client.get("/test")
assert ( assert (
"'None' was returned while requesting a handler from the router" "'None' was returned while requesting a handler from the router"
@ -148,6 +149,43 @@ def test_app_enable_websocket(app, websocket_enabled, enable):
assert app.websocket_enabled == True assert app.websocket_enabled == True
@patch("sanic.app.WebSocketProtocol")
def test_app_websocket_parameters(websocket_protocol_mock, app):
app.config.WEBSOCKET_MAX_SIZE = 44
app.config.WEBSOCKET_MAX_QUEUE = 45
app.config.WEBSOCKET_READ_LIMIT = 46
app.config.WEBSOCKET_WRITE_LIMIT = 47
app.config.WEBSOCKET_PING_TIMEOUT = 48
app.config.WEBSOCKET_PING_INTERVAL = 50
@app.websocket("/ws")
async def handler(request, ws):
await ws.send("test")
try:
# This will fail because WebSocketProtocol is mocked and only the call kwargs matter
app.test_client.get("/ws")
except:
pass
websocket_protocol_call_args = websocket_protocol_mock.call_args
ws_kwargs = websocket_protocol_call_args[1]
assert ws_kwargs["websocket_max_size"] == app.config.WEBSOCKET_MAX_SIZE
assert ws_kwargs["websocket_max_queue"] == app.config.WEBSOCKET_MAX_QUEUE
assert ws_kwargs["websocket_read_limit"] == app.config.WEBSOCKET_READ_LIMIT
assert (
ws_kwargs["websocket_write_limit"] == app.config.WEBSOCKET_WRITE_LIMIT
)
assert (
ws_kwargs["websocket_ping_timeout"]
== app.config.WEBSOCKET_PING_TIMEOUT
)
assert (
ws_kwargs["websocket_ping_interval"]
== app.config.WEBSOCKET_PING_INTERVAL
)
def test_handle_request_with_nested_exception(app, monkeypatch): def test_handle_request_with_nested_exception(app, monkeypatch):
err_msg = "Mock Exception" err_msg = "Mock Exception"

View File

@ -735,6 +735,36 @@ def test_static_blueprint_name(app: Sanic, static_file_directory, file_name):
_, response = app.test_client.get("/static/test.file/") _, response = app.test_client.get("/static/test.file/")
assert response.status == 200 assert response.status == 200
@pytest.mark.parametrize("file_name", ["test.file"])
def test_static_blueprintp_mw(app: Sanic, static_file_directory, file_name):
current_file = inspect.getfile(inspect.currentframe())
with open(current_file, "rb") as file:
file.read()
triggered = False
bp = Blueprint(name="test_mw", url_prefix="")
@bp.middleware('request')
def bp_mw1(request):
nonlocal triggered
triggered = True
bp.static(
"/test.file",
get_file_path(static_file_directory, file_name),
strict_slashes=True,
name="static"
)
app.blueprint(bp)
uri = app.url_for("test_mw.static")
assert uri == "/test.file"
_, response = app.test_client.get("/test.file")
assert triggered is True
def test_route_handler_add(app: Sanic): def test_route_handler_add(app: Sanic):
view = CompositionView() view = CompositionView()

86
tests/test_errorpages.py Normal file
View File

@ -0,0 +1,86 @@
import pytest
from sanic import Sanic
from sanic.errorpages import exception_response
from sanic.exceptions import NotFound
from sanic.request import Request
from sanic.response import HTTPResponse
@pytest.fixture
def app():
app = Sanic("error_page_testing")
@app.route("/error", methods=["GET", "POST"])
def err(request):
raise Exception("something went wrong")
return app
@pytest.fixture
def fake_request(app):
return Request(b"/foobar", {}, "1.1", "GET", None, app)
@pytest.mark.parametrize(
"fallback,content_type, exception, status",
(
(None, "text/html; charset=utf-8", Exception, 500),
("html", "text/html; charset=utf-8", Exception, 500),
("auto", "text/html; charset=utf-8", Exception, 500),
("text", "text/plain; charset=utf-8", Exception, 500),
("json", "application/json", Exception, 500),
(None, "text/html; charset=utf-8", NotFound, 404),
("html", "text/html; charset=utf-8", NotFound, 404),
("auto", "text/html; charset=utf-8", NotFound, 404),
("text", "text/plain; charset=utf-8", NotFound, 404),
("json", "application/json", NotFound, 404),
),
)
def test_should_return_html_valid_setting(
fake_request, fallback, content_type, exception, status
):
if fallback:
fake_request.app.config.FALLBACK_ERROR_FORMAT = fallback
try:
raise exception("bad stuff")
except Exception as e:
response = exception_response(fake_request, e, True)
assert isinstance(response, HTTPResponse)
assert response.status == status
assert response.content_type == content_type
def test_auto_fallback_with_data(app):
app.config.FALLBACK_ERROR_FORMAT = "auto"
_, response = app.test_client.get("/error")
assert response.status == 500
assert response.content_type == "text/html; charset=utf-8"
_, response = app.test_client.post("/error", json={"foo": "bar"})
assert response.status == 500
assert response.content_type == "application/json"
_, response = app.test_client.post("/error", data={"foo": "bar"})
assert response.status == 500
assert response.content_type == "text/html; charset=utf-8"
def test_auto_fallback_with_content_type(app):
app.config.FALLBACK_ERROR_FORMAT = "auto"
_, response = app.test_client.get(
"/error", headers={"content-type": "application/json"}
)
assert response.status == 500
assert response.content_type == "application/json"
_, response = app.test_client.get(
"/error", headers={"content-type": "text/plain"}
)
assert response.status == 500
assert response.content_type == "text/plain; charset=utf-8"

View File

@ -3,6 +3,7 @@ import asyncio
from asyncio import sleep as aio_sleep from asyncio import sleep as aio_sleep
from json import JSONDecodeError from json import JSONDecodeError
import httpcore
import httpx import httpx
from sanic import Sanic, server from sanic import Sanic, server
@ -12,67 +13,26 @@ from sanic.testing import HOST, SanicTestClient
CONFIG_FOR_TESTS = {"KEEP_ALIVE_TIMEOUT": 2, "KEEP_ALIVE": True} CONFIG_FOR_TESTS = {"KEEP_ALIVE_TIMEOUT": 2, "KEEP_ALIVE": True}
old_conn = None
PORT = 42101 # test_keep_alive_timeout_reuse doesn't work with random port PORT = 42101 # test_keep_alive_timeout_reuse doesn't work with random port
from httpcore._async.base import ConnectionState
from httpcore._async.connection import AsyncHTTPConnection
from httpcore._types import Origin
class ReusableSanicConnectionPool(
httpx.dispatch.connection_pool.ConnectionPool
):
@property
def cert(self):
return self.ssl.cert
@property class ReusableSanicConnectionPool(httpcore.AsyncConnectionPool):
def verify(self): last_reused_connection = None
return self.ssl.verify
@property async def _get_connection_from_pool(self, *args, **kwargs):
def trust_env(self): conn = await super()._get_connection_from_pool(*args, **kwargs)
return self.ssl.trust_env self.__class__.last_reused_connection = conn
return conn
@property
def http2(self):
return self.ssl.http2
async def acquire_connection(self, origin, timeout):
global old_conn
connection = self.pop_connection(origin)
if connection is None:
pool_timeout = None if timeout is None else timeout.pool_timeout
await self.max_connections.acquire(timeout=pool_timeout)
ssl_config = httpx.config.SSLConfig(
cert=self.cert,
verify=self.verify,
trust_env=self.trust_env,
http2=self.http2,
)
connection = httpx.dispatch.connection.HTTPConnection(
origin,
ssl=ssl_config,
backend=self.backend,
release_func=self.release_connection,
uds=self.uds,
)
self.active_connections.add(connection)
if old_conn is not None:
if old_conn != connection:
raise RuntimeError(
"We got a new connection, wanted the same one!"
)
old_conn = connection
return connection
class ResusableSanicSession(httpx.AsyncClient): class ResusableSanicSession(httpx.AsyncClient):
def __init__(self, *args, **kwargs) -> None: def __init__(self, *args, **kwargs) -> None:
dispatch = ReusableSanicConnectionPool() transport = ReusableSanicConnectionPool()
super().__init__(dispatch=dispatch, *args, **kwargs) super().__init__(transport=transport, *args, **kwargs)
class ReuseableSanicTestClient(SanicTestClient): class ReuseableSanicTestClient(SanicTestClient):
@ -258,6 +218,7 @@ def test_keep_alive_timeout_reuse():
request, response = client.get("/1") request, response = client.get("/1")
assert response.status == 200 assert response.status == 200
assert response.text == "OK" assert response.text == "OK"
assert ReusableSanicConnectionPool.last_reused_connection
finally: finally:
client.kill_server() client.kill_server()
@ -270,7 +231,6 @@ def test_keep_alive_client_timeout():
asyncio.set_event_loop(loop) asyncio.set_event_loop(loop)
client = ReuseableSanicTestClient(keep_alive_app_client_timeout, loop) client = ReuseableSanicTestClient(keep_alive_app_client_timeout, loop)
headers = {"Connection": "keep-alive"} headers = {"Connection": "keep-alive"}
try:
request, response = client.get( request, response = client.get(
"/1", headers=headers, request_keepalive=1 "/1", headers=headers, request_keepalive=1
) )
@ -279,11 +239,7 @@ def test_keep_alive_client_timeout():
loop.run_until_complete(aio_sleep(2)) loop.run_until_complete(aio_sleep(2))
exception = None exception = None
request, response = client.get("/1", request_keepalive=1) request, response = client.get("/1", request_keepalive=1)
except ValueError as e: assert ReusableSanicConnectionPool.last_reused_connection is None
exception = e
assert exception is not None
assert isinstance(exception, ValueError)
assert "got a new connection" in exception.args[0]
finally: finally:
client.kill_server() client.kill_server()
@ -298,7 +254,6 @@ def test_keep_alive_server_timeout():
asyncio.set_event_loop(loop) asyncio.set_event_loop(loop)
client = ReuseableSanicTestClient(keep_alive_app_server_timeout, loop) client = ReuseableSanicTestClient(keep_alive_app_server_timeout, loop)
headers = {"Connection": "keep-alive"} headers = {"Connection": "keep-alive"}
try:
request, response = client.get( request, response = client.get(
"/1", headers=headers, request_keepalive=60 "/1", headers=headers, request_keepalive=60
) )
@ -307,13 +262,6 @@ def test_keep_alive_server_timeout():
loop.run_until_complete(aio_sleep(3)) loop.run_until_complete(aio_sleep(3))
exception = None exception = None
request, response = client.get("/1", request_keepalive=60) request, response = client.get("/1", request_keepalive=60)
except ValueError as e: assert ReusableSanicConnectionPool.last_reused_connection is None
exception = e
assert exception is not None
assert isinstance(exception, ValueError)
assert (
"Connection reset" in exception.args[0]
or "got a new connection" in exception.args[0]
)
finally: finally:
client.kill_server() client.kill_server()

View File

@ -0,0 +1,35 @@
from pathlib import Path
from types import ModuleType
import pytest
from sanic.exceptions import LoadFileException
from sanic.utils import load_module_from_file_location
@pytest.fixture
def loaded_module_from_file_location():
return load_module_from_file_location(
str(Path(__file__).parent / "static/app_test_config.py")
)
@pytest.mark.dependency(name="test_load_module_from_file_location")
def test_load_module_from_file_location(loaded_module_from_file_location):
assert isinstance(loaded_module_from_file_location, ModuleType)
@pytest.mark.dependency(depends=["test_load_module_from_file_location"])
def test_loaded_module_from_file_location_name(
loaded_module_from_file_location,
):
assert loaded_module_from_file_location.__name__ == "app_test_config"
def test_load_module_from_file_location_with_non_existing_env_variable():
with pytest.raises(
LoadFileException,
match="The following environment variables are not set: MuuMilk",
):
load_module_from_file_location("${MuuMilk}")

View File

@ -46,8 +46,8 @@ def test_custom_context(app):
invalid = str(e) invalid = str(e)
j = loads(response.body) j = loads(response.body)
j['response_mw_valid'] = user j["response_mw_valid"] = user
j['response_mw_invalid'] = invalid j["response_mw_invalid"] = invalid
return json(j) return json(j)
request, response = app.test_client.get("/") request, response = app.test_client.get("/")
@ -59,8 +59,7 @@ def test_custom_context(app):
"has_missing": False, "has_missing": False,
"invalid": "'types.SimpleNamespace' object has no attribute 'missing'", "invalid": "'types.SimpleNamespace' object has no attribute 'missing'",
"response_mw_valid": "sanic", "response_mw_valid": "sanic",
"response_mw_invalid": "response_mw_invalid": "'types.SimpleNamespace' object has no attribute 'missing'",
"'types.SimpleNamespace' object has no attribute 'missing'"
} }

View File

@ -1,9 +1,12 @@
import asyncio
import pytest import pytest
from sanic.blueprints import Blueprint from sanic.blueprints import Blueprint
from sanic.exceptions import HeaderExpectationFailed from sanic.exceptions import HeaderExpectationFailed
from sanic.request import StreamBuffer from sanic.request import StreamBuffer
from sanic.response import json, stream, text from sanic.response import json, stream, text
from sanic.server import HttpProtocol
from sanic.views import CompositionView, HTTPMethodView from sanic.views import CompositionView, HTTPMethodView
from sanic.views import stream as stream_decorator from sanic.views import stream as stream_decorator
@ -337,6 +340,22 @@ def test_request_stream_handle_exception(app):
assert "Method GET not allowed for URL /post/random_id" in response.text assert "Method GET not allowed for URL /post/random_id" in response.text
@pytest.mark.asyncio
async def test_request_stream_unread(app):
"""ensure no error is raised when leaving unread bytes in byte-buffer"""
err = None
protocol = HttpProtocol(loop=asyncio.get_event_loop(), app=app)
try:
protocol.request = None
protocol._body_chunks.append("this is a test")
await protocol.stream_append()
except AttributeError as e:
err = e
assert err is None and not protocol._body_chunks
def test_request_stream_blueprint(app): def test_request_stream_blueprint(app):
"""for self.is_request_stream = True""" """for self.is_request_stream = True"""
bp = Blueprint("test_blueprint_request_stream_blueprint") bp = Blueprint("test_blueprint_request_stream_blueprint")

View File

@ -1,64 +1,54 @@
import asyncio import asyncio
from typing import cast
import httpcore
import httpx import httpx
from httpcore._async.base import (
AsyncByteStream,
AsyncHTTPTransport,
ConnectionState,
NewConnectionRequired,
)
from httpcore._async.connection import AsyncHTTPConnection
from httpcore._async.connection_pool import ResponseByteStream
from httpcore._exceptions import LocalProtocolError, UnsupportedProtocol
from httpcore._types import TimeoutDict
from httpcore._utils import url_to_origin
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 DelayableHTTPConnection(httpx.dispatch.connection.HTTPConnection): class DelayableHTTPConnection(httpcore._async.connection.AsyncHTTPConnection):
def __init__(self, *args, **kwargs): async def arequest(self, *args, **kwargs):
self._request_delay = None await asyncio.sleep(2)
if "request_delay" in kwargs: return await super().arequest(*args, **kwargs)
self._request_delay = kwargs.pop("request_delay")
super().__init__(*args, **kwargs)
async def send(self, request, timeout=None):
if self.connection is None:
self.connection = await self.connect(timeout=timeout)
async def _open_socket(self, *args, **kwargs):
retval = await super()._open_socket(*args, **kwargs)
if self._request_delay: if self._request_delay:
await asyncio.sleep(self._request_delay) await asyncio.sleep(self._request_delay)
return retval
response = await self.connection.send(request, timeout=timeout)
return response
class DelayableSanicConnectionPool( class DelayableSanicConnectionPool(httpcore.AsyncConnectionPool):
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 acquire_connection(self, origin, timeout=None): async def _add_to_pool(self, connection, timeout):
connection = self.pop_connection(origin) connection.__class__ = DelayableHTTPConnection
connection._request_delay = self._request_delay
if connection is None: await super()._add_to_pool(connection, timeout)
pool_timeout = None if timeout is None else timeout.pool_timeout
await self.max_connections.acquire(timeout=pool_timeout)
connection = DelayableHTTPConnection(
origin,
ssl=self.ssl,
backend=self.backend,
release_func=self.release_connection,
uds=self.uds,
request_delay=self._request_delay,
)
self.active_connections.add(connection)
return connection
class DelayableSanicSession(httpx.AsyncClient): class DelayableSanicSession(httpx.AsyncClient):
def __init__(self, request_delay=None, *args, **kwargs) -> None: def __init__(self, request_delay=None, *args, **kwargs) -> None:
dispatch = DelayableSanicConnectionPool(request_delay=request_delay) transport = DelayableSanicConnectionPool(request_delay=request_delay)
super().__init__(dispatch=dispatch, *args, **kwargs) super().__init__(transport=transport, *args, **kwargs)
class DelayableSanicTestClient(SanicTestClient): class DelayableSanicTestClient(SanicTestClient):

View File

@ -12,7 +12,14 @@ from sanic import Blueprint, Sanic
from sanic.exceptions import ServerError from sanic.exceptions import ServerError
from sanic.request import DEFAULT_HTTP_CONTENT_TYPE, Request, RequestParameters from sanic.request import DEFAULT_HTTP_CONTENT_TYPE, Request, RequestParameters
from sanic.response import html, json, text from sanic.response import html, json, text
from sanic.testing import ASGI_HOST, HOST, PORT, SanicTestClient from sanic.testing import (
ASGI_BASE_URL,
ASGI_HOST,
ASGI_PORT,
HOST,
PORT,
SanicTestClient,
)
# ------------------------------------------------------------ # # ------------------------------------------------------------ #
@ -59,7 +66,10 @@ async def test_ip_asgi(app):
request, response = await app.asgi_client.get("/") request, response = await app.asgi_client.get("/")
assert response.text == "http://mockserver/" if response.text.endswith("/") and not ASGI_BASE_URL.endswith("/"):
response.text[:-1] == ASGI_BASE_URL
else:
assert response.text == ASGI_BASE_URL
def test_text(app): def test_text(app):
@ -573,7 +583,7 @@ async def test_standard_forwarded_asgi(app):
assert response.json() == {"for": "127.0.0.2", "proto": "ws"} assert response.json() == {"for": "127.0.0.2", "proto": "ws"}
assert request.remote_addr == "127.0.0.2" assert request.remote_addr == "127.0.0.2"
assert request.scheme == "ws" assert request.scheme == "ws"
assert request.server_port == 80 assert request.server_port == ASGI_PORT
app.config.FORWARDED_SECRET = "mySecret" app.config.FORWARDED_SECRET = "mySecret"
request, response = await app.asgi_client.get("/", headers=headers) request, response = await app.asgi_client.get("/", headers=headers)
@ -1044,9 +1054,9 @@ def test_url_attributes_no_ssl(app, path, query, expected_url):
@pytest.mark.parametrize( @pytest.mark.parametrize(
"path,query,expected_url", "path,query,expected_url",
[ [
("/foo", "", "http://{}/foo"), ("/foo", "", "{}/foo"),
("/bar/baz", "", "http://{}/bar/baz"), ("/bar/baz", "", "{}/bar/baz"),
("/moo/boo", "arg1=val1", "http://{}/moo/boo?arg1=val1"), ("/moo/boo", "arg1=val1", "{}/moo/boo?arg1=val1"),
], ],
) )
@pytest.mark.asyncio @pytest.mark.asyncio
@ -1057,7 +1067,7 @@ async def test_url_attributes_no_ssl_asgi(app, path, query, expected_url):
app.add_route(handler, path) app.add_route(handler, path)
request, response = await app.asgi_client.get(path + f"?{query}") request, response = await app.asgi_client.get(path + f"?{query}")
assert request.url == expected_url.format(ASGI_HOST) assert request.url == expected_url.format(ASGI_BASE_URL)
parsed = urlparse(request.url) parsed = urlparse(request.url)

View File

@ -4,6 +4,7 @@ import os
import subprocess import subprocess
import sys import sys
import httpcore
import httpx import httpx
import pytest import pytest
@ -139,8 +140,9 @@ def test_unix_connection():
@app.listener("after_server_start") @app.listener("after_server_start")
async def client(app, loop): async def client(app, loop):
transport = httpcore.AsyncConnectionPool(uds=SOCKPATH)
try: try:
async with httpx.AsyncClient(uds=SOCKPATH) as client: async with httpx.AsyncClient(transport=transport) as client:
r = await client.get("http://myhost.invalid/") r = await client.get("http://myhost.invalid/")
assert r.status_code == 200 assert r.status_code == 200
assert r.text == os.path.abspath(SOCKPATH) assert r.text == os.path.abspath(SOCKPATH)
@ -179,8 +181,9 @@ async def test_zero_downtime():
from time import monotonic as current_time from time import monotonic as current_time
async def client(): async def client():
transport = httpcore.AsyncConnectionPool(uds=SOCKPATH)
for _ in range(40): for _ in range(40):
async with httpx.AsyncClient(uds=SOCKPATH) as client: async with httpx.AsyncClient(transport=transport) as client:
r = await client.get("http://localhost/sleep/0.1") r = await client.get("http://localhost/sleep/0.1")
assert r.status_code == 200 assert r.status_code == 200
assert r.text == f"Slept 0.1 seconds.\n" assert r.text == f"Slept 0.1 seconds.\n"

View File

@ -0,0 +1,36 @@
from pathlib import Path
import pytest
_test_setting_as_dict = {"TEST_SETTING_VALUE": 1}
_test_setting_as_class = type("C", (), {"TEST_SETTING_VALUE": 1})
_test_setting_as_module = str(
Path(__file__).parent / "static/app_test_config.py"
)
@pytest.mark.parametrize(
"conf_object",
[
_test_setting_as_dict,
_test_setting_as_class,
pytest.param(
_test_setting_as_module,
marks=pytest.mark.dependency(
depends=["test_load_module_from_file_location"],
scope="session",
),
),
],
ids=["from_dict", "from_class", "from_file"],
)
def test_update(app, conf_object):
app.update_config(conf_object)
assert app.config["TEST_SETTING_VALUE"] == 1
def test_update_from_lowercase_key(app):
d = {"test_setting_value": 1}
app.update_config(d)
assert "test_setting_value" not in app.config

View File

@ -12,12 +12,13 @@ deps =
pytest-cov pytest-cov
pytest-sanic pytest-sanic
pytest-sugar pytest-sugar
pytest-benchmark
pytest-dependency
httpcore==0.3.0 httpcore==0.3.0
httpx==0.11.1 httpx==0.15.4
chardet<=2.3.0 chardet<=2.3.0
beautifulsoup4 beautifulsoup4
gunicorn gunicorn
pytest-benchmark
uvicorn uvicorn
websockets>=8.1,<9.0 websockets>=8.1,<9.0
commands = commands =
@ -55,6 +56,9 @@ commands =
[pytest] [pytest]
filterwarnings = filterwarnings =
ignore:.*async with lock.* instead:DeprecationWarning ignore:.*async with lock.* instead:DeprecationWarning
addopts = --strict-markers
markers =
asyncio
[testenv:security] [testenv:security]
deps = deps =